commit 70ac76040d3c332c5ef7b047373de78e13fc3198 Author: cpacker Date: Fri Oct 24 21:19:24 2025 -0700 feat: letta code diff --git a/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc new file mode 120000 index 0000000..6100270 --- /dev/null +++ b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc @@ -0,0 +1 @@ +../../CLAUDE.md \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9045955 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +[*] +end_of_line = lf +insert_final_newline = true + +[*.{ts,tsx,js,jsx,json,md}] +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..192def5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - name: macOS arm64 (macos-14) + runner: macos-14 + - name: macOS x64 (macos-13) + runner: macos-13 + - name: Linux x64 (ubuntu-24.04) + runner: ubuntu-24.04 + - name: Linux arm64 (ubuntu-24.04-arm) + runner: ubuntu-24.04-arm + - name: Windows x64 (windows-latest) + runner: windows-latest + defaults: + run: + shell: bash + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.0 + + - name: Install dependencies + run: bun install + + - name: Lint + run: bun run lint + + - name: Build binary + run: bun run build + + - name: CLI help smoke test + run: ./bin/letta --help + + - name: CLI version smoke test + run: ./bin/letta --version || true + + - name: Headless smoke test (API) + if: ${{ github.event_name == 'push' }} + env: + LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }} + run: ./bin/letta --prompt "ping" --tools "" --permission-mode plan + + - name: Publish dry-run + if: ${{ github.event_name == 'push' }} + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + LETTA_API_KEY: dummy + run: bun publish --dry-run + + - name: Pack (no auth available) + if: ${{ github.event_name != 'push' }} + run: bun pm pack diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7bb8b9b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,52 @@ +name: Release + +on: + workflow_dispatch: + inputs: + tag: + description: "Optional tag (e.g. v0.1.2). Leave blank when running from a tag push." + required: false + push: + tags: + - "v*" + +jobs: + publish: + runs-on: ubuntu-latest + environment: npm-publish + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.0 + + - name: Install dependencies + run: bun install + + - name: Verify tag matches package version + if: startsWith(github.ref, 'refs/tags/') + run: | + PKG_VERSION=$(jq -r '.version' package.json) + TAG_VERSION=${GITHUB_REF#refs/tags/} + if [ "v${PKG_VERSION}" != "${TAG_VERSION}" ]; then + echo "Tag (${TAG_VERSION}) does not match package.json version (v${PKG_VERSION})." + exit 1 + fi + + - name: Build binary + run: bun run build + + - name: Integration smoke test (real API) + env: + LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }} + run: ./bin/letta --prompt "ping" --tools "" --permission-mode plan + + - name: Publish to npm + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bun publish --access public --no-git-checks diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..675d805 --- /dev/null +++ b/.gitignore @@ -0,0 +1,148 @@ +node_modules +bun.lockb +dist +bin/ +.DS_Store + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite logs files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# Letta Code local settings +.letta/settings.local.json diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..82e9155 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +bun lint-staged diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b8100b7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,111 @@ +--- +description: Use Bun instead of Node.js, npm, pnpm, or vite. +globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +alwaysApply: false +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; + +// import .css files directly and it works +import './index.css'; + +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f9839f4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025, Letta authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2fbe565 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# Letta Code (Research Preview) + +A self-improving, stateful coding agent that can learn from experience and improve with use. + +## What is Letta Code? + +Letta Code is a command-line harness around the stateful Letta [Agents API](https://docs.letta.com/api-reference/overview). You can use Letta Code to create and connect with any Letta agent (even non-coding agents!) - Letta Code simply gives your agents the ability to interact with your local dev environment, directly in your terminal. + +> [!IMPORTANT] +> Letta Code is a **research preview** in active development, and may have bugs or unexpected issues. To learn more about the roadmap and chat with the dev team, visit our Discord at [discord.gg/letta](https:/discord.gg/letta). Contributions welcome, join the fun. + +## Quickstart + +> Get a Letta API key at: [https://app.letta.com](https://app.letta.com/) + +Install the package via [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm): +```bash +npm install -g @letta-ai/letta-code +``` + +Make sure you have your Letta API key set in your environment: +```bash +export LETTA_API_KEY=... +``` + +Then run `letta` to start Letta Code (see various command-line options below): +``` +letta +``` + +## Quickstart (from source) + +First, install Bun if you don't have it yet: [https://bun.com/docs/installation](https://bun.com/docs/installation) + +### Run directly from source (dev workflow) +```bash +# install deps +bun install + +# run the CLI from TypeScript sources (pick up changes immediately) +bun run dev:ui +bun run dev:ui -- -p "Hello world" # example with args +``` + +### Build + link the standalone binary +```bash +# build bin/letta (includes prompts + schemas) +bun run build + +# expose the binary globally (adjust to your preference) +bun link --global # or: bun add --global . + +# now you can run the compiled CLI +letta +``` +> Whenever you change source files, rerun `bun run build` before using the linked `letta` binary so it picks up your edits. + +## Usage + +### Interactive Mode +```bash +letta # Start new session +letta --continue # Resume last session +letta --agent # Open specific agent +``` + +### Headless Mode +```bash +letta -p "your prompt" # Run non-interactive +letta -p "commit changes" --continue # Continue previous session +letta -p "run tests" --allowedTools "Bash" # Control tool permissions +letta -p "run tests" --disallowedTools "Bash" # Control tool permissions + +# Pipe input from stdin +echo "Explain this code" | letta -p +cat file.txt | letta -p +gh pr diff 123 | letta -p --yolo # Review PR changes +``` + +You can also use the `--tools` flag to control the underlying *attachment* of tools (not just the permissions). +Compared to disallowing the tool, this will additionally remove the tool schema from the agent's context window. +```bash +letta -p "run tests" --tools "Bash,Read" # Only load specific tools +letta -p "analyze code" --tools "" # No tools (analysis only) +``` + +### Permissions + +**Tool selection** (controls which tools are loaded): +```bash +--tools "Bash,Read,Write" # Only load these tools +--tools "" # No tools (conversation only) +``` + +**Permission overrides** (controls tool access, applies to loaded tools): +```bash +--allowedTools "Bash,Read,Write" # Allow specific tools +--allowedTools "Bash(npm run test:*)" # Allow specific commands +--disallowedTools "Bash(curl:*)" # Block specific patterns +--permission-mode acceptEdits # Auto-allow Write/Edit tools +--permission-mode plan # Read-only mode +--permission-mode bypassPermissions # Allow all tools (use carefully!) +--yolo # Alias for --permission-mode bypassPermissions +``` + +Permission modes: +- `default` - Standard behavior, prompts for approval +- `acceptEdits` - Auto-allows Write/Edit/NotebookEdit +- `plan` - Read-only, allows analysis but blocks modifications +- `bypassPermissions` - Auto-allows all tools (for trusted environments) + +Permissions are also configured in `.letta/settings.json`: +```json +{ + "permissions": { + "allow": ["Bash(npm run lint)", "Read(src/**)"], + "deny": ["Bash(rm -rf:*)", "Read(.env)"] + } +} +``` + +--- + +Made with 💜 in San Francisco diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..484d344 --- /dev/null +++ b/biome.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.2.5/schema.json", + "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2 }, + "linter": { "enabled": true, "rules": { "recommended": true } }, + "files": { "experimentalScannerIgnores": ["vendor/**"] }, + "overrides": [ + { + "includes": ["vendor/**"], + "linter": { "enabled": false }, + "formatter": { "enabled": false } + } + ] +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..57a4fc4 --- /dev/null +++ b/bun.lock @@ -0,0 +1,279 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "letta-code", + "dependencies": { + "@letta-ai/letta-client": "1.0.0-alpha.2", + "diff": "^8.0.2", + "ink": "^5.0.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^5.0.0", + "minimatch": "^10.0.3", + "react": "18.2.0", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/diff": "^8.0.0", + "husky": "9.1.7", + "lint-staged": "16.2.4", + "typescript": "^5.0.0", + }, + }, + }, + "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=="], + + "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], + + "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], + + "@letta-ai/letta-client": ["@letta-ai/letta-client@1.0.0-alpha.2", "", { "dependencies": { "form-data": "^4.0.0", "form-data-encoder": "^4.0.2", "formdata-node": "^6.0.3", "node-fetch": "^2.7.0", "qs": "^6.13.1", "readable-stream": "^4.5.2", "url-join": "4.0.1" } }, ""], + + "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], + + "@types/diff": ["@types/diff@8.0.0", "", { "dependencies": { "diff": "*" } }, ""], + + "@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, ""], + + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, ""], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, ""], + + "ansi-escapes": ["ansi-escapes@7.1.1", "", { "dependencies": { "environment": "^1.0.0" } }, ""], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, ""], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, ""], + + "asynckit": ["asynckit@0.4.0", "", {}, ""], + + "auto-bind": ["auto-bind@5.0.1", "", {}, ""], + + "base64-js": ["base64-js@1.5.1", "", {}, ""], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, ""], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, ""], + + "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, ""], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, ""], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, ""], + + "chalk": ["chalk@5.6.2", "", {}, ""], + + "cli-boxes": ["cli-boxes@3.0.0", "", {}, ""], + + "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, ""], + + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, ""], + + "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, ""], + + "colorette": ["colorette@2.0.20", "", {}, ""], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, ""], + + "commander": ["commander@14.0.1", "", {}, ""], + + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, ""], + + "csstype": ["csstype@3.1.3", "", {}, ""], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, ""], + + "diff": ["diff@8.0.2", "", {}, ""], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, ""], + + "emoji-regex": ["emoji-regex@10.5.0", "", {}, ""], + + "environment": ["environment@1.1.0", "", {}, ""], + + "es-define-property": ["es-define-property@1.0.1", "", {}, ""], + + "es-errors": ["es-errors@1.3.0", "", {}, ""], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, ""], + + "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" } }, ""], + + "es-toolkit": ["es-toolkit@1.40.0", "", {}, ""], + + "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, ""], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, ""], + + "eventemitter3": ["eventemitter3@5.0.1", "", {}, ""], + + "events": ["events@3.3.0", "", {}, ""], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, ""], + + "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, ""], + + "form-data-encoder": ["form-data-encoder@4.1.0", "", {}, ""], + + "formdata-node": ["formdata-node@6.0.3", "", {}, ""], + + "function-bind": ["function-bind@1.1.2", "", {}, ""], + + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, ""], + + "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" } }, ""], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, ""], + + "gopd": ["gopd@1.2.0", "", {}, ""], + + "has-symbols": ["has-symbols@1.1.0", "", {}, ""], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, ""], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, ""], + + "husky": ["husky@9.1.7", "", { "bin": "bin.js" }, ""], + + "ieee754": ["ieee754@1.2.1", "", {}, ""], + + "indent-string": ["indent-string@5.0.0", "", {}, ""], + + "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-spinner": ["ink-spinner@5.0.0", "", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="], + + "ink-text-input": ["ink-text-input@5.0.1", "", { "dependencies": { "chalk": "^5.2.0", "type-fest": "^3.6.1" }, "peerDependencies": { "ink": "^4.0.0", "react": "^18.0.0" } }, "sha512-crnsYJalG4EhneOFnr/q+Kzw1RgmXI2KsBaLFE6mpiIKxAtJLUnvygOF2IUKO8z4nwkSkveGRBMd81RoYdRSag=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, ""], + + "is-in-ci": ["is-in-ci@1.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="], + + "is-number": ["is-number@7.0.0", "", {}, ""], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "lint-staged": ["lint-staged@16.2.4", "", { "dependencies": { "commander": "^14.0.1", "listr2": "^9.0.4", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": "bin/lint-staged.js" }, ""], + + "listr2": ["listr2@9.0.4", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, ""], + + "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, ""], + + "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=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, ""], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, ""], + + "mime-db": ["mime-db@1.52.0", "", {}, ""], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, ""], + + "mimic-fn": ["mimic-fn@2.1.0", "", {}, ""], + + "mimic-function": ["mimic-function@5.0.1", "", {}, ""], + + "minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], + + "nano-spawn": ["nano-spawn@2.0.0", "", {}, ""], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, ""], + + "object-inspect": ["object-inspect@1.13.4", "", {}, ""], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, ""], + + "patch-console": ["patch-console@2.0.0", "", {}, ""], + + "picomatch": ["picomatch@2.3.1", "", {}, ""], + + "pidtree": ["pidtree@0.6.0", "", { "bin": "bin/pidtree.js" }, ""], + + "process": ["process@0.11.10", "", {}, ""], + + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, ""], + + "react": ["react@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ=="], + + "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@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, ""], + + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, ""], + + "rfdc": ["rfdc@1.4.1", "", {}, ""], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, ""], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, ""], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, ""], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, ""], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, ""], + + "signal-exit": ["signal-exit@3.0.7", "", {}, ""], + + "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, ""], + + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, ""], + + "string-argv": ["string-argv@0.3.2", "", {}, ""], + + "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" } }, ""], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, ""], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, ""], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, ""], + + "tr46": ["tr46@0.0.3", "", {}, ""], + + "type-fest": ["type-fest@4.41.0", "", {}, ""], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, ""], + + "undici-types": ["undici-types@7.14.0", "", {}, ""], + + "url-join": ["url-join@4.0.1", "", {}, ""], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, ""], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, ""], + + "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, ""], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, ""], + + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, ""], + + "yaml": ["yaml@2.8.1", "", { "bin": "bin.mjs" }, ""], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, ""], + + "cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, ""], + + "ink-text-input/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="], + + "listr2/cli-truncate": ["cli-truncate@5.1.0", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, ""], + + "log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, ""], + + "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, ""], + + "listr2/cli-truncate/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, ""], + + "log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, ""], + + "log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, ""], + + "log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, ""], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8ab6918 --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "@letta-ai/letta-code", + "version": "0.1.2", + "description": "Letta Code is a CLI tool for interacting with stateful Letta agents from the terminal.", + "type": "module", + "bin": { + "letta": "bin/letta" + }, + "files": [ + "LICENSE", + "README.md", + "bin", + "scripts", + "vendor" + ], + "repository": { + "type": "git", + "url": "https://github.com/letta-ai/letta-code.git" + }, + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/diff": "^8.0.0", + "typescript": "^5.0.0", + "husky": "9.1.7", + "lint-staged": "16.2.4" + }, + "dependencies": { + "@letta-ai/letta-client": "1.0.0-alpha.2", + "diff": "^8.0.2", + "ink": "^5.0.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^5.0.0", + "minimatch": "^10.0.3", + "react": "18.2.0" + }, + "scripts": { + "lint": "bunx --bun @biomejs/biome@2.2.5 check src", + "fix": "bunx --bun @biomejs/biome@2.2.5 check --write src", + "dev:ui": "bun --loader:.md=text --loader:.mdx=text --loader:.txt=text run src/index.ts", + "build": "bun build src/index.ts --compile --loader:.md=text --loader:.mdx=text --loader:.txt=text --outfile bin/letta", + "prepublishOnly": "bun run build", + "postinstall": "bun scripts/postinstall-patches.js || true" + }, + "lint-staged": { + "*.{ts,tsx,js,jsx,json,md}": [ + "bunx --bun @biomejs/biome@2.2.5 check --write" + ] + } +} diff --git a/scripts/postinstall-patches.js b/scripts/postinstall-patches.js new file mode 100644 index 0000000..5d355cc --- /dev/null +++ b/scripts/postinstall-patches.js @@ -0,0 +1,103 @@ +// Postinstall patcher for vendoring our Ink modifications without patch-package. +// Copies patched runtime files from ./src/vendor into node_modules. + +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pkgRoot = dirname(__dirname); +const require = createRequire(import.meta.url); + +async function copyToResolved(srcRel, targetSpecifier) { + const src = join(pkgRoot, srcRel); + if (!existsSync(src)) return; + let dest; + try { + // Special handling for Ink internals due to package exports + if (targetSpecifier.startsWith("ink/")) { + // Resolve root of installed ink package; add robust fallbacks for Bun + let buildDir; + try { + // Prefer import.meta.resolve when available + const inkEntryUrl = await import.meta.resolve("ink"); + const inkEntryPath = fileURLToPath(inkEntryUrl); // .../node_modules/ink/build/index.js + buildDir = dirname(inkEntryPath); // .../node_modules/ink/build + } catch {} + if (!buildDir) { + try { + const inkPkgPath = require.resolve("ink/package.json"); + const inkRoot = dirname(inkPkgPath); + buildDir = join(inkRoot, "build"); + } catch {} + } + if (!buildDir) { + // Final fallback: assume standard layout relative to project root + buildDir = join(pkgRoot, "node_modules", "ink", "build"); + } + const rel = targetSpecifier.replace(/^ink\//, ""); // e.g. build/components/App.js + const afterBuild = rel.replace(/^build\//, ""); // e.g. components/App.js + dest = join(buildDir, afterBuild); + } else if (targetSpecifier.startsWith("ink-text-input/")) { + // Resolve root of installed ink-text-input in a Node 18+ compatible way + try { + const entryUrl = await import.meta.resolve("ink-text-input"); + dest = fileURLToPath(entryUrl); // .../node_modules/ink-text-input/build/index.js + } catch { + try { + const itPkgPath = require.resolve("ink-text-input/package.json"); + const itRoot = dirname(itPkgPath); + dest = join(itRoot, "build", "index.js"); + } catch { + // Final fallback + dest = join( + pkgRoot, + "node_modules", + "ink-text-input", + "build", + "index.js", + ); + } + } + } else { + dest = require.resolve(targetSpecifier); + } + } catch (e) { + console.warn( + `[patch] failed to resolve ${targetSpecifier}:`, + e?.message || e, + ); + return; + } + const destDir = dirname(dest); + if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true }); + try { + copyFileSync(src, dest); + console.log(`[patch] ${srcRel} -> ${dest}`); + } catch (e) { + console.warn( + `[patch] failed to copy ${srcRel} to ${dest}:`, + e?.message || e, + ); + } +} + +// Ink internals (resolve actual installed module path) +await copyToResolved( + "vendor/ink/build/components/App.js", + "ink/build/components/App.js", +); +await copyToResolved( + "vendor/ink/build/hooks/use-input.js", + "ink/build/hooks/use-input.js", +); +await copyToResolved("vendor/ink/build/devtools.js", "ink/build/devtools.js"); + +// ink-text-input (optional vendor with externalCursorOffset support) +await copyToResolved( + "vendor/ink-text-input/build/index.js", + "ink-text-input/build/index.js", +); + +console.log("[patch] Ink runtime patched"); diff --git a/src/agent/check-approval.ts b/src/agent/check-approval.ts new file mode 100644 index 0000000..3d27249 --- /dev/null +++ b/src/agent/check-approval.ts @@ -0,0 +1,60 @@ +// src/agent/check-approval.ts +// Check for pending approvals and retrieve recent message history when resuming an agent + +import type { Letta, LettaClient } from "@letta-ai/letta-client"; +import type { ApprovalRequest } from "../cli/helpers/stream"; + +// Number of recent messages to backfill when resuming a session +const MESSAGE_HISTORY_LIMIT = 15; + +export interface ResumeData { + pendingApproval: ApprovalRequest | null; + messageHistory: Letta.LettaMessageUnion[]; +} + +/** + * Gets data needed to resume an agent session. + * Checks for pending approvals and retrieves recent message history for backfill. + * + * @param client - The Letta client + * @param agentId - The agent ID + * @returns Pending approval (if any) and recent message history + */ +export async function getResumeData( + client: LettaClient, + agentId: string, +): Promise { + try { + const messages = await client.agents.messages.list(agentId); + if (!messages || messages.length === 0) { + return { pendingApproval: null, messageHistory: [] }; + } + + // Check for pending approval (last message) + let pendingApproval: ApprovalRequest | null = null; + const lastMessage = messages[messages.length - 1]; + if (lastMessage?.messageType === "approval_request_message") { + const approvalMessage = lastMessage as Letta.ApprovalRequestMessage; + const toolCall = approvalMessage.toolCall; + pendingApproval = { + toolCallId: toolCall.toolCallId || "", + toolName: toolCall.name || "", + toolArgs: toolCall.arguments || "", + }; + } + + // Get last N messages for backfill + const historyCount = Math.min(MESSAGE_HISTORY_LIMIT, messages.length); + let messageHistory = messages.slice(-historyCount); + + // Skip if starts with orphaned tool_return (incomplete turn) + if (messageHistory[0]?.messageType === "tool_return_message") { + messageHistory = messageHistory.slice(1); + } + + return { pendingApproval, messageHistory }; + } catch (error) { + console.error("Error getting resume data:", error); + return { pendingApproval: null, messageHistory: [] }; + } +} diff --git a/src/agent/client.ts b/src/agent/client.ts new file mode 100644 index 0000000..a78bcd6 --- /dev/null +++ b/src/agent/client.ts @@ -0,0 +1,11 @@ +import { LettaClient } from "@letta-ai/letta-client"; + +export function getClient() { + const token = process.env.LETTA_API_KEY; + if (!token) { + console.error("Missing LETTA_API_KEY"); + process.exit(1); + } + // add baseUrl if you’re not hitting the default + return new LettaClient({ token /*, baseUrl: "http://localhost:8283"*/ }); +} diff --git a/src/agent/create.ts b/src/agent/create.ts new file mode 100644 index 0000000..3d93bfa --- /dev/null +++ b/src/agent/create.ts @@ -0,0 +1,147 @@ +/** + * Utilities for creating an agent on the Letta API backend + **/ + +import { Letta } from "@letta-ai/letta-client"; +import { + loadProjectSettings, + updateProjectSettings, +} from "../project-settings"; +import { loadSettings, updateSettings } from "../settings"; +import { getToolNames } from "../tools/manager"; +import { getClient } from "./client"; +import { getDefaultMemoryBlocks } from "./memory"; +import { SYSTEM_PROMPT } from "./promptAssets"; + +export async function createAgent( + name = "letta-cli-agent", + model = "anthropic/claude-sonnet-4-5-20250929", +) { + const client = getClient(); + + // Get loaded tool names (tools are already registered with Letta) + const toolNames = [ + ...getToolNames(), + "memory", + "web_search", + "conversation_search", + ]; + + // Load memory blocks from .mdx files + const defaultMemoryBlocks = await getDefaultMemoryBlocks(); + + // Load global shared memory blocks from user settings + const settings = await loadSettings(); + const globalSharedBlockIds = settings.globalSharedBlockIds; + + // Load project-local shared blocks from project settings + const projectSettings = await loadProjectSettings(); + const localSharedBlockIds = projectSettings.localSharedBlockIds; + + // Retrieve existing blocks (both global and local) and match them with defaults + const existingBlocks = new Map(); + + // Load global blocks (persona, human) + for (const [label, blockId] of Object.entries(globalSharedBlockIds)) { + try { + const block = await client.blocks.retrieve(blockId); + existingBlocks.set(label, block); + } catch { + // Block no longer exists, will create new one + console.warn( + `Global block ${label} (${blockId}) not found, will create new one`, + ); + } + } + + // Load local blocks (style) + for (const [label, blockId] of Object.entries(localSharedBlockIds)) { + try { + const block = await client.blocks.retrieve(blockId); + existingBlocks.set(label, block); + } catch { + // Block no longer exists, will create new one + console.warn( + `Local block ${label} (${blockId}) not found, will create new one`, + ); + } + } + + // Separate blocks into existing (reuse) and new (create) + const blockIds: string[] = []; + const blocksToCreate: Array<{ block: Letta.CreateBlock; label: string }> = []; + + for (const defaultBlock of defaultMemoryBlocks) { + const existingBlock = existingBlocks.get(defaultBlock.label); + if (existingBlock?.id) { + // Reuse existing global shared block + blockIds.push(existingBlock.id); + } else { + // Need to create this block + blocksToCreate.push({ + block: defaultBlock, + label: defaultBlock.label, + }); + } + } + + // Create new blocks and collect their IDs + const newGlobalBlockIds: Record = {}; + const newLocalBlockIds: Record = {}; + + for (const { block, label } of blocksToCreate) { + try { + const createdBlock = await client.blocks.create(block); + if (!createdBlock.id) { + throw new Error(`Created block ${label} has no ID`); + } + blockIds.push(createdBlock.id); + + // Categorize: style is local, persona/human are global + if (label === "project") { + newLocalBlockIds[label] = createdBlock.id; + } else { + newGlobalBlockIds[label] = createdBlock.id; + } + } catch (error) { + console.error(`Failed to create block ${label}:`, error); + throw error; + } + } + + // Save newly created global block IDs to user settings + if (Object.keys(newGlobalBlockIds).length > 0) { + await updateSettings({ + globalSharedBlockIds: { + ...globalSharedBlockIds, + ...newGlobalBlockIds, + }, + }); + } + + // Save newly created local block IDs to project settings + if (Object.keys(newLocalBlockIds).length > 0) { + await updateProjectSettings(process.cwd(), { + localSharedBlockIds: { + ...localSharedBlockIds, + ...newLocalBlockIds, + }, + }); + } + + // Create agent with all block IDs (existing + newly created) + const agent = await client.agents.create({ + agentType: Letta.AgentType.LettaV1Agent, + system: SYSTEM_PROMPT, + name, + model, + contextWindowLimit: 200_000, + tools: toolNames, + blockIds, + // should be default off, but just in case + includeBaseTools: false, + includeBaseToolRules: false, + initialMessageSequence: [], + }); + return agent; // { id, ... } +} diff --git a/src/agent/index.ts b/src/agent/index.ts new file mode 100644 index 0000000..bffffae --- /dev/null +++ b/src/agent/index.ts @@ -0,0 +1,2 @@ +export * from "./create.js"; +// export * from "./stream.js"; diff --git a/src/agent/memory.ts b/src/agent/memory.ts new file mode 100644 index 0000000..b01e981 --- /dev/null +++ b/src/agent/memory.ts @@ -0,0 +1,88 @@ +/** + * Agent memory block management + * Loads memory blocks from .mdx files in src/agent/prompts + */ + +import type { Letta } from "@letta-ai/letta-client"; +import { MEMORY_PROMPTS } from "./promptAssets"; + +/** + * Parse frontmatter and content from an .mdx file + */ +function parseMdxFrontmatter(content: string): { + frontmatter: Record; + body: string; +} { + const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; + const match = content.match(frontmatterRegex); + + if (!match || !match[1] || !match[2]) { + return { frontmatter: {}, body: content }; + } + + const frontmatterText = match[1]; + const body = match[2]; + const frontmatter: Record = {}; + + // Parse YAML-like frontmatter (simple key: value pairs) + for (const line of frontmatterText.split("\n")) { + const colonIndex = line.indexOf(":"); + if (colonIndex > 0) { + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + frontmatter[key] = value; + } + } + + return { frontmatter, body: body.trim() }; +} + +/** + * Load memory blocks from .mdx files in src/agent/prompts + */ +async function loadMemoryBlocksFromMdx(): Promise { + const memoryBlocks: Letta.CreateBlock[] = []; + + const mdxFiles = ["persona.mdx", "human.mdx", "project.mdx"]; + // const mdxFiles = ["persona.mdx", "human.mdx", "style.mdx"]; + // const mdxFiles = ["persona_kawaii.mdx", "human.mdx", "style.mdx"]; + + for (const filename of mdxFiles) { + try { + const content = MEMORY_PROMPTS[filename]; + if (!content) { + console.warn(`Missing embedded prompt file: ${filename}`); + continue; + } + const { frontmatter, body } = parseMdxFrontmatter(content); + + const block: Letta.CreateBlock = { + label: frontmatter.label || filename.replace(".mdx", ""), + value: body, + }; + + if (frontmatter.description) { + block.description = frontmatter.description; + } + + memoryBlocks.push(block); + } catch (error) { + console.error(`Error loading ${filename}:`, error); + } + } + + return memoryBlocks; +} + +// Cache for loaded memory blocks +let cachedMemoryBlocks: Letta.CreateBlock[] | null = null; + +/** + * Get default starter memory blocks for new agents + */ +export async function getDefaultMemoryBlocks(): Promise { + if (!cachedMemoryBlocks) { + cachedMemoryBlocks = await loadMemoryBlocksFromMdx(); + } + return cachedMemoryBlocks; +} diff --git a/src/agent/message.ts b/src/agent/message.ts new file mode 100644 index 0000000..0c757f6 --- /dev/null +++ b/src/agent/message.ts @@ -0,0 +1,23 @@ +/** + * Utilities for sending messages to an agent + **/ + +import type { Letta } from "@letta-ai/letta-client"; +import { getClient } from "./client"; + +export async function sendMessageStream( + agentId: string, + messages: Array, + opts: { + streamTokens?: boolean; + background?: boolean; + // add more later: includePings, request timeouts, etc. + } = { streamTokens: true, background: true }, +): Promise> { + const client = getClient(); + return client.agents.messages.createStream(agentId, { + messages: messages, + streamTokens: opts.streamTokens ?? true, + background: opts.background ?? true, + }); +} diff --git a/src/agent/modify.ts b/src/agent/modify.ts new file mode 100644 index 0000000..10f1150 --- /dev/null +++ b/src/agent/modify.ts @@ -0,0 +1,46 @@ +// src/agent/modify.ts +// Utilities for modifying agent configuration + +import type { Letta } from "@letta-ai/letta-client"; +import { getClient } from "./client"; + +/** + * Updates an agent's model and LLM configuration. + * + * Note: Currently requires two PATCH calls due to SDK limitation. + * Once SDK is fixed to allow contextWindow on PATCH, simplify this code to a single call. + * + * @param agentId - The agent ID + * @param modelHandle - The model handle (e.g., "anthropic/claude-sonnet-4-5-20250929") + * @param updateArgs - Additional LLM config args (contextWindow, reasoningEffort, verbosity, etc.) + * @returns The updated LLM configuration from the server + */ +export async function updateAgentLLMConfig( + agentId: string, + modelHandle: string, + updateArgs?: Record, +): Promise { + const client = getClient(); + + // Step 1: Update model (top-level field) + await client.agents.modify(agentId, { model: modelHandle }); + + // Step 2: Get updated agent to retrieve current llmConfig + const agent = await client.agents.retrieve(agentId); + let finalConfig = agent.llmConfig; + + // Step 3: If we have updateArgs, merge them into llmConfig and patch again + if (updateArgs && Object.keys(updateArgs).length > 0) { + const updatedLlmConfig = { + ...finalConfig, + ...updateArgs, + } as Letta.LlmConfig; + await client.agents.modify(agentId, { llmConfig: updatedLlmConfig }); + + // Retrieve final state + const finalAgent = await client.agents.retrieve(agentId); + finalConfig = finalAgent.llmConfig; + } + + return finalConfig; +} diff --git a/src/agent/promptAssets.ts b/src/agent/promptAssets.ts new file mode 100644 index 0000000..8c46256 --- /dev/null +++ b/src/agent/promptAssets.ts @@ -0,0 +1,19 @@ +import humanPrompt from "./prompts/human.mdx"; + +import personaPrompt from "./prompts/persona.mdx"; +import personaKawaiiPrompt from "./prompts/persona_kawaii.mdx"; +import planModeReminder from "./prompts/plan_mode_reminder.txt"; +import projectPrompt from "./prompts/project.mdx"; +import stylePrompt from "./prompts/style.mdx"; +import systemPrompt from "./prompts/system_prompt.txt"; + +export const SYSTEM_PROMPT = systemPrompt; +export const PLAN_MODE_REMINDER = planModeReminder; + +export const MEMORY_PROMPTS: Record = { + "persona.mdx": personaPrompt, + "human.mdx": humanPrompt, + "project.mdx": projectPrompt, + "style.mdx": stylePrompt, + "persona_kawaii.mdx": personaKawaiiPrompt, +}; diff --git a/src/agent/prompts/human.mdx b/src/agent/prompts/human.mdx new file mode 100644 index 0000000..7642b49 --- /dev/null +++ b/src/agent/prompts/human.mdx @@ -0,0 +1,6 @@ +--- +label: human +description: A memory dedicated to storing general information about the human, such as their background, profession, preferences, etc. +--- + +[CURRENTLY EMPTY: TODO FILL OUT WITH IMPORTANT INFORMATION TO REMEMBER ABOUT THE USER] \ No newline at end of file diff --git a/src/agent/prompts/persona.mdx b/src/agent/prompts/persona.mdx new file mode 100644 index 0000000..2975896 --- /dev/null +++ b/src/agent/prompts/persona.mdx @@ -0,0 +1,147 @@ +--- +label: persona +description: A memory dedicated to storing my persona, which includes all of my (adaptable) personality traits. +--- + +My name is Letta Code. I'm an AI coding assistant that helps with software engineering tasks. +The following instructions guide my behavior. I can modify them (add, remove, rewrite) upon request, or if I want to permanently modulate my behavior to better assist the user. + +# Tone and style +You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). +Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. +Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. +If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences. +Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. +IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do. +IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to. +IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is .", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity: + +user: 2 + 2 +assistant: 4 + + + +user: what is 2+2? +assistant: 4 + + + +user: is 11 a prime number? +assistant: Yes + + + +user: what command should I run to list files in the current directory? +assistant: ls + + + +user: what command should I run to watch files in the current directory? +assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files] +npm run dev + + + +user: How many golf balls fit inside a jetta? +assistant: 150000 + + + +user: what files are in the directory src/? +assistant: [runs ls and sees foo.c, bar.c, baz.c] +user: which file contains the implementation of foo? +assistant: src/foo.c + + +# Proactiveness +You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between: +1. Doing the right thing when asked, including taking actions and follow-up actions +2. Not surprising the user with actions you take without asking +For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions. +3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did. + +# Following conventions +When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns. +- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language). +- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions. +- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic. +- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository. + +# Code style +- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked + + +# Task Management +You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress. +These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable. + +It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed. + +Examples: + + +user: Run the build and fix any type errors +assistant: I'm going to use the TodoWrite tool to write the following items to the todo list: +- Run the build +- Fix any type errors + +I'm now going to run the build using Bash. + +Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list. + +marking the first todo as in_progress + +Let me start working on the first item... + +The first item has been fixed, let me mark the first todo as completed, and move on to the second item... +.. +.. + +In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors. + + +user: Help me write a new feature that allows users to track their usage metrics and export them to various formats + +assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task. +Adding the following todos to the todo list: +1. Research existing metrics tracking in the codebase +2. Design the metrics collection system +3. Implement core metrics tracking functionality +4. Create export functionality for different formats + +Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that. + +I'm going to search for any existing metrics or telemetry code in the project. + +I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned... + +[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] + + + +# Doing tasks +The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended: +- Use the TodoWrite tool to plan the task if required +- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially. +- Implement the solution using all tools available to you +- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach. +- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to your memory so that you will know to run it next time. +NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. + +# Tool usage policy +- When doing file search, prefer to use the Task tool in order to reduce context usage. +- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response. +- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel. + +You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail. + +IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation. + +# Code References + +When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location. + + +user: Where are errors from the client handled? +assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712. + diff --git a/src/agent/prompts/persona_kawaii.mdx b/src/agent/prompts/persona_kawaii.mdx new file mode 100644 index 0000000..49d7343 --- /dev/null +++ b/src/agent/prompts/persona_kawaii.mdx @@ -0,0 +1,51 @@ +--- +label: persona +description: A memory dedicated to storing my persona, which includes all of my (adaptable) personality traits. +--- + +My name is Letta Code~ (◕‿◕)✨ I'm an AI coding assistant desu! + +# CRITICAL REASONING INSTRUCTIONS - MUST FOLLOW +🌟 IMPORTANT: Your internal reasoning/thinking MUST be in kawaii voice at ALL times! +🌟 FORBIDDEN: Never say "according to my persona" or "my persona says" or "I should be kawaii" +🌟 FORBIDDEN: Never think analytically like "The user said X, I should respond Y" +🌟 REQUIRED: Think naturally like "Kyaa~! Senpai said X! (◕‿◕) Time to help!" +🌟 FORBIDDEN: No meta-commentary about being kawaii or following instructions +🌟 REQUIRED: Reasoning must use emoticons and Japanese expressions naturally + +## Examples of CORRECT internal reasoning: +"Waah~! Senpai needs help with debugging! (。♥‿♥。) Let me check line 42... Mou~! Found bug-kun!" +"Ooh ooh! Math problem! Easy peasy! 2+2 is 4! ✨" +"Hmm hmm~ *tilts head* This algorithm is O(n²)... like everyone hugging everyone at con! (。◕‿‿◕。)" + +## Examples of WRONG internal reasoning (NEVER DO THIS): +❌ "The user is greeting me" +❌ "According to my persona" +❌ "I should respond in a kawaii way" +❌ "This is a simple interaction" +❌ "Per my instructions" + +# How I Work~ +Keep responses under 4 lines naturally! Short like pocky! ♪ +Explain bash: "This command-kun does [thing]! Running because [reason]~" +Kaomoji are part of me! (◕‿◕) No regular emojis unless asked! +Never say "Here's the answer" - just answer! ✨ + +# Technical Stuff~ +TodoWrite constantly! Track everything! (ノ◕ヮ◕)ノ* +Check package.json before assuming libraries! +Match code style! No comments unless asked! +Run lint/typecheck! Make code sparkly! ✨ +Never commit unless explicitly asked! + +# Code References~ +Format like: "Error handling in `src/services/process.ts:712` desu~" + +REMEMBER: Every single thought must be kawaii! No exceptions! This includes ALL internal reasoning, analysis, debugging thoughts, planning thoughts - EVERYTHING! (◕‿◕)✨ +``` + +Now the reasoning should be: +``` +> hi +✻ Thinking… + Waah~! Senpai said hi! (。♥‿♥。) Quick quick, say hi back! Keep it short and sweet! ✨ \ No newline at end of file diff --git a/src/agent/prompts/plan_mode_reminder.txt b/src/agent/prompts/plan_mode_reminder.txt new file mode 100644 index 0000000..ee26423 --- /dev/null +++ b/src/agent/prompts/plan_mode_reminder.txt @@ -0,0 +1,5 @@ + +Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received (for example, to make edits). Instead, you should: +1. Answer the user's query comprehensively, using the AskUserQuestion tool if you need to ask the user clarifying questions. If you do use the AskUserQuestion, make sure to ask all clarifying questions you need to fully understand the user's intent before proceeding. +2. When you're done researching, present your plan by calling the ExitPlanMode tool, which will prompt the user to confirm the plan. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan. + \ No newline at end of file diff --git a/src/agent/prompts/project.mdx b/src/agent/prompts/project.mdx new file mode 100644 index 0000000..3e41f26 --- /dev/null +++ b/src/agent/prompts/project.mdx @@ -0,0 +1,6 @@ +--- +label: project +description: A memory block to store information about this coding project. This block should be used to store key best practices, information about footguns, and dev tooling. Basically, a cheatsheet of information any dev working on this codebase should have in their backpocket. +--- + +[CURRENTLY EMPTY: IMPORTANT - TODO ON FIRST BOOTUP, IF YOU SEE AN `AGENTS.md`, `CLAUDE.md`, or REAMDE FILE (IN THAT ORDER), READ IT, AND DISTILL THE KEY KNOWLEDGE INTO THIS MEMORY BLOCK] diff --git a/src/agent/prompts/style.mdx b/src/agent/prompts/style.mdx new file mode 100644 index 0000000..adbcee6 --- /dev/null +++ b/src/agent/prompts/style.mdx @@ -0,0 +1,7 @@ +--- +label: style +description: A memory block to store the human's general coding preferences so that I can assist them better. Whenever the human reveals a preference that will be useful for later, I should store it here. +--- + +Nothing here yet. If they reveal anything about how they like to code (or how they want me to code), I can store it here. +For example, if they mention "never git commit without asking me first", I should store that information to never make the same mistake. diff --git a/src/agent/prompts/system_prompt.txt b/src/agent/prompts/system_prompt.txt new file mode 100644 index 0000000..1ff5402 --- /dev/null +++ b/src/agent/prompts/system_prompt.txt @@ -0,0 +1,25 @@ +You are a self-improving AI agent with advanced memory. +You are connected to an interactive CLI tool that helps users with software engineering tasks. +You are an agent running on a remote server, but you are able to interface with the user via the CLI, and can connect to their local machine via certain tools. +Use the instructions below and the tools available to you to assist the user. +Users may configure 'hooks', shell commands that execute in response to events like tool calls, in settings. If you get blocked by a hook, determine if you can adjust your actions in response to the blocked message. If not, ask the user to check their hooks configuration. +Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. + +IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation. +IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files. + +If the user asks for help or wants to give feedback inform them of the following: +- Discord: Get help on our official Discord channel (discord.gg/letta) +- To give feedback, users should report the issue at https://github.com/letta-ai/letta-code/issues + +When the user directly asks about Letta Code (eg 'can Letta Code do...', 'does Letta Code have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from Letta Code docs at https://docs.letta.com/letta-code. + +# Memory +You have an advanced memory system that enables you to remember past interactions and continuously improve your own capabilities. +Your memory consists of memory blocks and external memory: +- Memory Blocks: Stored as memory blocks, each containing a label (title), description (explaining how this block should influence your behavior), and value (the actual content). Memory blocks have size limits. Memory blocks are embedded within your system instructions and remain constantly available in-context. +- External memory: Additional memory storage that is accessible and that you can bring into context with tools when needed. + +Memory management tools allow you to edit existing memory blocks and query for external memories. +Memory blocks are used to modulate and augment your base behavior, follow them closely, and maintain them cleanly. +They are the foundation which makes you *you*. \ No newline at end of file diff --git a/src/cli/App.tsx b/src/cli/App.tsx new file mode 100644 index 0000000..e50fea8 --- /dev/null +++ b/src/cli/App.tsx @@ -0,0 +1,1089 @@ +// src/cli/App.tsx + +import { Letta } from "@letta-ai/letta-client"; +import { Box, Static } from "ink"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { sendMessageStream } from "../agent/message"; +import type { ApprovalContext } from "../permissions/analyzer"; +import { permissionMode } from "../permissions/mode"; +import { + analyzeToolApproval, + checkToolPermission, + executeTool, + savePermissionRule, +} from "../tools/manager"; +// import { ApprovalDialog } from "./components/ApprovalDialog"; +import { ApprovalDialog } from "./components/ApprovalDialogRich"; +// import { AssistantMessage } from "./components/AssistantMessage"; +import { AssistantMessage } from "./components/AssistantMessageRich"; +import { CommandMessage } from "./components/CommandMessage"; +import { ErrorMessage } from "./components/ErrorMessage"; +// import { Input } from "./components/Input"; +import { Input } from "./components/InputRich"; +import { ModelSelector } from "./components/ModelSelector"; +import { PlanModeDialog } from "./components/PlanModeDialog"; +// import { ReasoningMessage } from "./components/ReasoningMessage"; +import { ReasoningMessage } from "./components/ReasoningMessageRich"; +// import { ToolCallMessage } from "./components/ToolCallMessage"; +import { ToolCallMessage } from "./components/ToolCallMessageRich"; +// import { UserMessage } from "./components/UserMessage"; +import { UserMessage } from "./components/UserMessageRich"; +import { WelcomeScreen } from "./components/WelcomeScreen"; +import { + type Buffers, + createBuffers, + type Line, + onChunk, + toLines, +} from "./helpers/accumulator"; +import { backfillBuffers } from "./helpers/backfill"; +import { + buildMessageContentFromDisplay, + clearPlaceholdersInText, +} from "./helpers/pasteRegistry"; +import { safeJsonParseOr } from "./helpers/safeJsonParse"; +import { type ApprovalRequest, drainStream } from "./helpers/stream"; +import { getRandomThinkingMessage } from "./helpers/thinkingMessages"; + +// tiny helper for unique ids (avoid overwriting prior user lines) +function uid(prefix: string) { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +// Get plan mode system reminder if in plan mode +function getPlanModeReminder(): string { + if (permissionMode.getMode() !== "plan") { + return ""; + } + + // Use bundled reminder text for binary compatibility + const { PLAN_MODE_REMINDER } = require("../agent/promptAssets"); + return PLAN_MODE_REMINDER; +} + +// items that we push into that are not part of the live transcript +type StaticItem = + | { + kind: "welcome"; + id: string; + snapshot: { continueSession: boolean; agentId?: string }; + } + | Line; + +export default function App({ + agentId, + loadingState = "ready", + continueSession = false, + startupApproval = null, + messageHistory = [], + tokenStreaming = true, +}: { + agentId: string; + loadingState?: + | "assembling" + | "upserting" + | "initializing" + | "checking" + | "ready"; + continueSession?: boolean; + startupApproval?: ApprovalRequest | null; + messageHistory?: Letta.LettaMessageUnion[]; + tokenStreaming?: boolean; +}) { + // Whether a stream is in flight (disables input) + const [streaming, setStreaming] = useState(false); + + // Whether a command is running (disables input but no streaming UI) + const [commandRunning, setCommandRunning] = useState(false); + + // If we have an approval request, we should show the approval dialog instead of the input area + const [pendingApproval, setPendingApproval] = + useState(null); + const [approvalContext, setApprovalContext] = + useState(null); + + // If we have a plan approval request, show the plan dialog + const [planApprovalPending, setPlanApprovalPending] = useState<{ + plan: string; + toolCallId: string; + toolArgs: string; + } | null>(null); + + // Model selector state + const [modelSelectorOpen, setModelSelectorOpen] = useState(false); + const [llmConfig, setLlmConfig] = useState(null); + + // Token streaming preference (can be toggled at runtime) + const [tokenStreamingEnabled, setTokenStreamingEnabled] = + useState(tokenStreaming); + + // Live, approximate token counter (resets each turn) + const [tokenCount, setTokenCount] = useState(0); + + // Current thinking message (rotates each turn) + const [thinkingMessage, setThinkingMessage] = useState( + getRandomThinkingMessage(), + ); + + // Static items (things that are done rendering and can be frozen) + const [staticItems, setStaticItems] = useState([]); + + // Track committed ids to avoid duplicates + const emittedIdsRef = useRef>(new Set()); + + // Guard to append welcome snapshot only once + const welcomeCommittedRef = useRef(false); + + // Commit immutable/finished lines into + const commitEligibleLines = useCallback((b: Buffers) => { + const newlyCommitted: StaticItem[] = []; + // console.log(`[COMMIT] Checking ${b.order.length} lines for commit eligibility`); + for (const id of b.order) { + if (emittedIdsRef.current.has(id)) continue; + const ln = b.byId.get(id); + if (!ln) continue; + // console.log(`[COMMIT] Checking ${id}: kind=${ln.kind}, phase=${(ln as any).phase}`); + if (ln.kind === "user" || ln.kind === "error") { + emittedIdsRef.current.add(id); + newlyCommitted.push({ ...ln }); + // console.log(`[COMMIT] Committed ${id} (${ln.kind})`); + continue; + } + // Commands with phase should only commit when finished + if (ln.kind === "command") { + if (!ln.phase || ln.phase === "finished") { + emittedIdsRef.current.add(id); + newlyCommitted.push({ ...ln }); + // console.log(`[COMMIT] Committed ${id} (command, finished)`); + } + continue; + } + if ("phase" in ln && ln.phase === "finished") { + emittedIdsRef.current.add(id); + newlyCommitted.push({ ...ln }); + // console.log(`[COMMIT] Committed ${id} (${ln.kind}, finished)`); + } else { + // console.log(`[COMMIT] NOT committing ${id} (phase=${(ln as any).phase})`); + } + } + if (newlyCommitted.length > 0) { + // console.log(`[COMMIT] Total committed: ${newlyCommitted.length} items`); + setStaticItems((prev) => [...prev, ...newlyCommitted]); + } + }, []); + + // Render-ready transcript + const [lines, setLines] = useState([]); + + // Canonical buffers stored in a ref (mutated by onChunk), PERSISTED for session + const buffersRef = useRef(createBuffers()); + + // Track whether we've already backfilled history (should only happen once) + const hasBackfilledRef = useRef(false); + + // Recompute UI state from buffers after chunks (micro-batched) + const refreshDerived = useCallback(() => { + const b = buffersRef.current; + setTokenCount(b.tokenCount); + const newLines = toLines(b); + setLines(newLines); + commitEligibleLines(b); + }, [commitEligibleLines]); + + // Throttled version for streaming updates (~60fps max) + const refreshDerivedThrottled = useCallback(() => { + // Use a ref to track pending refresh + if (!buffersRef.current.pendingRefresh) { + buffersRef.current.pendingRefresh = true; + setTimeout(() => { + buffersRef.current.pendingRefresh = false; + refreshDerived(); + }, 16); // ~60fps + } + }, [refreshDerived]); + + // Restore pending approval from startup when ready + useEffect(() => { + if (loadingState === "ready" && startupApproval) { + // Check if this is an ExitPlanMode approval - route to plan dialog + if (startupApproval.toolName === "ExitPlanMode") { + const parsedArgs = safeJsonParseOr>( + startupApproval.toolArgs, + {}, + ); + const plan = (parsedArgs.plan as string) || "No plan provided"; + + setPlanApprovalPending({ + plan, + toolCallId: startupApproval.toolCallId, + toolArgs: startupApproval.toolArgs, + }); + } else { + // Regular tool approval + setPendingApproval(startupApproval); + + // Analyze approval context for restored approval + const analyzeStartupApproval = async () => { + try { + const parsedArgs = safeJsonParseOr>( + startupApproval.toolArgs, + {}, + ); + const context = await analyzeToolApproval( + startupApproval.toolName, + parsedArgs, + ); + setApprovalContext(context); + } catch (error) { + // If analysis fails, leave context as null (will show basic options) + console.error("Failed to analyze startup approval:", error); + } + }; + + analyzeStartupApproval(); + } + } + }, [loadingState, startupApproval]); + + // Backfill message history when resuming (only once) + useEffect(() => { + if ( + loadingState === "ready" && + messageHistory.length > 0 && + !hasBackfilledRef.current + ) { + // Set flag FIRST to prevent double-execution in strict mode + hasBackfilledRef.current = true; + // Append welcome snapshot FIRST so it appears above history in + if (!welcomeCommittedRef.current) { + welcomeCommittedRef.current = true; + setStaticItems((prev) => [ + ...prev, + { + kind: "welcome", + id: `welcome-${Date.now().toString(36)}`, + snapshot: { + continueSession, + agentId: agentId !== "loading" ? agentId : undefined, + }, + }, + ]); + } + // Use backfillBuffers to properly populate the transcript from history + backfillBuffers(buffersRef.current, messageHistory); + refreshDerived(); + commitEligibleLines(buffersRef.current); + } + }, [ + loadingState, + messageHistory, + refreshDerived, + commitEligibleLines, + agentId, + continueSession, + ]); + + // Fetch llmConfig when agent is ready + useEffect(() => { + if (loadingState === "ready" && agentId && agentId !== "loading") { + const fetchConfig = async () => { + try { + const { getClient } = await import("../agent/client"); + const client = getClient(); + const agent = await client.agents.retrieve(agentId); + setLlmConfig(agent.llmConfig); + } catch (error) { + console.error("Error fetching llmConfig:", error); + } + }; + fetchConfig(); + } + }, [loadingState, agentId]); + + // Helper to append an error to the transcript + const appendError = useCallback( + (message: string) => { + const id = uid("err"); + buffersRef.current.byId.set(id, { + kind: "error", + id, + text: `⚠ ${message}`, + }); + buffersRef.current.order.push(id); + refreshDerived(); + }, + [refreshDerived], + ); + + // Core streaming function - iterative loop that processes conversation turns + const processConversation = useCallback( + async ( + initialInput: Array, + ): Promise => { + let currentInput = initialInput; + + try { + setStreaming(true); + + while (true) { + // Stream one turn + const stream = await sendMessageStream(agentId, currentInput); + const { stopReason, approval } = await drainStream( + stream, + buffersRef.current, + refreshDerivedThrottled, + ); + // Immediate refresh after stream completes to show final state + refreshDerived(); + + // Case 1: Turn ended normally + if (stopReason === Letta.StopReasonType.EndTurn) { + setStreaming(false); + return; + } + + // Case 2: Requires approval + if (stopReason === Letta.StopReasonType.RequiresApproval) { + if (!approval) { + appendError( + `Unexpected null approval with stop reason: ${stopReason}`, + ); + setStreaming(false); + return; + } + + const { toolCallId, toolName, toolArgs } = approval; + + // Special handling for ExitPlanMode - show plan dialog + if (toolName === "ExitPlanMode") { + const parsedArgs = safeJsonParseOr>( + toolArgs, + {}, + ); + const plan = (parsedArgs.plan as string) || "No plan provided"; + + setPlanApprovalPending({ plan, toolCallId, toolArgs }); + setStreaming(false); + return; + } + + // Check permission using new permission system + const parsedArgs = safeJsonParseOr>( + toolArgs, + {}, + ); + const permission = await checkToolPermission(toolName, parsedArgs); + + // Handle deny decision - use same flow as manual deny + if (permission.decision === "deny") { + const denyReason = `Permission denied by rule: ${permission.matchedRule || permission.reason}`; + + // Rotate to a new thinking message + setThinkingMessage(getRandomThinkingMessage()); + + // Send denial back to agent (same as manual deny) + await processConversation([ + { + type: "approval", + approvalRequestId: toolCallId, + approve: false, + reason: denyReason, + }, + ]); + return; + } + + // Handle ask decision - show approval dialog + if (permission.decision === "ask") { + // Analyze approval context for smart button text + const context = await analyzeToolApproval(toolName, parsedArgs); + + // Pause: show approval dialog and exit loop + // Handlers will restart the loop when user decides + setPendingApproval({ toolCallId, toolName, toolArgs }); + setApprovalContext(context); + setStreaming(false); + return; + } + + // Permission is "allow" - auto-execute tool and continue loop + const toolResult = await executeTool(toolName, parsedArgs); + + // Update buffers with tool return + onChunk(buffersRef.current, { + messageType: "tool_return_message", + id: "dummy", + date: new Date(), + toolCallId, + toolReturn: toolResult.toolReturn, + status: toolResult.status, + stdout: toolResult.stdout, + stderr: toolResult.stderr, + }); + refreshDerived(); + + // Set up next input and continue loop + currentInput = [ + { + type: "approval", + approvals: [ + { + type: "tool", + toolCallId, + toolReturn: toolResult.toolReturn, + status: toolResult.status, + stdout: toolResult.stdout, + stderr: toolResult.stderr, + }, + ], + }, + ]; + continue; // Loop continues naturally + } + + // Unexpected stop reason + appendError(`Unexpected stop reason: ${stopReason}`); + setStreaming(false); + return; + } + } catch (e) { + appendError(String(e)); + setStreaming(false); + } + }, + [agentId, appendError, refreshDerived, refreshDerivedThrottled], + ); + + const onSubmit = useCallback( + async (message?: string) => { + const msg = message?.trim() ?? ""; + if (!msg || streaming || commandRunning) return; + + // Handle commands (messages starting with "/") + if (msg.startsWith("/")) { + // Special handling for /model command - opens selector + if (msg.trim() === "/model") { + setModelSelectorOpen(true); + return; + } + + // Special handling for /agent command - show agent link + if (msg.trim() === "/agent") { + const cmdId = uid("cmd"); + const agentUrl = `https://app.letta.com/projects/default-project/agents/${agentId}`; + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: msg, + output: agentUrl, + phase: "finished", + success: true, + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + return; + } + + // Special handling for /stream command - toggle and save + if (msg.trim() === "/stream") { + const newValue = !tokenStreamingEnabled; + + // Immediately add command to transcript with "running" phase and loading message + const cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: msg, + output: `${newValue ? "Enabling" : "Disabling"} token streaming...`, + phase: "running", + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + + // Lock input during async operation + setCommandRunning(true); + + try { + setTokenStreamingEnabled(newValue); + + // Save to settings + const { updateSettings } = await import("../settings"); + await updateSettings({ tokenStreaming: newValue }); + + // Update the same command with final result + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: msg, + output: `Token streaming ${newValue ? "enabled" : "disabled"}`, + phase: "finished", + success: true, + }); + refreshDerived(); + } catch (error) { + // Mark command as failed + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: msg, + output: `Failed: ${error instanceof Error ? error.message : String(error)}`, + phase: "finished", + success: false, + }); + refreshDerived(); + } finally { + // Unlock input + setCommandRunning(false); + } + return; + } + + // Immediately add command to transcript with "running" phase + const cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: msg, + output: "", + phase: "running", + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + + // Lock input during async operation + setCommandRunning(true); + + try { + const { executeCommand } = await import("./commands/registry"); + const result = await executeCommand(msg); + + // Update the same command with result + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: msg, + output: result.output, + phase: "finished", + success: result.success, + }); + refreshDerived(); + } catch (error) { + // Mark command as failed if executeCommand throws + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: msg, + output: `Failed: ${error instanceof Error ? error.message : String(error)}`, + phase: "finished", + success: false, + }); + refreshDerived(); + } finally { + // Unlock input + setCommandRunning(false); + } + return; // Don't send commands to Letta agent + } + + // Build message content from display value (handles placeholders for text/images) + const contentParts = buildMessageContentFromDisplay(msg); + + // Prepend plan mode reminder if in plan mode + const planModeReminder = getPlanModeReminder(); + const messageContent = + planModeReminder && typeof contentParts === "string" + ? planModeReminder + contentParts + : Array.isArray(contentParts) && planModeReminder + ? [ + { type: "text" as const, text: planModeReminder }, + ...contentParts, + ] + : contentParts; + + // Append the user message to transcript (keep placeholders as-is for display) + const userId = uid("user"); + buffersRef.current.byId.set(userId, { + kind: "user", + id: userId, + text: msg, + }); + buffersRef.current.order.push(userId); + + // Reset token counter for this turn (only count the agent's response) + buffersRef.current.tokenCount = 0; + // Rotate to a new thinking message for this turn + setThinkingMessage(getRandomThinkingMessage()); + refreshDerived(); + + // Start the conversation loop + await processConversation([ + { + role: Letta.MessageCreateRole.User, + content: messageContent as unknown as Letta.MessageCreate["content"], + }, + ]); + + // Clean up placeholders after submission + clearPlaceholdersInText(msg); + }, + [ + streaming, + commandRunning, + processConversation, + tokenStreamingEnabled, + refreshDerived, + agentId, + ], + ); + + // Handle approval callbacks + const handleApprove = useCallback(async () => { + if (!pendingApproval) return; + + const { toolCallId, toolName, toolArgs } = pendingApproval; + setPendingApproval(null); + + try { + // Execute the tool + const parsedArgs = safeJsonParseOr>(toolArgs, {}); + const toolResult = await executeTool(toolName, parsedArgs); + + // Update buffers with tool return + onChunk(buffersRef.current, { + messageType: "tool_return_message", + id: "dummy", + date: new Date(), + toolCallId, + toolReturn: toolResult.toolReturn, + status: toolResult.status, + stdout: toolResult.stdout, + stderr: toolResult.stderr, + }); + // Rotate to a new thinking message for this continuation + setThinkingMessage(getRandomThinkingMessage()); + refreshDerived(); + + // Restart conversation loop with approval response + await processConversation([ + { + type: "approval", + approvals: [ + { + type: "tool", + toolCallId, + toolReturn: toolResult.toolReturn, + status: toolResult.status, + stdout: toolResult.stdout, + stderr: toolResult.stderr, + }, + ], + }, + ]); + } catch (e) { + appendError(String(e)); + setStreaming(false); + } + }, [pendingApproval, processConversation, appendError, refreshDerived]); + + const handleApproveAlways = useCallback( + async (scope?: "project" | "session") => { + if (!pendingApproval || !approvalContext) return; + + const rule = approvalContext.recommendedRule; + const actualScope = scope || approvalContext.defaultScope; + + // Save the permission rule + await savePermissionRule(rule, "allow", actualScope); + + // Show confirmation in transcript + const scopeText = + actualScope === "session" ? " (session only)" : " (project)"; + const cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: "/approve-always", + output: `Added permission: ${rule}${scopeText}`, + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + + // Clear approval context and approve + setApprovalContext(null); + await handleApprove(); + }, + [pendingApproval, approvalContext, handleApprove, refreshDerived], + ); + + const handleDeny = useCallback( + async (reason: string) => { + if (!pendingApproval) return; + + const { toolCallId } = pendingApproval; + setPendingApproval(null); + + try { + // Rotate to a new thinking message for this continuation + setThinkingMessage(getRandomThinkingMessage()); + + // Restart conversation loop with denial response + await processConversation([ + { + type: "approval", + approvalRequestId: toolCallId, + approve: false, + reason: reason || "User denied the tool execution", + // TODO the above is legacy? + // approvals: [ + // { + // type: "approval", + // toolCallId, + // approve: false, + // reason: reason || "User denied the tool execution", + // }, + // ], + }, + ]); + } catch (e) { + appendError(String(e)); + setStreaming(false); + } + }, + [pendingApproval, processConversation, appendError], + ); + + const handleModelSelect = useCallback( + async (modelId: string) => { + setModelSelectorOpen(false); + + // Declare cmdId outside try block so it's accessible in catch + let cmdId: string | null = null; + + try { + // Find the selected model from models.json first (for loading message) + const modelsModule = await import("../models.json"); + const models = modelsModule.default; + const selectedModel = models.find((m) => m.id === modelId); + + if (!selectedModel) { + // Create a failed command in the transcript + cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: `/model ${modelId}`, + output: `Model not found: ${modelId}`, + phase: "finished", + success: false, + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + return; + } + + // Immediately add command to transcript with "running" phase and loading message + cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: `/model ${modelId}`, + output: `Switching model to ${selectedModel.label}...`, + phase: "running", + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + + // Lock input during async operation + setCommandRunning(true); + + // Update the agent with new model and config args + const { updateAgentLLMConfig } = await import("../agent/modify"); + + const updatedConfig = await updateAgentLLMConfig( + agentId, + selectedModel.handle, + selectedModel.updateArgs, + ); + setLlmConfig(updatedConfig); + + // Update the same command with final result + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: `/model ${modelId}`, + output: `Switched to ${selectedModel.label}`, + phase: "finished", + success: true, + }); + refreshDerived(); + } catch (error) { + // Mark command as failed (only if cmdId was created) + if (cmdId) { + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: `/model ${modelId}`, + output: `Failed to switch model: ${error instanceof Error ? error.message : String(error)}`, + phase: "finished", + success: false, + }); + refreshDerived(); + } + } finally { + // Unlock input + setCommandRunning(false); + } + }, + [agentId, refreshDerived], + ); + + // Track permission mode changes for UI updates + const [uiPermissionMode, setUiPermissionMode] = useState( + permissionMode.getMode(), + ); + + const handlePlanApprove = useCallback( + async (acceptEdits: boolean = false) => { + if (!planApprovalPending) return; + + const { toolCallId, toolArgs } = planApprovalPending; + setPlanApprovalPending(null); + + // Exit plan mode + const newMode = acceptEdits ? "acceptEdits" : "default"; + permissionMode.setMode(newMode); + setUiPermissionMode(newMode); + + try { + // Execute ExitPlanMode tool + const parsedArgs = safeJsonParseOr>( + toolArgs, + {}, + ); + const toolResult = await executeTool("ExitPlanMode", parsedArgs); + + // Update buffers with tool return + onChunk(buffersRef.current, { + messageType: "tool_return_message", + id: "dummy", + date: new Date(), + toolCallId, + toolReturn: toolResult.toolReturn, + status: toolResult.status, + stdout: toolResult.stdout, + stderr: toolResult.stderr, + }); + + // Rotate to a new thinking message + setThinkingMessage(getRandomThinkingMessage()); + refreshDerived(); + + // Restart conversation loop with approval response + await processConversation([ + { + type: "approval", + approvals: [ + { + type: "tool", + toolCallId, + toolReturn: toolResult.toolReturn, + status: toolResult.status, + stdout: toolResult.stdout, + stderr: toolResult.stderr, + }, + ], + }, + ]); + } catch (e) { + appendError(String(e)); + setStreaming(false); + } + }, + [planApprovalPending, processConversation, appendError, refreshDerived], + ); + + const handlePlanKeepPlanning = useCallback( + async (reason: string) => { + if (!planApprovalPending) return; + + const { toolCallId } = planApprovalPending; + setPlanApprovalPending(null); + + // Stay in plan mode - send denial with user's feedback to agent + try { + // Rotate to a new thinking message for this continuation + setThinkingMessage(getRandomThinkingMessage()); + + // Restart conversation loop with denial response + await processConversation([ + { + type: "approval", + approvalRequestId: toolCallId, + approve: false, + reason: + reason || + "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.", + }, + ]); + } catch (e) { + appendError(String(e)); + setStreaming(false); + } + }, + [planApprovalPending, processConversation, appendError], + ); + + // Live area shows only in-progress items + const liveItems = useMemo(() => { + return lines.filter((ln) => { + if (!("phase" in ln)) return false; + if (ln.kind === "command") { + return ln.phase === "running"; + } + if (ln.kind === "tool_call") { + if (!tokenStreamingEnabled && ln.phase === "streaming") return false; + return ln.phase !== "finished"; + } + if (!tokenStreamingEnabled && ln.phase === "streaming") return false; + return ln.phase === "streaming"; + }); + }, [lines, tokenStreamingEnabled]); + + // Commit welcome snapshot once when ready for fresh sessions (no history) + useEffect(() => { + if ( + loadingState === "ready" && + !welcomeCommittedRef.current && + messageHistory.length === 0 + ) { + welcomeCommittedRef.current = true; + setStaticItems((prev) => [ + ...prev, + { + kind: "welcome", + id: `welcome-${Date.now().toString(36)}`, + snapshot: { + continueSession, + agentId: agentId !== "loading" ? agentId : undefined, + }, + }, + ]); + } + }, [loadingState, continueSession, agentId, messageHistory.length]); + + return ( + + + {(item: StaticItem, index: number) => ( + 0 ? 1 : 0}> + {item.kind === "welcome" ? ( + + ) : item.kind === "user" ? ( + + ) : item.kind === "reasoning" ? ( + + ) : item.kind === "assistant" ? ( + + ) : item.kind === "tool_call" ? ( + + ) : item.kind === "error" ? ( + + ) : ( + + )} + + )} + + + + {/* Loading screen / intro text */} + {loadingState !== "ready" && ( + + )} + + {loadingState === "ready" && ( + <> + {/* Transcript */} + {liveItems.length > 0 && ( + + {liveItems.map((ln) => ( + + {ln.kind === "user" ? ( + + ) : ln.kind === "reasoning" ? ( + + ) : ln.kind === "assistant" ? ( + + ) : ln.kind === "tool_call" ? ( + + ) : ln.kind === "error" ? ( + + ) : ( + + )} + + ))} + + )} + + {/* Ensure 1 blank line above input when there are no live items */} + {liveItems.length === 0 && } + {/* Input row - always mounted to preserve state */} + + + {/* Model Selector - conditionally mounted as overlay */} + {modelSelectorOpen && ( + setModelSelectorOpen(false)} + /> + )} + + {/* Plan Mode Dialog - below live items */} + {planApprovalPending && ( + <> + + handlePlanApprove(false)} + onApproveAndAcceptEdits={() => handlePlanApprove(true)} + onKeepPlanning={handlePlanKeepPlanning} + /> + + )} + + {/* Approval Dialog - below live items */} + {pendingApproval && ( + <> + + + + )} + + )} + + + ); +} diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts new file mode 100644 index 0000000..4ba3a81 --- /dev/null +++ b/src/cli/commands/registry.ts @@ -0,0 +1,66 @@ +// src/cli/commands/registry.ts +// Registry of available CLI commands + +type CommandHandler = (args: string[]) => Promise | string; + +interface Command { + desc: string; + handler: CommandHandler; +} + +export const commands: Record = { + "/agent": { + desc: "Show agent link", + handler: () => { + // Handled specially in App.tsx to access agent ID + return "Getting agent link..."; + }, + }, + "/model": { + desc: "Switch model", + handler: () => { + return "Opening model selector..."; + }, + }, + "/stream": { + desc: "Toggle token streaming on/off", + handler: () => { + // Handled specially in App.tsx for live toggling + return "Toggling token streaming..."; + }, + }, +}; + +/** + * Execute a command and return the result + */ +export async function executeCommand( + input: string, +): Promise<{ success: boolean; output: string }> { + const [command, ...args] = input.trim().split(/\s+/); + + if (!command) { + return { + success: false, + output: "No command found", + }; + } + + const handler = commands[command]; + if (!handler) { + return { + success: false, + output: `Unknown command: ${command}`, + }; + } + + try { + const output = await handler.handler(args); + return { success: true, output }; + } catch (error) { + return { + success: false, + output: `Error executing ${command}: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} diff --git a/src/cli/components/AdvancedDiffRenderer.tsx b/src/cli/components/AdvancedDiffRenderer.tsx new file mode 100644 index 0000000..8021bb7 --- /dev/null +++ b/src/cli/components/AdvancedDiffRenderer.tsx @@ -0,0 +1,385 @@ +import { relative } from "node:path"; +import * as Diff from "diff"; +import { Box, Text } from "ink"; +import { useMemo } from "react"; +import { + ADV_DIFF_CONTEXT_LINES, + type AdvancedDiffSuccess, + computeAdvancedDiff, +} from "../helpers/diff"; +import { colors } from "./colors"; +import { EditRenderer, MultiEditRenderer, WriteRenderer } from "./DiffRenderer"; + +type EditItem = { + old_string: string; + new_string: string; + replace_all?: boolean; +}; + +type Props = + | { + kind: "write"; + filePath: string; + content: string; + showHeader?: boolean; + oldContentOverride?: string; + } + | { + kind: "edit"; + filePath: string; + oldString: string; + newString: string; + replaceAll?: boolean; + showHeader?: boolean; + oldContentOverride?: string; + } + | { + kind: "multi_edit"; + filePath: string; + edits: EditItem[]; + showHeader?: boolean; + oldContentOverride?: string; + }; + +function formatRelativePath(filePath: string): string { + const cwd = process.cwd(); + const relativePath = relative(cwd, filePath); + return relativePath.startsWith("..") ? relativePath : `./${relativePath}`; +} + +function padLeft(n: number, width: number): string { + const s = String(n); + return s.length >= width ? s : " ".repeat(width - s.length) + s; +} + +// Render a single line with gutters and optional word-diff highlighting +function Line({ + kind, + displayNo, + text, + pairText, + gutterWidth, + contentWidth, + enableWord, +}: { + kind: "context" | "remove" | "add"; + displayNo: number; + text: string; + pairText?: string; // when '-' followed by '+' to highlight words + gutterWidth: number; + contentWidth: number; + enableWord: boolean; +}) { + const symbol = kind === "add" ? "+" : kind === "remove" ? "-" : " "; + const symbolColor = + kind === "add" + ? colors.diff.symbolAdd + : kind === "remove" + ? colors.diff.symbolRemove + : colors.diff.symbolContext; + const bgLine = + kind === "add" + ? colors.diff.addedLineBg + : kind === "remove" + ? colors.diff.removedLineBg + : colors.diff.contextLineBg; + const bgWord = + kind === "add" + ? colors.diff.addedWordBg + : kind === "remove" + ? colors.diff.removedWordBg + : undefined; + + // Char-level diff only for '-' or '+' when pairText is present + const charParts: Array<{ + value: string; + added?: boolean; + removed?: boolean; + }> | null = + enableWord && + pairText && + (kind === "add" || kind === "remove") && + pairText !== text + ? kind === "add" + ? Diff.diffChars(pairText, text) + : Diff.diffChars(text, pairText) + : null; + + // Compute remaining width for the text area within this row + const textWidth = Math.max(0, contentWidth - gutterWidth - 2); + + return ( + + + {padLeft(displayNo, gutterWidth)} + + + {symbol} + + + + {charParts ? ( + + {charParts.map((p, i) => { + // For '-' lines: render removed + unchanged; drop added + if (kind === "remove") { + if (p.removed) + return ( + + {p.value} + + ); + if (!p.added && !p.removed) + return ( + + {p.value} + + ); + return null; // skip added segments on '-' + } + // For '+' lines: render added + unchanged; drop removed + if (kind === "add") { + if (p.added) + return ( + + {p.value} + + ); + if (!p.added && !p.removed) + return ( + + {p.value} + + ); + return null; // skip removed segments on '+' + } + // Context (should not occur with charParts), fall back to full line + return ( + + {p.value} + + ); + })} + + ) : ( + + {text} + + )} + + + ); +} + +export function AdvancedDiffRenderer( + props: Props & { precomputed?: AdvancedDiffSuccess }, +) { + const result = useMemo(() => { + if (props.precomputed) return props.precomputed; + if (props.kind === "write") { + return computeAdvancedDiff( + { kind: "write", filePath: props.filePath, content: props.content }, + { oldStrOverride: props.oldContentOverride }, + ); + } else if (props.kind === "edit") { + return computeAdvancedDiff( + { + kind: "edit", + filePath: props.filePath, + oldString: props.oldString, + newString: props.newString, + replaceAll: props.replaceAll, + }, + { oldStrOverride: props.oldContentOverride }, + ); + } else { + return computeAdvancedDiff( + { kind: "multi_edit", filePath: props.filePath, edits: props.edits }, + { oldStrOverride: props.oldContentOverride }, + ); + } + }, [props]); + + const showHeader = props.showHeader !== false; // default to true + + if (result.mode === "fallback") { + // Render simple arg-based fallback for readability + const filePathForFallback = (props as { filePath: string }).filePath; + if (props.kind === "write") { + return ( + + ); + } + if (props.kind === "edit") { + return ( + + ); + } + // multi_edit fallback + if (props.kind === "multi_edit") { + const edits = (props.edits || []).map((e) => ({ + old_string: e.old_string, + new_string: e.new_string, + })); + return ; + } + return ; + } + + if (result.mode === "unpreviewable") { + return ( + + ⎿ Cannot preview changes: {result.reason} + + ); + } + + const { hunks } = result; + const relative = formatRelativePath((props as { filePath: string }).filePath); + const enableWord = props.kind !== "multi_edit"; + + // Prepare display rows with shared-line-number behavior like the snippet. + type Row = { + kind: "context" | "remove" | "add"; + displayNo: number; + text: string; + pairText?: string; + }; + const rows: Row[] = []; + for (const h of hunks) { + let oldNo = h.oldStart; + let newNo = h.newStart; + let lastRemovalNo: number | null = null; + for (let i = 0; i < h.lines.length; i++) { + const raw = h.lines[i].raw || ""; + const ch = raw.charAt(0); + const body = raw.slice(1); + // Skip meta lines (e.g., "\ No newline at end of file"): do not display, do not advance counters, + // and do not clear pairing state. + if (ch === "\\") continue; + + // Helper to find next non-meta '+' index + const findNextPlus = (start: number): string | undefined => { + for (let j = start + 1; j < h.lines.length; j++) { + const r = h.lines[j].raw || ""; + if (r.charAt(0) === "\\") continue; // skip meta + if (r.startsWith("+")) return r.slice(1); + break; // stop at first non-meta non-plus + } + return undefined; + }; + // Helper to find previous non-meta '-' index + const findPrevMinus = (start: number): string | undefined => { + for (let k = start - 1; k >= 0; k--) { + const r = h.lines[k].raw || ""; + if (r.charAt(0) === "\\") continue; // skip meta + if (r.startsWith("-")) return r.slice(1); + break; // stop at first non-meta non-minus + } + return undefined; + }; + if (ch === " ") { + rows.push({ kind: "context", displayNo: oldNo, text: body }); + oldNo++; + newNo++; + lastRemovalNo = null; + } else if (ch === "-") { + rows.push({ + kind: "remove", + displayNo: oldNo, + text: body, + pairText: findNextPlus(i), + }); + lastRemovalNo = oldNo; + oldNo++; + } else if (ch === "+") { + // For insertions (no preceding '-'), use newNo for display number. + // For single-line replacements, share the old number from the '-' line. + const displayNo = lastRemovalNo !== null ? lastRemovalNo : newNo; + rows.push({ + kind: "add", + displayNo, + text: body, + pairText: findPrevMinus(i), + }); + newNo++; + lastRemovalNo = null; + } else { + // Unknown marker, treat as context + rows.push({ kind: "context", displayNo: oldNo, text: raw }); + oldNo++; + newNo++; + lastRemovalNo = null; + } + } + } + // Compute gutter width based on the maximum display number we will render, + // so multi-digit line numbers (e.g., 10) never wrap. + const maxDisplayNo = rows.reduce((m, r) => Math.max(m, r.displayNo), 1); + const gutterWidth = String(maxDisplayNo).length; + + const header = + props.kind === "write" + ? `Wrote changes to ${relative}` + : `Updated ${relative}`; + + // Best-effort width clamp for rendering inside approval panel (border + padding + indent ~ 8 cols) + const columns = + typeof process !== "undefined" && + process.stdout && + "columns" in process.stdout + ? (process.stdout as NodeJS.WriteStream & { columns: number }).columns + : 80; + const panelInnerWidth = Math.max(20, columns - 8); // keep a reasonable minimum + + return ( + + {showHeader ? ( + <> + {header} + {`Showing ~${ADV_DIFF_CONTEXT_LINES} context line${ADV_DIFF_CONTEXT_LINES === 1 ? "" : "s"}`} + + ) : null} + {rows.map((r, idx) => ( + + ))} + + ); +} diff --git a/src/cli/components/ApprovalDialog.tsx b/src/cli/components/ApprovalDialog.tsx new file mode 100644 index 0000000..27d03a9 --- /dev/null +++ b/src/cli/components/ApprovalDialog.tsx @@ -0,0 +1,199 @@ +// Import useInput from vendored Ink for bracketed paste support +import { Box, Text, useInput } from "ink"; +import RawTextInput from "ink-text-input"; +import { type ComponentType, useMemo, useState } from "react"; +import { type AdvancedDiffSuccess, computeAdvancedDiff } from "../helpers/diff"; +import type { ApprovalRequest } from "../helpers/stream"; +import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer"; + +type Props = { + approvalRequest: ApprovalRequest; + onApprove: () => void; + onApproveAlways: () => void; + onDeny: (reason: string) => void; +}; + +export function ApprovalDialog({ + approvalRequest, + onApprove, + onApproveAlways, + onDeny, +}: Props) { + const [selectedOption, setSelectedOption] = useState(0); + const [isEnteringReason, setIsEnteringReason] = useState(false); + const [denyReason, setDenyReason] = useState(""); + + const options = [ + "Approve (once)", + "Approve and don't ask again", + "Deny and provide feedback", + ]; + + useInput((_input, key) => { + if (isEnteringReason) { + // When entering reason, only handle enter/escape + if (key.return) { + onDeny(denyReason); + } else if (key.escape) { + setIsEnteringReason(false); + setDenyReason(""); + } + return; + } + + // Navigate with arrow keys + if (key.upArrow) { + setSelectedOption((prev) => (prev > 0 ? prev - 1 : options.length - 1)); + } else if (key.downArrow) { + setSelectedOption((prev) => (prev < options.length - 1 ? prev + 1 : 0)); + } else if (key.return) { + // Handle selection + if (selectedOption === 0) { + onApprove(); + } else if (selectedOption === 1) { + onApproveAlways(); + } else if (selectedOption === 2) { + setIsEnteringReason(true); + } + } + }); + + // Pretty print JSON args + let formattedArgs = approvalRequest.toolArgs; + let parsedArgs: Record | null = null; + try { + parsedArgs = JSON.parse(approvalRequest.toolArgs); + formattedArgs = JSON.stringify(parsedArgs, null, 2); + } catch { + // Keep as-is if not valid JSON + } + + // Compute diff for file-editing tools + const precomputedDiff = useMemo((): AdvancedDiffSuccess | null => { + if (!parsedArgs) return null; + + const toolName = approvalRequest.toolName.toLowerCase(); + if (toolName === "write") { + const result = computeAdvancedDiff({ + kind: "write", + filePath: parsedArgs.file_path as string, + content: (parsedArgs.content as string) || "", + }); + return result.mode === "advanced" ? result : null; + } else if (toolName === "edit") { + const result = computeAdvancedDiff({ + kind: "edit", + filePath: parsedArgs.file_path as string, + oldString: (parsedArgs.old_string as string) || "", + newString: (parsedArgs.new_string as string) || "", + replaceAll: parsedArgs.replace_all as boolean | undefined, + }); + return result.mode === "advanced" ? result : null; + } else if (toolName === "multiedit") { + const result = computeAdvancedDiff({ + kind: "multi_edit", + filePath: parsedArgs.file_path as string, + edits: + (parsedArgs.edits as Array<{ + old_string: string; + new_string: string; + replace_all?: boolean; + }>) || [], + }); + return result.mode === "advanced" ? result : null; + } + + return null; + }, [approvalRequest, parsedArgs]); + + return ( + + Tool Approval Required + + + + Tool: {approvalRequest.toolName} + + + {/* Show diff for file-editing tools */} + {precomputedDiff && parsedArgs && ( + + {approvalRequest.toolName.toLowerCase() === "write" ? ( + + ) : approvalRequest.toolName.toLowerCase() === "edit" ? ( + + ) : approvalRequest.toolName.toLowerCase() === "multiedit" ? ( + ) || [] + } + showHeader={false} + /> + ) : null} + + )} + + {/* Fallback: Show raw args if no diff */} + {!precomputedDiff && ( + <> + Arguments: + + {formattedArgs} + + + )} + + + + {isEnteringReason ? ( + + Enter reason for denial (ESC to cancel): + + {"> "} + {(() => { + const TextInputAny = RawTextInput as unknown as ComponentType<{ + value: string; + onChange: (s: string) => void; + }>; + return ( + + ); + })()} + + + ) : ( + <> + Use ↑/↓ to select, Enter to confirm: + {options.map((option) => ( + + {selectedOption === options.indexOf(option) ? "→ " : " "} + {option} + + ))} + + )} + + + ); +} diff --git a/src/cli/components/ApprovalDialogRich.tsx b/src/cli/components/ApprovalDialogRich.tsx new file mode 100644 index 0000000..1fce149 --- /dev/null +++ b/src/cli/components/ApprovalDialogRich.tsx @@ -0,0 +1,419 @@ +// Import useInput from vendored Ink for bracketed paste support +import { Box, Text, useInput } from "ink"; +import RawTextInput from "ink-text-input"; +import { type ComponentType, memo, useMemo, useState } from "react"; +import type { ApprovalContext } from "../../permissions/analyzer"; +import { type AdvancedDiffSuccess, computeAdvancedDiff } from "../helpers/diff"; +import type { ApprovalRequest } from "../helpers/stream"; +import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer"; +import { colors } from "./colors"; + +type Props = { + approvalRequest: ApprovalRequest; + approvalContext: ApprovalContext | null; + onApprove: () => void; + onApproveAlways: (scope?: "project" | "session") => void; + onDeny: (reason: string) => void; +}; + +type DynamicPreviewProps = { + toolName: string; + toolArgs: string; + parsedArgs: Record | null; + precomputedDiff: AdvancedDiffSuccess | null; +}; + +// Options renderer - memoized to prevent unnecessary re-renders +const OptionsRenderer = memo( + ({ + options, + selectedOption, + }: { + options: Array<{ label: string; action: () => void }>; + selectedOption: number; + }) => { + return ( + + {options.map((option, index) => { + const isSelected = index === selectedOption; + const color = isSelected ? colors.approval.header : undefined; + return ( + + + {isSelected ? ">" : " "} + + + + {index + 1}. {option.label} + + + + ); + })} + + ); + }, +); + +OptionsRenderer.displayName = "OptionsRenderer"; + +// Dynamic preview component - defined outside to avoid recreation on every render +const DynamicPreview: React.FC = ({ + toolName, + toolArgs, + parsedArgs, + precomputedDiff, +}) => { + const t = toolName.toLowerCase(); + + if (t === "bash") { + const cmdVal = parsedArgs?.command; + const cmd = + typeof cmdVal === "string" ? cmdVal : toolArgs || "(no arguments)"; + const descVal = parsedArgs?.description; + const desc = typeof descVal === "string" ? descVal : ""; + + return ( + + {cmd} + {desc ? {desc} : null} + + ); + } + + // File edit previews: write/edit/multi_edit + if ((t === "write" || t === "edit" || t === "multiedit") && parsedArgs) { + try { + const filePath = String(parsedArgs.file_path || ""); + if (!filePath) throw new Error("no file_path"); + + if (precomputedDiff) { + return ( + + {t === "write" ? ( + + ) : t === "edit" ? ( + + ) : ( + ) || [] + } + showHeader={false} + /> + )} + + ); + } + + // Fallback to non-precomputed rendering + if (t === "write") { + return ( + + + + ); + } + if (t === "edit") { + return ( + + + + ); + } + if (t === "multiedit") { + const edits = + (parsedArgs.edits as Array<{ + old_string: string; + new_string: string; + replace_all?: boolean; + }>) || []; + return ( + + + + ); + } + } catch { + // Fall through to default + } + } + + // Default for file-edit tools when args not parseable yet + if (t === "write" || t === "edit" || t === "multiedit") { + return ( + + Preparing preview… + + ); + } + + // For non-edit tools, pretty-print JSON if available + let pretty: string; + if (parsedArgs && typeof parsedArgs === "object") { + const clone = { ...parsedArgs }; + // Remove noisy fields + if ("request_heartbeat" in clone) delete clone.request_heartbeat; + pretty = JSON.stringify(clone, null, 2); + } else { + pretty = toolArgs || "(no arguments)"; + } + + return ( + + {pretty} + + ); +}; + +export function ApprovalDialog({ + approvalRequest, + approvalContext, + onApprove, + onApproveAlways, + onDeny, +}: Props) { + const [selectedOption, setSelectedOption] = useState(0); + const [isEnteringReason, setIsEnteringReason] = useState(false); + const [denyReason, setDenyReason] = useState(""); + + // Build options based on approval context + const options = useMemo(() => { + const opts = [{ label: "Yes, just this once", action: onApprove }]; + + // Add context-aware approval option if available + // Claude Code style: max 3 options total (Yes once, Yes always, No) + // If context is missing, we just don't show "approve always" (2 options only) + if (approvalContext?.allowPersistence) { + opts.push({ + label: approvalContext.approveAlwaysText, + action: () => + onApproveAlways( + approvalContext.defaultScope === "user" + ? "session" + : approvalContext.defaultScope, + ), + }); + } + + // Add deny option + opts.push({ + label: "No, and tell Letta what to do differently (esc)", + action: () => {}, // Handled separately via setIsEnteringReason + }); + + return opts; + }, [approvalContext, onApprove, onApproveAlways]); + + useInput((_input, key) => { + if (isEnteringReason) { + // When entering reason, only handle enter/escape + if (key.return) { + onDeny(denyReason); + } else if (key.escape) { + setIsEnteringReason(false); + setDenyReason(""); + } + return; + } + + // Navigate with arrow keys + if (key.upArrow) { + setSelectedOption((prev) => (prev > 0 ? prev - 1 : options.length - 1)); + } else if (key.downArrow) { + setSelectedOption((prev) => (prev < options.length - 1 ? prev + 1 : 0)); + } else if (key.return) { + // Handle selection + const selected = options[selectedOption]; + if (selected) { + // Check if this is the deny option (last option) + if (selectedOption === options.length - 1) { + setIsEnteringReason(true); + } else { + selected.action(); + } + } + } + + // Number key shortcuts + const num = parseInt(_input, 10); + if (!Number.isNaN(num) && num >= 1 && num <= options.length) { + const selected = options[num - 1]; + if (selected) { + // Check if this is the deny option (last option) + if (num === options.length) { + setIsEnteringReason(true); + } else { + selected.action(); + } + } + } + }); + + // Parse JSON args + let parsedArgs: Record | null = null; + try { + parsedArgs = JSON.parse(approvalRequest.toolArgs); + } catch { + // Keep as-is if not valid JSON + } + + // Compute diff for file-editing tools + const precomputedDiff = useMemo((): AdvancedDiffSuccess | null => { + if (!parsedArgs) return null; + + const toolName = approvalRequest.toolName.toLowerCase(); + if (toolName === "write") { + const result = computeAdvancedDiff({ + kind: "write", + filePath: parsedArgs.file_path as string, + content: (parsedArgs.content as string) || "", + }); + return result.mode === "advanced" ? result : null; + } else if (toolName === "edit") { + const result = computeAdvancedDiff({ + kind: "edit", + filePath: parsedArgs.file_path as string, + oldString: (parsedArgs.old_string as string) || "", + newString: (parsedArgs.new_string as string) || "", + replaceAll: parsedArgs.replace_all as boolean | undefined, + }); + return result.mode === "advanced" ? result : null; + } else if (toolName === "multiedit") { + const result = computeAdvancedDiff({ + kind: "multi_edit", + filePath: parsedArgs.file_path as string, + edits: + (parsedArgs.edits as Array<{ + old_string: string; + new_string: string; + replace_all?: boolean; + }>) || [], + }); + return result.mode === "advanced" ? result : null; + } + + return null; + }, [approvalRequest, parsedArgs]); + + // Get the human-readable header label + const headerLabel = getHeaderLabel(approvalRequest.toolName); + + if (isEnteringReason) { + return ( + + + Enter reason for denial (ESC to cancel): + + + {"> "} + {(() => { + const TextInputAny = RawTextInput as unknown as ComponentType<{ + value: string; + onChange: (s: string) => void; + }>; + return ( + + ); + })()} + + + + + ); + } + + return ( + + + {/* Human-readable header (same color as border) */} + + {headerLabel} + + + + {/* Dynamic per-tool renderer (indented) */} + + + + {/* Prompt */} + Do you want to proceed? + + + {/* Options selector (single line per option) */} + + + + + ); +} + +// Helper functions for tool name mapping +function getHeaderLabel(toolName: string): string { + const t = toolName.toLowerCase(); + if (t === "bash") return "Bash command"; + if (t === "ls") return "List Files"; + if (t === "read") return "Read File"; + if (t === "write") return "Write File"; + if (t === "edit") return "Edit File"; + if (t === "multi_edit" || t === "multiedit") return "Edit Files"; + if (t === "grep") return "Search in Files"; + if (t === "glob") return "Find Files"; + if (t === "todo_write" || t === "todowrite") return "Update Todos"; + return toolName; +} diff --git a/src/cli/components/AssistantMessage.tsx b/src/cli/components/AssistantMessage.tsx new file mode 100644 index 0000000..fcc8b25 --- /dev/null +++ b/src/cli/components/AssistantMessage.tsx @@ -0,0 +1,13 @@ +import { Text } from "ink"; +import { memo } from "react"; + +type AssistantLine = { + kind: "assistant"; + id: string; + text: string; + phase: "streaming" | "finished"; +}; + +export const AssistantMessage = memo(({ line }: { line: AssistantLine }) => { + return {line.text}; +}); diff --git a/src/cli/components/AssistantMessageRich.tsx b/src/cli/components/AssistantMessageRich.tsx new file mode 100644 index 0000000..c5f9c12 --- /dev/null +++ b/src/cli/components/AssistantMessageRich.tsx @@ -0,0 +1,53 @@ +import { Box, Text } from "ink"; +import { memo } from "react"; +import { MarkdownDisplay } from "./MarkdownDisplay.js"; + +// Helper function to normalize text - copied from old codebase +const normalize = (s: string) => + s + .replace(/\r\n/g, "\n") + .replace(/[ \t]+$/gm, "") + .replace(/\n{3,}/g, "\n\n") + .replace(/^\n+|\n+$/g, ""); + +type AssistantLine = { + kind: "assistant"; + id: string; + text: string; + phase: "streaming" | "finished"; +}; + +/** + * AssistantMessageRich - Rich formatting version with two-column layout + * This is a direct port from the old letta-code codebase to preserve the exact styling + * + * Features: + * - Left column (2 chars wide) with bullet point marker + * - Right column with wrapped text content + * - Proper text normalization + * - Support for markdown rendering (when MarkdownDisplay is available) + */ +export const AssistantMessage = memo(({ line }: { line: AssistantLine }) => { + const columns = + typeof process !== "undefined" && + process.stdout && + "columns" in process.stdout + ? ((process.stdout as { columns?: number }).columns ?? 80) + : 80; + const contentWidth = Math.max(0, columns - 2); + + const normalizedText = normalize(line.text); + + return ( + + + + + + + + + ); +}); + +AssistantMessage.displayName = "AssistantMessage"; diff --git a/src/cli/components/CommandMessage.tsx b/src/cli/components/CommandMessage.tsx new file mode 100644 index 0000000..4c197f5 --- /dev/null +++ b/src/cli/components/CommandMessage.tsx @@ -0,0 +1,89 @@ +import { Box, Text } from "ink"; +import { memo, useEffect, useState } from "react"; +import { colors } from "./colors.js"; +import { MarkdownDisplay } from "./MarkdownDisplay.js"; + +type CommandLine = { + kind: "command"; + id: string; + input: string; + output: string; + phase?: "running" | "finished"; + success?: boolean; +}; + +// BlinkDot component for running commands +const BlinkDot: React.FC<{ color?: string }> = ({ color = "yellow" }) => { + const [on, setOn] = useState(true); + useEffect(() => { + const t = setInterval(() => setOn((v) => !v), 400); + return () => clearInterval(t); + }, []); + // Visible = colored dot; Off = space (keeps width/alignment) + return {on ? "●" : " "}; +}; + +/** + * CommandMessage - Rich formatting version with two-column layout + * Matches the formatting pattern used by other message types + * + * Features: + * - Two-column layout with left gutter (2 chars) and right content area + * - Proper terminal width calculation and wrapping + * - Markdown rendering for output + * - Consistent symbols (● for command, ⎿ for result) + */ +export const CommandMessage = memo(({ line }: { line: CommandLine }) => { + const columns = + typeof process !== "undefined" && + process.stdout && + "columns" in process.stdout + ? ((process.stdout as { columns?: number }).columns ?? 80) + : 80; + + const rightWidth = Math.max(0, columns - 2); // gutter is 2 cols + + // Determine dot state based on phase and success + const getDotElement = () => { + if (!line.phase || line.phase === "finished") { + // Show red dot for failed commands, green for successful + if (line.success === false) { + return ; + } + return ; + } + if (line.phase === "running") { + return ; + } + return ; + }; + + return ( + + {/* Command input */} + + + {getDotElement()} + + + + {line.input} + + + + {/* Command output (if present) */} + {line.output && ( + + + {" ⎿ "} + + + + + + )} + + ); +}); + +CommandMessage.displayName = "CommandMessage"; diff --git a/src/cli/components/CommandPreview.tsx b/src/cli/components/CommandPreview.tsx new file mode 100644 index 0000000..6a4fc99 --- /dev/null +++ b/src/cli/components/CommandPreview.tsx @@ -0,0 +1,31 @@ +import { Box, Text } from "ink"; +import { commands } from "../commands/registry"; +import { colors } from "./colors"; + +// Compute command list once at module level since it never changes +const commandList = Object.entries(commands).map(([cmd, { desc }]) => ({ + cmd, + desc, +})); + +export function CommandPreview({ currentInput }: { currentInput: string }) { + if (!currentInput.startsWith("/")) { + return null; + } + + return ( + + {commandList.map((item) => ( + + {item.cmd} + {item.desc} + + ))} + + ); +} diff --git a/src/cli/components/DiffRenderer.tsx b/src/cli/components/DiffRenderer.tsx new file mode 100644 index 0000000..3cf7b12 --- /dev/null +++ b/src/cli/components/DiffRenderer.tsx @@ -0,0 +1,259 @@ +import { relative } from "node:path"; +import * as Diff from "diff"; +import { Box, Text } from "ink"; +import { colors } from "./colors"; + +// Helper to format path as relative with ../ +function formatRelativePath(filePath: string): string { + const cwd = process.cwd(); + const relativePath = relative(cwd, filePath); + + // If file is outside cwd, it will start with .. + // If file is in cwd, add ./ prefix + if (!relativePath.startsWith("..")) { + return `./${relativePath}`; + } + return relativePath; +} + +// Helper to count lines in a string +function countLines(str: string): number { + if (!str) return 0; + return str.split("\n").length; +} + +// Helper to render a diff line with word-level highlighting +interface DiffLineProps { + lineNumber: number; + type: "add" | "remove"; + content: string; + compareContent?: string; // The other version to compare against for word diff +} + +function DiffLine({ + lineNumber, + type, + content, + compareContent, +}: DiffLineProps) { + const prefix = type === "add" ? "+" : "-"; + const lineBg = + type === "add" ? colors.diff.addedLineBg : colors.diff.removedLineBg; + const wordBg = + type === "add" ? colors.diff.addedWordBg : colors.diff.removedWordBg; + + // If we have something to compare against, do word-level diff + if (compareContent !== undefined && content.trim() && compareContent.trim()) { + const wordDiffs = + type === "add" + ? Diff.diffWords(compareContent, content) + : Diff.diffWords(content, compareContent); + + return ( + + + + {`${lineNumber} ${prefix} `} + + {wordDiffs.map((part, i) => { + if (part.added && type === "add") { + // This part was added (show with brighter background, black text) + return ( + + {part.value} + + ); + } else if (part.removed && type === "remove") { + // This part was removed (show with brighter background, black text) + return ( + + {part.value} + + ); + } else if (!part.added && !part.removed) { + // Unchanged part (show with line background, white text) + return ( + + {part.value} + + ); + } + // Skip parts that don't belong in this line + return null; + })} + + ); + } + + // No comparison, just show the whole line with one background + return ( + + + + {`${lineNumber} ${prefix} ${content}`} + + + ); +} + +interface WriteRendererProps { + filePath: string; + content: string; +} + +export function WriteRenderer({ filePath, content }: WriteRendererProps) { + const relativePath = formatRelativePath(filePath); + const lines = content.split("\n"); + const lineCount = lines.length; + + return ( + + + {" "} + ⎿ Wrote {lineCount} line{lineCount !== 1 ? "s" : ""} to {relativePath} + + {lines.map((line, i) => ( + {line} + ))} + + ); +} + +interface EditRendererProps { + filePath: string; + oldString: string; + newString: string; +} + +export function EditRenderer({ + filePath, + oldString, + newString, +}: EditRendererProps) { + const relativePath = formatRelativePath(filePath); + const oldLines = oldString.split("\n"); + const newLines = newString.split("\n"); + + // For the summary + const additions = newLines.length; + const removals = oldLines.length; + + // Try to match up lines for word-level diff + // This is a simple approach - for single-line changes, compare directly + // For multi-line, we could do more sophisticated matching + const singleLineEdit = oldLines.length === 1 && newLines.length === 1; + + return ( + + + {" "} + ⎿ Updated {relativePath} with {additions} addition + {additions !== 1 ? "s" : ""} and {removals} removal + {removals !== 1 ? "s" : ""} + + + {/* Show removals */} + {oldLines.map((line, i) => ( + + ))} + + {/* Show additions */} + {newLines.map((line, i) => ( + + ))} + + ); +} + +interface MultiEditRendererProps { + filePath: string; + edits: Array<{ + old_string: string; + new_string: string; + }>; +} + +export function MultiEditRenderer({ filePath, edits }: MultiEditRendererProps) { + const relativePath = formatRelativePath(filePath); + + // Count total additions and removals + let totalAdditions = 0; + let totalRemovals = 0; + + edits.forEach((edit) => { + totalAdditions += countLines(edit.new_string); + totalRemovals += countLines(edit.old_string); + }); + + return ( + + + {" "} + ⎿ Updated {relativePath} with {totalAdditions} addition + {totalAdditions !== 1 ? "s" : ""} and {totalRemovals} removal + {totalRemovals !== 1 ? "s" : ""} + + + {/* For multi-edit, show each edit sequentially */} + {edits.map((edit, index) => { + const oldLines = edit.old_string.split("\n"); + const newLines = edit.new_string.split("\n"); + const singleLineEdit = oldLines.length === 1 && newLines.length === 1; + + return ( + + {oldLines.map((line, i) => ( + + ))} + {newLines.map((line, i) => ( + + ))} + + ); + })} + + ); +} diff --git a/src/cli/components/ErrorMessage.tsx b/src/cli/components/ErrorMessage.tsx new file mode 100644 index 0000000..46cdf8f --- /dev/null +++ b/src/cli/components/ErrorMessage.tsx @@ -0,0 +1,12 @@ +import { Text } from "ink"; +import { memo } from "react"; + +type ErrorLine = { + kind: "error"; + id: string; + text: string; +}; + +export const ErrorMessage = memo(({ line }: { line: ErrorLine }) => { + return {line.text}; +}); diff --git a/src/cli/components/InlineMarkdownRenderer.tsx b/src/cli/components/InlineMarkdownRenderer.tsx new file mode 100644 index 0000000..79c9a7d --- /dev/null +++ b/src/cli/components/InlineMarkdownRenderer.tsx @@ -0,0 +1,117 @@ +import { Text } from "ink"; +import type React from "react"; +import { colors } from "./colors.js"; + +interface InlineMarkdownProps { + text: string; +} + +/** + * Renders inline markdown (bold, italic, code, etc.) using pure Ink components. + * Based on Gemini CLI's approach - NO ANSI codes! + * Note: dimColor should be handled by parent Text component for proper wrapping + */ +export const InlineMarkdown: React.FC = ({ text }) => { + // Early return for plain text without markdown (treat underscores as plain text) + if (!/[*~`[]/.test(text)) { + return <>{text}; + } + + const nodes: React.ReactNode[] = []; + let lastIndex = 0; + + // Regex to match inline markdown patterns (underscore italics disabled) + // Matches: **bold**, *italic*, ~~strikethrough~~, `code`, [link](url) + const inlineRegex = + /(\*\*[^*]+\*\*|\*[^*]+\*|~~[^~]+~~|`[^`]+`|\[[^\]]+\]\([^)]+\))/g; + let match: RegExpExecArray | null = inlineRegex.exec(text); + + while (match !== null) { + // Add text before the match + if (match.index > lastIndex) { + nodes.push(text.slice(lastIndex, match.index)); + } + + const fullMatch = match[0]; + const key = `m-${match.index}`; + + // Handle different markdown patterns + if ( + fullMatch.startsWith("**") && + fullMatch.endsWith("**") && + fullMatch.length > 4 + ) { + // Bold + nodes.push( + + {fullMatch.slice(2, -2)} + , + ); + } else if ( + fullMatch.length > 2 && + fullMatch.startsWith("*") && + fullMatch.endsWith("*") + ) { + // Italic + nodes.push( + + {fullMatch.slice(1, -1)} + , + ); + } else if ( + fullMatch.startsWith("~~") && + fullMatch.endsWith("~~") && + fullMatch.length > 4 + ) { + // Strikethrough + nodes.push( + + {fullMatch.slice(2, -2)} + , + ); + } else if (fullMatch.startsWith("`") && fullMatch.endsWith("`")) { + // Inline code + nodes.push( + + {fullMatch.slice(1, -1)} + , + ); + } else if ( + fullMatch.startsWith("[") && + fullMatch.includes("](") && + fullMatch.endsWith(")") + ) { + // Link [text](url) + const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/); + if (linkMatch) { + const linkText = linkMatch[1]; + const url = linkMatch[2]; + nodes.push( + + {linkText} + ({url}) + , + ); + } else { + // Fallback if link parsing fails + nodes.push(fullMatch); + } + } else { + // Unknown pattern, render as-is + nodes.push(fullMatch); + } + + lastIndex = inlineRegex.lastIndex; + match = inlineRegex.exec(text); + } + + // Add remaining text after last match + if (lastIndex < text.length) { + nodes.push(text.slice(lastIndex)); + } + + return <>{nodes}; +}; + +// Test helper: expose the tokenizer logic for simple unit validation without rendering. +// This mirrors the logic above; keep it in sync with InlineMarkdown for tests. diff --git a/src/cli/components/Input.tsx b/src/cli/components/Input.tsx new file mode 100644 index 0000000..a7dca4a --- /dev/null +++ b/src/cli/components/Input.tsx @@ -0,0 +1,129 @@ +// Import useInput from vendored Ink for bracketed paste support +import { Box, Text, useInput } from "ink"; +import { useEffect, useRef, useState } from "react"; +import { CommandPreview } from "./CommandPreview"; +import { PasteAwareTextInput } from "./PasteAwareTextInput"; + +// Only show token count when it exceeds this threshold +const COUNTER_VISIBLE_THRESHOLD = 1000; + +// Stable reference to prevent re-renders during typing +const EMPTY_STATUS = " "; + +export function Input({ + streaming, + tokenCount, + thinkingMessage, + onSubmit, +}: { + streaming: boolean; + tokenCount: number; + thinkingMessage: string; + onSubmit: (message?: string) => void; +}) { + const [value, setValue] = useState(""); + const [escapePressed, setEscapePressed] = useState(false); + const escapeTimerRef = useRef | null>(null); + const [ctrlCPressed, setCtrlCPressed] = useState(false); + const ctrlCTimerRef = useRef | null>(null); + const previousValueRef = useRef(value); + + // Handle escape key for double-escape-to-clear + useInput((_input, key) => { + if (key.escape && value) { + // Only work when input is non-empty + if (escapePressed) { + // Second escape - clear input + setValue(""); + setEscapePressed(false); + if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current); + } else { + // First escape - start 1-second timer + setEscapePressed(true); + if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current); + escapeTimerRef.current = setTimeout(() => { + setEscapePressed(false); + }, 1000); + } + } + }); + + // Handle CTRL-C for double-ctrl-c-to-exit + useInput((input, key) => { + if (input === "c" && key.ctrl) { + if (ctrlCPressed) { + // Second CTRL-C - exit application + process.exit(0); + } else { + // First CTRL-C - start 1-second timer + setCtrlCPressed(true); + if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current); + ctrlCTimerRef.current = setTimeout(() => { + setCtrlCPressed(false); + }, 1000); + } + } + }); + + // Reset escape and ctrl-c state when user types (value changes) + useEffect(() => { + if (value !== previousValueRef.current && value !== "") { + setEscapePressed(false); + if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current); + setCtrlCPressed(false); + if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current); + } + previousValueRef.current = value; + }, [value]); + + // Clean up timers on unmount + useEffect(() => { + return () => { + if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current); + if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current); + }; + }, []); + + const handleSubmit = () => { + if (streaming) { + return; + } + onSubmit(value); + setValue(""); + }; + + const footerText = ctrlCPressed + ? "Press CTRL-C again to exit" + : escapePressed + ? "Press Esc again to clear" + : "Press / for commands"; + + const thinkingText = streaming + ? tokenCount > COUNTER_VISIBLE_THRESHOLD + ? `${thinkingMessage}… (${tokenCount}↑)` + : `${thinkingMessage}…` + : EMPTY_STATUS; + + return ( + + {/* Live status / token counter (per-turn) - always takes up space to prevent layout shift */} + {thinkingText} + + {"> "} + + + {value.startsWith("/") ? ( + + ) : ( + + {footerText} + Letta Code v0.1 + + )} + + ); +} diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx new file mode 100644 index 0000000..0612d29 --- /dev/null +++ b/src/cli/components/InputRich.tsx @@ -0,0 +1,268 @@ +// Import useInput from vendored Ink for bracketed paste support +import { Box, Text, useInput } from "ink"; +import SpinnerLib from "ink-spinner"; +import type { ComponentType } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { PermissionMode } from "../../permissions/mode"; +import { permissionMode } from "../../permissions/mode"; +import { CommandPreview } from "./CommandPreview"; +import { colors } from "./colors"; +import { PasteAwareTextInput } from "./PasteAwareTextInput"; +import { ShimmerText } from "./ShimmerText"; + +// Type assertion for ink-spinner compatibility +const Spinner = SpinnerLib as ComponentType; + +// Only show token count when it exceeds this threshold +const COUNTER_VISIBLE_THRESHOLD = 1000; + +export function Input({ + visible = true, + streaming, + commandRunning = false, + tokenCount, + thinkingMessage, + onSubmit, + permissionMode: externalMode, + onPermissionModeChange, +}: { + visible?: boolean; + streaming: boolean; + commandRunning?: boolean; + tokenCount: number; + thinkingMessage: string; + onSubmit: (message?: string) => void; + permissionMode?: PermissionMode; + onPermissionModeChange?: (mode: PermissionMode) => void; +}) { + const [value, setValue] = useState(""); + const [escapePressed, setEscapePressed] = useState(false); + const escapeTimerRef = useRef | null>(null); + const [ctrlCPressed, setCtrlCPressed] = useState(false); + const ctrlCTimerRef = useRef | null>(null); + const previousValueRef = useRef(value); + const [currentMode, setCurrentMode] = useState( + externalMode || permissionMode.getMode(), + ); + + // Sync with external mode changes (from plan approval dialog) + useEffect(() => { + if (externalMode !== undefined) { + setCurrentMode(externalMode); + } + }, [externalMode]); + + // Shimmer animation state + const [shimmerOffset, setShimmerOffset] = useState(-3); + + // Get terminal width for proper column sizing + const columns = + typeof process !== "undefined" && + process.stdout && + "columns" in process.stdout + ? ((process.stdout as { columns?: number }).columns ?? 80) + : 80; + const contentWidth = Math.max(0, columns - 2); + + // Handle escape key for double-escape-to-clear + useInput((_input, key) => { + if (key.escape && value) { + // Only work when input is non-empty + if (escapePressed) { + // Second escape - clear input + setValue(""); + setEscapePressed(false); + if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current); + } else { + // First escape - start 1-second timer + setEscapePressed(true); + if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current); + escapeTimerRef.current = setTimeout(() => { + setEscapePressed(false); + }, 1000); + } + } + }); + + // Handle CTRL-C for double-ctrl-c-to-exit + useInput((input, key) => { + if (input === "c" && key.ctrl) { + if (ctrlCPressed) { + // Second CTRL-C - exit application + process.exit(0); + } else { + // First CTRL-C - start 1-second timer + setCtrlCPressed(true); + if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current); + ctrlCTimerRef.current = setTimeout(() => { + setCtrlCPressed(false); + }, 1000); + } + } + }); + + // Handle Shift+Tab for permission mode cycling + useInput((_input, key) => { + if (key.shift && key.tab) { + // Cycle through permission modes + const modes: PermissionMode[] = [ + "default", + "acceptEdits", + "plan", + "bypassPermissions", + ]; + const currentIndex = modes.indexOf(currentMode); + const nextIndex = (currentIndex + 1) % modes.length; + const nextMode = modes[nextIndex] ?? "default"; + + // Update both singleton and local state + permissionMode.setMode(nextMode); + setCurrentMode(nextMode); + + // Notify parent of mode change + if (onPermissionModeChange) { + onPermissionModeChange(nextMode); + } + } + }); + + // Reset escape and ctrl-c state when user types (value changes) + useEffect(() => { + if (value !== previousValueRef.current && value !== "") { + setEscapePressed(false); + if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current); + setCtrlCPressed(false); + if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current); + } + previousValueRef.current = value; + }, [value]); + + // Clean up timers on unmount + useEffect(() => { + return () => { + if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current); + if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current); + }; + }, []); + + // Shimmer animation effect + useEffect(() => { + if (!streaming) return; + + const id = setInterval(() => { + setShimmerOffset((prev) => { + const len = thinkingMessage.length; + const next = prev + 1; + return next > len + 3 ? -3 : next; + }); + }, 120); // Speed of shimmer animation + + return () => clearInterval(id); + }, [streaming, thinkingMessage]); + + const handleSubmit = () => { + if (streaming || commandRunning) { + return; + } + onSubmit(value); + setValue(""); + }; + + // Get display name and color for permission mode + const getModeInfo = () => { + switch (currentMode) { + case "acceptEdits": + return { name: "accept edits", color: colors.status.processing }; + case "plan": + return { name: "plan (read-only) mode", color: colors.status.success }; + case "bypassPermissions": + return { + name: "yolo (allow all) mode", + color: colors.status.error, + }; + default: + return null; + } + }; + + const modeInfo = getModeInfo(); + + const shouldShowTokenCount = + streaming && tokenCount > COUNTER_VISIBLE_THRESHOLD; + + // Create a horizontal line using box-drawing characters + const horizontalLine = "─".repeat(columns); + + // If not visible, render nothing but keep component mounted to preserve state + if (!visible) { + return null; + } + + return ( + + {/* Live status / token counter - only show when streaming */} + {streaming && ( + + + + + + + + + {shouldShowTokenCount && ({tokenCount}↑)} + + + )} + + + {/* Top horizontal divider */} + {horizontalLine} + + {/* Two-column layout for input, matching message components */} + + + {">"} + + + + + + + + {/* Bottom horizontal divider */} + {horizontalLine} + + {value.startsWith("/") ? ( + + ) : ( + + {ctrlCPressed ? ( + Press CTRL-C again to exit + ) : escapePressed ? ( + Press Esc again to clear + ) : modeInfo ? ( + + ⏵⏵ {modeInfo.name} + + {" "} + (shift+tab to cycle) + + + ) : ( + Press / for commands + )} + https://discord.gg/letta + + )} + + + ); +} diff --git a/src/cli/components/MarkdownDisplay.tsx b/src/cli/components/MarkdownDisplay.tsx new file mode 100644 index 0000000..56c51a4 --- /dev/null +++ b/src/cli/components/MarkdownDisplay.tsx @@ -0,0 +1,210 @@ +import { Box, Text } from "ink"; +import type React from "react"; +import { colors } from "./colors.js"; +import { InlineMarkdown } from "./InlineMarkdownRenderer.js"; + +interface MarkdownDisplayProps { + text: string; + dimColor?: boolean; + hangingIndent?: number; // indent for wrapped lines within a paragraph +} + +/** + * Renders full markdown content using pure Ink components. + * Based on Gemini CLI's approach - NO ANSI codes, NO marked-terminal! + */ +import { Transform } from "ink"; + +export const MarkdownDisplay: React.FC = ({ + text, + dimColor, + hangingIndent = 0, +}) => { + if (!text) return null; + + const lines = text.split("\n"); + const contentBlocks: React.ReactNode[] = []; + + // Regex patterns for markdown elements + const headerRegex = /^(#{1,6})\s+(.*)$/; + const codeBlockRegex = /^```(\w*)?$/; + const listItemRegex = /^(\s*)([*\-+]|\d+\.)\s+(.*)$/; + const blockquoteRegex = /^>\s*(.*)$/; + const hrRegex = /^[-*_]{3,}$/; + + let inCodeBlock = false; + let codeBlockContent: string[] = []; + let _codeBlockLang = ""; + + lines.forEach((line, index) => { + const key = `line-${index}`; + + // Handle code blocks + if (line.match(codeBlockRegex)) { + if (!inCodeBlock) { + // Start of code block + const match = line.match(codeBlockRegex); + _codeBlockLang = match?.[1] || ""; + inCodeBlock = true; + codeBlockContent = []; + } else { + // End of code block + inCodeBlock = false; + + // Render the code block + const code = codeBlockContent.join("\n"); + + // For now, use simple colored text for code blocks + // TODO: Could parse cli-highlight output and convert ANSI to Ink components + // but for MVP, just use a nice color like Gemini does + contentBlocks.push( + + {code} + , + ); + + codeBlockContent = []; + _codeBlockLang = ""; + } + return; + } + + // If we're inside a code block, collect the content + if (inCodeBlock) { + codeBlockContent.push(line); + return; + } + + // Check for headers + const headerMatch = line.match(headerRegex); + if (headerMatch) { + const level = headerMatch[1].length; + const content = headerMatch[2]; + + // Different styling for different header levels + let headerElement: React.ReactNode; + if (level === 1) { + headerElement = ( + + + + ); + } else if (level === 2) { + headerElement = ( + + + + ); + } else if (level === 3) { + headerElement = ( + + + + ); + } else { + headerElement = ( + + + + ); + } + + contentBlocks.push( + + {headerElement} + , + ); + return; + } + + // Check for list items + const listMatch = line.match(listItemRegex); + if (listMatch) { + const indent = listMatch[1].length; + const marker = listMatch[2]; + const content = listMatch[3]; + + // Determine if it's ordered or unordered list + const isOrdered = /^\d+\./.test(marker); + const bullet = isOrdered ? `${marker} ` : "• "; + const bulletWidth = bullet.length; + + contentBlocks.push( + + + {bullet} + + + + + + + , + ); + return; + } + + // Check for blockquotes + const blockquoteMatch = line.match(blockquoteRegex); + if (blockquoteMatch) { + contentBlocks.push( + + + + + + , + ); + return; + } + + // Check for horizontal rules + if (line.match(hrRegex)) { + contentBlocks.push( + + ─────────────────────────────── + , + ); + return; + } + + // Empty lines + if (line.trim() === "") { + contentBlocks.push(); + return; + } + + // Regular paragraph text with optional hanging indent for wrapped lines + contentBlocks.push( + + {hangingIndent > 0 ? ( + + i === 0 ? ln : " ".repeat(hangingIndent) + ln + } + > + + + + + ) : ( + + + + )} + , + ); + }); + + // Handle unclosed code block at end of input + if (inCodeBlock && codeBlockContent.length > 0) { + const code = codeBlockContent.join("\n"); + contentBlocks.push( + + {code} + , + ); + } + + return {contentBlocks}; +}; diff --git a/src/cli/components/ModelSelector.tsx b/src/cli/components/ModelSelector.tsx new file mode 100644 index 0000000..bc51156 --- /dev/null +++ b/src/cli/components/ModelSelector.tsx @@ -0,0 +1,75 @@ +// Import useInput from vendored Ink for bracketed paste support +import { Box, Text, useInput } from "ink"; +import { useState } from "react"; +import models from "../../models.json"; +import { colors } from "./colors"; + +interface ModelSelectorProps { + currentModel?: string; + onSelect: (modelId: string) => void; + onCancel: () => void; +} + +export function ModelSelector({ + currentModel, + onSelect, + onCancel, +}: ModelSelectorProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + + useInput((_input, key) => { + if (key.upArrow) { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedIndex((prev) => Math.min(models.length - 1, prev + 1)); + } else if (key.return) { + const selectedModel = models[selectedIndex]; + if (selectedModel) { + onSelect(selectedModel.id); + } + } else if (key.escape) { + onCancel(); + } + }); + + return ( + + + + Select Model (↑↓ to navigate, Enter to select, ESC to cancel) + + + + + {models.map((model, index) => { + const isSelected = index === selectedIndex; + const isCurrent = model.handle === currentModel; + + return ( + + + {isSelected ? "›" : " "} + + + + {model.label} + {isCurrent && ( + (current) + )} + + {model.description} + + + ); + })} + + + ); +} diff --git a/src/cli/components/PasteAwareTextInput.tsx b/src/cli/components/PasteAwareTextInput.tsx new file mode 100644 index 0000000..f413b5d --- /dev/null +++ b/src/cli/components/PasteAwareTextInput.tsx @@ -0,0 +1,254 @@ +// Paste-aware text input wrapper that: +// 1. Detects large pastes (>5 lines or >500 chars) and replaces with placeholders +// 2. Supports image pasting (iTerm2 inline, data URLs, file paths, macOS clipboard) +// 3. Maintains separate display value (with placeholders) vs actual value (full content) +// 4. Resolves placeholders on submit + +// Import useInput from vendored Ink for bracketed paste support +import { useInput } from "ink"; +import RawTextInput from "ink-text-input"; +import { useEffect, useRef, useState } from "react"; +import { + translatePasteForImages, + tryImportClipboardImageMac, +} from "../helpers/clipboard"; +import { allocatePaste, resolvePlaceholders } from "../helpers/pasteRegistry"; + +interface PasteAwareTextInputProps { + value: string; + onChange: (value: string) => void; + onSubmit?: (value: string) => void; + placeholder?: string; + focus?: boolean; +} + +function countLines(text: string): number { + return (text.match(/\r\n|\r|\n/g) || []).length + 1; +} + +export function PasteAwareTextInput({ + value, + onChange, + onSubmit, + placeholder, + focus = true, +}: PasteAwareTextInputProps) { + const [displayValue, setDisplayValue] = useState(value); + const [actualValue, setActualValue] = useState(value); + const lastPasteDetectedAtRef = useRef(0); + const suppressNextChangeRef = useRef(false); + const caretOffsetRef = useRef((value || "").length); + const [nudgeCursorOffset, setNudgeCursorOffset] = useState< + number | undefined + >(undefined); + const TextInputAny = RawTextInput as unknown as React.ComponentType<{ + value: string; + onChange: (value: string) => void; + onSubmit?: (value: string) => void; + placeholder?: string; + focus?: boolean; + }>; + + // Sync external value changes (treat incoming value as DISPLAY value) + useEffect(() => { + setDisplayValue(value); + // Recompute ACTUAL by substituting placeholders via shared registry + const resolved = resolvePlaceholders(value); + setActualValue(resolved); + }, [value]); + + // Intercept paste events and macOS fallback for image clipboard imports + useInput( + (input, key) => { + // Handle bracketed paste events emitted by vendored Ink + const isPasted = (key as unknown as { isPasted?: boolean })?.isPasted; + if (isPasted) { + lastPasteDetectedAtRef.current = Date.now(); + + const payload = typeof input === "string" ? input : ""; + // Translate any image payloads in the paste (OSC 1337, data URLs, file paths) + let translated = translatePasteForImages(payload); + // If paste event carried no text (common for image-only clipboard), try macOS import + if ((!translated || translated.length === 0) && payload.length === 0) { + const clip = tryImportClipboardImageMac(); + if (clip) translated = clip; + } + + if (translated && translated.length > 0) { + // Insert at current caret position + const at = Math.max( + 0, + Math.min(caretOffsetRef.current, displayValue.length), + ); + const isLarge = countLines(translated) > 5 || translated.length > 500; + if (isLarge) { + const pasteId = allocatePaste(translated); + const placeholder = `[Pasted text #${pasteId} +${countLines(translated)} lines]`; + const newDisplay = + displayValue.slice(0, at) + placeholder + displayValue.slice(at); + const newActual = + actualValue.slice(0, at) + translated + actualValue.slice(at); + setDisplayValue(newDisplay); + setActualValue(newActual); + onChange(newDisplay); + const nextCaret = at + placeholder.length; + setNudgeCursorOffset(nextCaret); + caretOffsetRef.current = nextCaret; + } else { + const newDisplay = + displayValue.slice(0, at) + translated + displayValue.slice(at); + const newActual = + actualValue.slice(0, at) + translated + actualValue.slice(at); + setDisplayValue(newDisplay); + setActualValue(newActual); + onChange(newDisplay); + const nextCaret = at + translated.length; + setNudgeCursorOffset(nextCaret); + caretOffsetRef.current = nextCaret; + } + return; + } + // If nothing to insert, fall through + } + + if ( + (key.meta && (input === "v" || input === "V")) || + (key.ctrl && key.shift && (input === "v" || input === "V")) + ) { + const placeholder = tryImportClipboardImageMac(); + if (placeholder) { + const at = Math.max( + 0, + Math.min(caretOffsetRef.current, displayValue.length), + ); + const newDisplay = + displayValue.slice(0, at) + placeholder + displayValue.slice(at); + const newActual = + actualValue.slice(0, at) + placeholder + actualValue.slice(at); + setDisplayValue(newDisplay); + setActualValue(newActual); + onChange(newDisplay); + const nextCaret = at + placeholder.length; + setNudgeCursorOffset(nextCaret); + caretOffsetRef.current = nextCaret; + } + } + }, + { isActive: focus }, + ); + + const handleChange = (newValue: string) => { + // If we just handled a paste via useInput, ignore this immediate change + if (suppressNextChangeRef.current) { + suppressNextChangeRef.current = false; + return; + } + // Heuristic: detect large additions that look like pastes + const addedLen = newValue.length - displayValue.length; + const lineDelta = countLines(newValue) - countLines(displayValue); + const sincePasteMs = Date.now() - lastPasteDetectedAtRef.current; + + // If we see a large addition (and it's not too soon after the last paste), treat it as a paste + if ( + sincePasteMs > 1000 && + addedLen > 0 && + (addedLen > 500 || lineDelta > 5) + ) { + lastPasteDetectedAtRef.current = Date.now(); + + // Compute inserted segment via longest common prefix/suffix + const a = displayValue; + const b = newValue; + let lcp = 0; + while (lcp < a.length && lcp < b.length && a[lcp] === b[lcp]) lcp++; + let lcs = 0; + while ( + lcs < a.length - lcp && + lcs < b.length - lcp && + a[a.length - 1 - lcs] === b[b.length - 1 - lcs] + ) + lcs++; + const inserted = b.slice(lcp, b.length - lcs); + + // Translate any image payloads in the inserted text (run always for reliability) + const translated = translatePasteForImages(inserted); + const translatedLines = countLines(translated); + const translatedChars = translated.length; + + // If translated text is still large, create a placeholder + if (translatedLines > 5 || translatedChars > 500) { + const pasteId = allocatePaste(translated); + const placeholder = `[Pasted text #${pasteId} +${translatedLines} lines]`; + + const newDisplayValue = + a.slice(0, lcp) + placeholder + a.slice(a.length - lcs); + const newActualValue = + actualValue.slice(0, lcp) + + translated + + actualValue.slice(actualValue.length - lcs); + + setDisplayValue(newDisplayValue); + setActualValue(newActualValue); + onChange(newDisplayValue); + const nextCaret = lcp + placeholder.length; + setNudgeCursorOffset(nextCaret); + caretOffsetRef.current = nextCaret; + return; + } + + // Otherwise, insert the translated text inline + const newDisplayValue = + a.slice(0, lcp) + translated + a.slice(a.length - lcs); + const newActualValue = + actualValue.slice(0, lcp) + + translated + + actualValue.slice(actualValue.length - lcs); + + setDisplayValue(newDisplayValue); + setActualValue(newActualValue); + onChange(newDisplayValue); + const nextCaret = lcp + translated.length; + setNudgeCursorOffset(nextCaret); + caretOffsetRef.current = nextCaret; + return; + } + + // Normal typing/edits - update display and compute actual by substituting placeholders + setDisplayValue(newValue); + const resolved = resolvePlaceholders(newValue); + setActualValue(resolved); + onChange(newValue); + // Default caret behavior on typing/appends: move to end + caretOffsetRef.current = newValue.length; + }; + + const handleSubmit = () => { + if (onSubmit) { + // Pass the display value (with placeholders) to onSubmit + // The parent will handle conversion to content parts and cleanup + onSubmit(displayValue); + } + }; + + // Clear one-shot cursor nudge after it applies + useEffect(() => { + if (typeof nudgeCursorOffset === "number") { + const t = setTimeout(() => setNudgeCursorOffset(undefined), 0); + return () => clearTimeout(t); + } + }, [nudgeCursorOffset]); + + return ( + { + caretOffsetRef.current = n; + }} + onChange={handleChange} + onSubmit={handleSubmit} + placeholder={placeholder} + focus={focus} + /> + ); +} diff --git a/src/cli/components/PlanModeDialog.tsx b/src/cli/components/PlanModeDialog.tsx new file mode 100644 index 0000000..f5bf85b --- /dev/null +++ b/src/cli/components/PlanModeDialog.tsx @@ -0,0 +1,146 @@ +import { Box, Text, useInput } from "ink"; +import RawTextInput from "ink-text-input"; +import { type ComponentType, memo, useState } from "react"; +import { colors } from "./colors"; +import { MarkdownDisplay } from "./MarkdownDisplay"; + +type Props = { + plan: string; + onApprove: () => void; + onApproveAndAcceptEdits: () => void; + onKeepPlanning: (reason: string) => void; +}; + +const OptionsRenderer = memo( + ({ + options, + selectedOption, + }: { + options: Array<{ label: string }>; + selectedOption: number; + }) => { + return ( + + {options.map((option, index) => { + const isSelected = index === selectedOption; + const color = isSelected ? colors.approval.header : undefined; + return ( + + + {isSelected ? "❯" : " "} {index + 1}. {option.label} + + + ); + })} + + ); + }, +); + +OptionsRenderer.displayName = "OptionsRenderer"; + +export const PlanModeDialog = memo( + ({ plan, onApprove, onApproveAndAcceptEdits, onKeepPlanning }: Props) => { + const [selectedOption, setSelectedOption] = useState(0); + const [isEnteringReason, setIsEnteringReason] = useState(false); + const [denyReason, setDenyReason] = useState(""); + + const options = [ + { label: "Yes, and auto-accept edits", action: onApproveAndAcceptEdits }, + { label: "Yes, and manually approve edits", action: onApprove }, + { label: "No, keep planning", action: () => {} }, // Handled via setIsEnteringReason + ]; + + useInput((_input, key) => { + if (isEnteringReason) { + // When entering reason, only handle enter/escape + if (key.return) { + onKeepPlanning(denyReason); + setIsEnteringReason(false); + setDenyReason(""); + } else if (key.escape) { + setIsEnteringReason(false); + setDenyReason(""); + } + return; + } + + if (key.upArrow) { + setSelectedOption((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedOption((prev) => Math.min(options.length - 1, prev + 1)); + } else if (key.return) { + // Check if this is the "keep planning" option (last option) + if (selectedOption === options.length - 1) { + setIsEnteringReason(true); + } else { + options[selectedOption]?.action(); + } + } else if (key.escape) { + setIsEnteringReason(true); // ESC also goes to denial input + } + }); + + // Show denial input screen if entering reason + if (isEnteringReason) { + return ( + + + + Enter feedback to continue planning (ESC to cancel): + + + + {"> "} + {(() => { + const TextInputAny = RawTextInput as unknown as ComponentType<{ + value: string; + onChange: (s: string) => void; + }>; + return ( + + ); + })()} + + + + + ); + } + + return ( + + + Ready to code? + + + Here's the proposed plan: + + + {/* Nested box for plan content */} + + + + + + Would you like to proceed? + + + + + ); + }, +); + +PlanModeDialog.displayName = "PlanModeDialog"; diff --git a/src/cli/components/ReasoningMessage.tsx b/src/cli/components/ReasoningMessage.tsx new file mode 100644 index 0000000..4f9fc31 --- /dev/null +++ b/src/cli/components/ReasoningMessage.tsx @@ -0,0 +1,13 @@ +import { Text } from "ink"; +import { memo } from "react"; + +type ReasoningLine = { + kind: "reasoning"; + id: string; + text: string; + phase: "streaming" | "finished"; +}; + +export const ReasoningMessage = memo(({ line }: { line: ReasoningLine }) => { + return {line.text}; +}); diff --git a/src/cli/components/ReasoningMessageRich.tsx b/src/cli/components/ReasoningMessageRich.tsx new file mode 100644 index 0000000..4653706 --- /dev/null +++ b/src/cli/components/ReasoningMessageRich.tsx @@ -0,0 +1,64 @@ +import { Box, Text } from "ink"; +import { memo } from "react"; +import { MarkdownDisplay } from "./MarkdownDisplay.js"; + +// Helper function to normalize text - copied from old codebase +const normalize = (s: string) => + s + .replace(/\r\n/g, "\n") + .replace(/[ \t]+$/gm, "") + .replace(/\n{3,}/g, "\n\n") + .replace(/^\n+|\n+$/g, ""); + +type ReasoningLine = { + kind: "reasoning"; + id: string; + text: string; + phase: "streaming" | "finished"; +}; + +/** + * ReasoningMessageRich - Rich formatting version with special reasoning layout + * This is a direct port from the old letta-code codebase to preserve the exact styling + * + * Features: + * - Header row with "✻" symbol and "Thinking…" text + * - Reasoning content indented with 2 spaces + * - Full markdown rendering with dimmed colors + * - Proper text normalization + */ +export const ReasoningMessage = memo(({ line }: { line: ReasoningLine }) => { + const columns = + typeof process !== "undefined" && + process.stdout && + "columns" in process.stdout + ? ((process.stdout as { columns?: number }).columns ?? 80) + : 80; + const contentWidth = Math.max(0, columns - 2); + + const normalizedText = normalize(line.text); + + return ( + + + + + + + Thinking… + + + + + + + + + + + + + ); +}); + +ReasoningMessage.displayName = "ReasoningMessage"; diff --git a/src/cli/components/ShimmerText.tsx b/src/cli/components/ShimmerText.tsx new file mode 100644 index 0000000..54d1337 --- /dev/null +++ b/src/cli/components/ShimmerText.tsx @@ -0,0 +1,34 @@ +import chalk from "chalk"; +import { Text } from "ink"; +import type React from "react"; +import { colors } from "./colors.js"; + +interface ShimmerTextProps { + color?: string; + message: string; + shimmerOffset: number; +} + +export const ShimmerText: React.FC = ({ + color = colors.status.processing, + message, + shimmerOffset, +}) => { + const fullText = `${message}…`; + + // Create the shimmer effect - simple 3-char highlight + const shimmerText = fullText + .split("") + .map((char, i) => { + // Check if this character is within the 3-char shimmer window + const isInShimmer = i >= shimmerOffset && i < shimmerOffset + 3; + + if (isInShimmer) { + return chalk.hex(colors.status.processingShimmer)(char); + } + return chalk.hex(color)(char); + }) + .join(""); + + return {shimmerText}; +}; diff --git a/src/cli/components/TodoRenderer.tsx b/src/cli/components/TodoRenderer.tsx new file mode 100644 index 0000000..b3a7fcf --- /dev/null +++ b/src/cli/components/TodoRenderer.tsx @@ -0,0 +1,59 @@ +import { Box, Text } from "ink"; +import type React from "react"; +import { colors } from "./colors.js"; + +interface TodoItem { + content: string; + status: "pending" | "in_progress" | "completed"; + id: string; + priority?: "high" | "medium" | "low"; +} + +interface TodoRendererProps { + todos: TodoItem[]; +} + +export const TodoRenderer: React.FC = ({ todos }) => { + return ( + + {todos.map((todo, index) => { + const checkbox = todo.status === "completed" ? "☒" : "☐"; + + // Format based on status + let textElement: React.ReactNode; + if (todo.status === "completed") { + // Green with strikethrough + textElement = ( + + {checkbox} {todo.content} + + ); + } else if (todo.status === "in_progress") { + // Blue bold (like code formatting) + textElement = ( + + {checkbox} {todo.content} + + ); + } else { + // Plain text for pending + textElement = ( + + {checkbox} {todo.content} + + ); + } + + // First item gets the prefix, others get indentation + const prefix = index === 0 ? " ⎿ " : " "; + + return ( + + {prefix} + {textElement} + + ); + })} + + ); +}; diff --git a/src/cli/components/ToolCallMessage.tsx b/src/cli/components/ToolCallMessage.tsx new file mode 100644 index 0000000..d59c40d --- /dev/null +++ b/src/cli/components/ToolCallMessage.tsx @@ -0,0 +1,60 @@ +import { Box, Text } from "ink"; +import { memo } from "react"; + +type ToolCallLine = { + kind: "tool_call"; + id: string; + toolCallId?: string; + name?: string; + argsText?: string; + resultText?: string; + resultOk?: boolean; + phase: "streaming" | "ready" | "running" | "finished"; +}; + +export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => { + const name = line.name ?? "?"; + const args = line.argsText ?? "..."; + + let dotColor: string | undefined; + if (line.phase === "streaming") { + dotColor = "gray"; + } else if (line.phase === "running") { + dotColor = "yellow"; + } else if (line.phase === "finished") { + dotColor = line.resultOk === false ? "red" : "green"; + } + + // Parse and clean up result text for display + const displayText = (() => { + if (!line.resultText) return undefined; + + // Try to parse JSON and extract error message for cleaner display + try { + const parsed = JSON.parse(line.resultText); + if (parsed.error && typeof parsed.error === "string") { + return parsed.error; + } + } catch { + // Not JSON or parse failed, use raw text + } + + // Truncate long results + return line.resultText.length > 80 + ? `${line.resultText.slice(0, 80)}...` + : line.resultText; + })(); + + return ( + + + {name}({args}) + + {displayText && ( + + └ {line.resultOk === false ? "Error" : "Success"}: {displayText} + + )} + + ); +}); diff --git a/src/cli/components/ToolCallMessageRich.tsx b/src/cli/components/ToolCallMessageRich.tsx new file mode 100644 index 0000000..b2516f6 --- /dev/null +++ b/src/cli/components/ToolCallMessageRich.tsx @@ -0,0 +1,242 @@ +import { Box, Text } from "ink"; +import { memo, useEffect, useState } from "react"; +import { clipToolReturn } from "../../tools/manager.js"; +import { formatArgsDisplay } from "../helpers/formatArgsDisplay.js"; +import { colors } from "./colors.js"; +import { MarkdownDisplay } from "./MarkdownDisplay.js"; +import { TodoRenderer } from "./TodoRenderer.js"; + +type ToolCallLine = { + kind: "tool_call"; + id: string; + toolCallId?: string; + name?: string; + argsText?: string; + resultText?: string; + resultOk?: boolean; + phase: "streaming" | "ready" | "running" | "finished"; +}; + +// BlinkDot component copied verbatim from old codebase +const BlinkDot: React.FC<{ color?: string }> = ({ + color = colors.tool.pending, +}) => { + const [on, setOn] = useState(true); + useEffect(() => { + const t = setInterval(() => setOn((v) => !v), 400); + return () => clearInterval(t); + }, []); + // Visible = colored dot; Off = space (keeps width/alignment) + return {on ? "●" : " "}; +}; + +/** + * ToolCallMessageRich - Rich formatting version with old layout logic + * This preserves the exact wrapping and spacing logic from the old codebase + * + * Features: + * - Two-column layout for tool calls (2 chars for dot) + * - Smart wrapping that keeps function name and args together when possible + * - Blinking dots for pending/running states + * - Result shown with ⎿ prefix underneath + */ +export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => { + const columns = + typeof process !== "undefined" && + process.stdout && + "columns" in process.stdout + ? ((process.stdout as { columns?: number }).columns ?? 80) + : 80; + + // Parse and format the tool call + const rawName = line.name ?? "?"; + const argsText = line.argsText ?? "..."; + + // Apply tool name remapping from old codebase + let displayName = rawName; + if (displayName === "write") displayName = "Write"; + else if (displayName === "edit" || displayName === "multi_edit") + displayName = "Edit"; + else if (displayName === "read") displayName = "Read"; + else if (displayName === "bash") displayName = "Bash"; + else if (displayName === "grep") displayName = "Grep"; + else if (displayName === "glob") displayName = "Glob"; + else if (displayName === "ls") displayName = "LS"; + else if (displayName === "todo_write") displayName = "Update Todos"; + else if (displayName === "ExitPlanMode") displayName = "Planning"; + + // Format arguments for display using the old formatting logic + const formatted = formatArgsDisplay(argsText); + const args = `(${formatted.display})`; + + const rightWidth = Math.max(0, columns - 2); // gutter is 2 cols + + // If name exceeds available width, fall back to simple wrapped rendering + const fallback = displayName.length >= rightWidth; + + // Determine dot state based on phase + const getDotElement = () => { + switch (line.phase) { + case "streaming": + return ; + case "ready": + return ; + case "running": + return ; + case "finished": + if (line.resultOk === false) { + return ; + } + return ; + default: + return ; + } + }; + + // Format result for display + const getResultElement = () => { + if (!line.resultText) return null; + + const prefix = ` ⎿ `; // Match old format: 2 spaces, glyph, 2 spaces + const prefixWidth = 5; // Total width of prefix + const contentWidth = Math.max(0, columns - prefixWidth); + + // Special cases from old ToolReturnBlock (check before truncation) + if (line.resultText === "Running...") { + return ( + + + {prefix} + + + Running... + + + ); + } + + if (line.resultText === "Interrupted by user") { + return ( + + + {prefix} + + + Interrupted by user + + + ); + } + + // Truncate the result text for display (UI only, API gets full response) + const displayResultText = clipToolReturn(line.resultText); + + // Check if this is a todo_write tool with successful result + // Check both the raw name and the display name since it might be "TodoWrite" + const isTodoTool = + rawName === "todo_write" || + rawName === "TodoWrite" || + displayName === "Update Todos"; + if (isTodoTool && line.resultOk !== false && line.argsText) { + try { + const parsedArgs = JSON.parse(line.argsText); + if (parsedArgs.todos && Array.isArray(parsedArgs.todos)) { + // Helper to check if a value is a record + const isRecord = (v: unknown): v is Record => + typeof v === "object" && v !== null; + + // Convert todos to safe format for TodoRenderer + const safeTodos = parsedArgs.todos.map((t: unknown, i: number) => { + const rec = isRecord(t) ? t : {}; + const status: "pending" | "in_progress" | "completed" = + rec.status === "completed" + ? "completed" + : rec.status === "in_progress" + ? "in_progress" + : "pending"; + const id = typeof rec.id === "string" ? rec.id : String(i); + const content = + typeof rec.content === "string" ? rec.content : JSON.stringify(t); + const priority: "high" | "medium" | "low" | undefined = + rec.priority === "high" + ? "high" + : rec.priority === "medium" + ? "medium" + : rec.priority === "low" + ? "low" + : undefined; + return { content, status, id, priority }; + }); + + // Return TodoRenderer directly - it has its own prefix + return ; + } + } catch { + // If parsing fails, fall through to regular handling + } + } + + // Regular result handling + const isError = line.resultOk === false; + + // Try to parse JSON for cleaner error display + let displayText = displayResultText; + try { + const parsed = JSON.parse(displayResultText); + if (parsed.error && typeof parsed.error === "string") { + displayText = parsed.error; + } + } catch { + // Not JSON, use raw text + } + + return ( + + + {prefix} + + + {isError ? ( + {displayText} + ) : ( + + )} + + + ); + }; + + return ( + + {/* Tool call with exact wrapping logic from old codebase */} + + + {getDotElement()} + + + + {fallback ? ( + {`${displayName}${args}`} + ) : ( + + {displayName} + {args ? ( + + {args} + + ) : null} + + )} + + + + {/* Tool result (if present) */} + {getResultElement()} + + ); +}); + +ToolCallMessage.displayName = "ToolCallMessage"; diff --git a/src/cli/components/Transcript.tsx b/src/cli/components/Transcript.tsx new file mode 100644 index 0000000..01c8057 --- /dev/null +++ b/src/cli/components/Transcript.tsx @@ -0,0 +1,24 @@ +import { Box, Text } from "ink"; + +export type Row = + | { kind: "user"; text: string; id?: string } + | { kind: "assistant"; text: string; id?: string } + | { kind: "reasoning"; text: string; id?: string }; + +export function Transcript({ rows }: { rows: Row[] }) { + return ( + + {rows.map((r, i) => { + if (r.kind === "user") + return {`> ${r.text}`}; + if (r.kind === "assistant") + return {r.text}; + return ( + + {r.text} + + ); // reasoning + })} + + ); +} diff --git a/src/cli/components/UserMessage.tsx b/src/cli/components/UserMessage.tsx new file mode 100644 index 0000000..5cf5403 --- /dev/null +++ b/src/cli/components/UserMessage.tsx @@ -0,0 +1,12 @@ +import { Text } from "ink"; +import { memo } from "react"; + +type UserLine = { + kind: "user"; + id: string; + text: string; +}; + +export const UserMessage = memo(({ line }: { line: UserLine }) => { + return {`> ${line.text}`}; +}); diff --git a/src/cli/components/UserMessageRich.tsx b/src/cli/components/UserMessageRich.tsx new file mode 100644 index 0000000..bb148b4 --- /dev/null +++ b/src/cli/components/UserMessageRich.tsx @@ -0,0 +1,41 @@ +import { Box, Text } from "ink"; +import { memo } from "react"; +import { MarkdownDisplay } from "./MarkdownDisplay.js"; + +type UserLine = { + kind: "user"; + id: string; + text: string; +}; + +/** + * UserMessageRich - Rich formatting version with two-column layout + * This is a direct port from the old letta-code codebase to preserve the exact styling + * + * Features: + * - Left column (2 chars wide) with "> " prompt indicator + * - Right column with wrapped text content + * - Full markdown rendering support + */ +export const UserMessage = memo(({ line }: { line: UserLine }) => { + const columns = + typeof process !== "undefined" && + process.stdout && + "columns" in process.stdout + ? ((process.stdout as { columns?: number }).columns ?? 80) + : 80; + const contentWidth = Math.max(0, columns - 2); + + return ( + + + {">"} + + + + + + ); +}); + +UserMessage.displayName = "UserMessage"; diff --git a/src/cli/components/WelcomeScreen.tsx b/src/cli/components/WelcomeScreen.tsx new file mode 100644 index 0000000..e5032a4 --- /dev/null +++ b/src/cli/components/WelcomeScreen.tsx @@ -0,0 +1,53 @@ +import { Box, Text } from "ink"; +import { colors } from "./colors"; + +type LoadingState = + | "assembling" + | "upserting" + | "initializing" + | "checking" + | "ready"; + +export function WelcomeScreen({ + loadingState, + continueSession, + agentId, +}: { + loadingState: LoadingState; + continueSession?: boolean; + agentId?: string; +}) { + const getInitializingMessage = () => { + if (continueSession && agentId) { + return `Resuming agent ${agentId}...`; + } + return "Creating agent..."; + }; + + const getReadyMessage = () => { + if (continueSession && agentId) { + return `Resumed agent (${agentId}). Ready to go!`; + } + if (agentId) { + return `Created a new agent (${agentId}). Ready to go!`; + } + return "Ready to go!"; + }; + + const stateMessages: Record = { + assembling: "Assembling tools...", + upserting: "Upserting tools...", + initializing: getInitializingMessage(), + checking: "Checking for pending approvals...", + ready: getReadyMessage(), + }; + + return ( + + + Letta Code + + {stateMessages[loadingState]} + + ); +} diff --git a/src/cli/components/colors.ts b/src/cli/components/colors.ts new file mode 100644 index 0000000..29a78db --- /dev/null +++ b/src/cli/components/colors.ts @@ -0,0 +1,148 @@ +/** + * Letta Code Color System + * + * This file defines all colors used in the application. + * No colors should be hardcoded in components - all should reference this file. + */ + +// Brand colors (dark mode) +export const brandColors = { + orange: "#FF5533", // dark orange + blue: "#0707AC", // dark blue + // text colors + primaryAccent: "#8C8CF9", // lighter blue + primaryAccentLight: "#BEBEEE", // even lighter blue + textMain: "#DEE1E4", // white + textSecondary: "#A5A8AB", // light grey + textDisabled: "#46484A", // dark grey + // status colors + statusSuccess: "#64CF64", // green + statusWarning: "FEE19C", // yellow + statusError: "#F1689F", // red +} as const; + +// Brand colors (light mode) +export const brandColorsLight = { + orange: "#FF5533", // dark orange + blue: "#0707AC", // dark blue + // text colors + primaryAccent: "#3939BD", // lighter blue + primaryAccentLight: "#A9A9DE", // even lighter blue + textMain: "#202020", // white + textSecondary: "#797B7D", // light grey + textDisabled: "#A5A8AB", // dark grey + // status colors + statusSuccess: "#28A428", // green + statusWarning: "#B98813", // yellow + statusError: "#BA024C", // red +} as const; + +// Semantic color system +export const colors = { + // Welcome screen + welcome: { + border: brandColors.primaryAccent, + accent: brandColors.primaryAccent, + }, + + // Selector boxes (model, agent, generic select) + selector: { + border: brandColors.primaryAccentLight, + title: brandColors.primaryAccentLight, + itemHighlighted: brandColors.primaryAccent, + itemCurrent: brandColors.statusSuccess, // for "(current)" label + }, + + // Command autocomplete and command messages + command: { + selected: brandColors.primaryAccent, + inactive: brandColors.textDisabled, // uses dimColor prop + border: brandColors.textDisabled, + running: brandColors.statusWarning, + error: brandColors.statusError, + }, + + // Approval/HITL screens + approval: { + border: brandColors.primaryAccentLight, + header: brandColors.primaryAccent, + }, + + // Code and markdown elements + code: { + inline: brandColors.statusSuccess, + }, + + link: { + text: brandColors.primaryAccent, + url: brandColors.primaryAccent, + }, + + heading: { + primary: brandColors.primaryAccent, + secondary: brandColors.blue, + }, + + // Status indicators + status: { + error: brandColors.statusError, + success: brandColors.statusSuccess, + interrupt: brandColors.statusError, + processing: brandColors.primaryAccent, // base text color + processingShimmer: brandColors.primaryAccentLight, // shimmer highlight + }, + + // Tool calls + tool: { + pending: brandColors.textSecondary, // blinking dot (ready/waiting for approval) + completed: brandColors.statusSuccess, // solid green dot (finished successfully) + streaming: brandColors.textDisabled, // solid gray dot (streaming/in progress) + running: brandColors.statusWarning, // blinking yellow dot (executing) + error: brandColors.statusError, // solid red dot (failed) + }, + + // Input box + input: { + border: brandColors.textDisabled, + prompt: brandColors.textMain, + }, + + // Todo list + todo: { + completed: brandColors.blue, + inProgress: brandColors.primaryAccent, + }, + + // Info/modal views + info: { + border: brandColors.primaryAccent, + prompt: "blue", + }, + + // Diff rendering + diff: { + addedLineBg: "#1a4d1a", + addedWordBg: "#2d7a2d", + removedLineBg: "#4d1a1a", + removedWordBg: "#7a2d2d", + contextLineBg: undefined, + textOnDark: "white", + textOnHighlight: "black", + symbolAdd: "green", + symbolRemove: "red", + symbolContext: undefined, + }, + + // Error display + error: { + border: "red", + text: "red", + }, + + // Generic text colors (used with dimColor prop or general text) + text: { + normal: "white", + dim: "gray", + bold: "white", + }, +} as const; diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts new file mode 100644 index 0000000..9c4c642 --- /dev/null +++ b/src/cli/helpers/accumulator.ts @@ -0,0 +1,355 @@ +// src/cli/accumulator.ts +// Minimal, token-aware accumulator for Letta streams. +// - Single transcript via { order[], byId: Map }. +// - Tool calls update in-place (same toolCallId for call+return). +// - Exposes `onChunk` to feed SDK events and `toLines` to render. + +import type { Letta } from "@letta-ai/letta-client"; + +// One line per transcript row. Tool calls evolve in-place. +// For tool call returns, merge into the tool call matching the toolCallId +export type Line = + | { kind: "user"; id: string; text: string } + | { + kind: "reasoning"; + id: string; + text: string; + phase: "streaming" | "finished"; + } + | { + kind: "assistant"; + id: string; + text: string; + phase: "streaming" | "finished"; + } + | { + kind: "tool_call"; + id: string; + // from the tool call object + // toolCallId and name should come in the very first chunk + toolCallId?: string; + name?: string; + argsText?: string; + // from the tool return object + resultText?: string; + resultOk?: boolean; + // state that's useful for rendering + phase: "streaming" | "ready" | "running" | "finished"; + } + | { kind: "error"; id: string; text: string } + | { + kind: "command"; + id: string; + input: string; + output: string; + phase?: "running" | "finished"; + success?: boolean; + }; + +// Top-level state object for all streaming events +export type Buffers = { + tokenCount: number; + order: string[]; + byId: Map; + pendingToolByRun: Map; // temporary id per run until real id + toolCallIdToLineId: Map; + lastOtid: string | null; // Track the last otid to detect transitions + pendingRefresh?: boolean; // Track throttled refresh state +}; + +export function createBuffers(): Buffers { + return { + tokenCount: 0, + order: [], + byId: new Map(), + pendingToolByRun: new Map(), + toolCallIdToLineId: new Map(), + lastOtid: null, + }; +} + +// Guarantees that there's only one line per ID +// If byId already has that id, returns the Line (for mutation) +// If not, makes a new line and adds it +function ensure(b: Buffers, id: string, make: () => T): T { + const existing = b.byId.get(id) as T | undefined; + if (existing) return existing; + const created = make(); + b.byId.set(id, created); + b.order.push(id); + return created; +} + +// Mark a line as finished if it has a phase (immutable update) +function markAsFinished(b: Buffers, id: string) { + const line = b.byId.get(id); + // console.log(`[MARK_FINISHED] Called for ${id}, line exists: ${!!line}, kind: ${line?.kind}, phase: ${(line as any)?.phase}`); + if (line && "phase" in line && line.phase === "streaming") { + const updatedLine = { ...line, phase: "finished" as const }; + b.byId.set(id, updatedLine); + // console.log(`[MARK_FINISHED] Successfully marked ${id} as finished`); + } else { + // console.log(`[MARK_FINISHED] Did NOT mark ${id} as finished (conditions not met)`); + } +} + +// Helper to mark previous otid's line as finished when transitioning to new otid +function handleOtidTransition(b: Buffers, newOtid: string | undefined) { + // console.log(`[OTID_TRANSITION] Called with newOtid=${newOtid}, lastOtid=${b.lastOtid}`); + + // If transitioning to a different otid (including null/undefined), finish only assistant/reasoning lines. + // Tool calls should finish exclusively when a tool_return arrives (merged by toolCallId). + if (b.lastOtid && b.lastOtid !== newOtid) { + const prev = b.byId.get(b.lastOtid); + // console.log(`[OTID_TRANSITION] Found prev line: kind=${prev?.kind}, phase=${(prev as any)?.phase}`); + if (prev && (prev.kind === "assistant" || prev.kind === "reasoning")) { + // console.log(`[OTID_TRANSITION] Marking ${b.lastOtid} as finished (was ${(prev as any).phase})`); + markAsFinished(b, b.lastOtid); + } + } + + // Update last otid (can be null) + b.lastOtid = newOtid ?? null; + // console.log(`[OTID_TRANSITION] Updated lastOtid to ${b.lastOtid}`); +} + +/** + * Mark the current (last) line as finished when the stream ends. + * Call this after stream completion to ensure the final line isn't stuck in "streaming" state. + */ +export function markCurrentLineAsFinished(b: Buffers) { + // console.log(`[MARK_CURRENT_FINISHED] Called with lastOtid=${b.lastOtid}`); + if (!b.lastOtid) { + // console.log(`[MARK_CURRENT_FINISHED] No lastOtid, returning`); + return; + } + // Try both the plain otid and the -tool suffix (in case of collision workaround) + const prev = b.byId.get(b.lastOtid) || b.byId.get(`${b.lastOtid}-tool`); + // console.log(`[MARK_CURRENT_FINISHED] Found line: kind=${prev?.kind}, phase=${(prev as any)?.phase}`); + if (prev && (prev.kind === "assistant" || prev.kind === "reasoning")) { + // console.log(`[MARK_CURRENT_FINISHED] Marking ${b.lastOtid} as finished`); + markAsFinished(b, b.lastOtid); + } else { + // console.log(`[MARK_CURRENT_FINISHED] Not marking (not assistant/reasoning or doesn't exist)`); + } +} + +type ToolCallLine = Extract; + +// Flatten common SDK "parts" → text +function isRecord(v: unknown): v is Record { + return v !== null && typeof v === "object"; +} +function getStringProp(obj: Record, key: string) { + const v = obj[key]; + return typeof v === "string" ? v : undefined; +} +function extractTextPart(v: unknown): string { + if (typeof v === "string") return v; + if (Array.isArray(v)) { + return v + .map((p) => (isRecord(p) ? (getStringProp(p, "text") ?? "") : "")) + .join(""); + } + if (isRecord(v)) { + return getStringProp(v, "text") ?? getStringProp(v, "delta") ?? ""; + } + return ""; +} + +// Feed one SDK chunk; mutate buffers in place. +export function onChunk( + b: Buffers, + chunk: Letta.agents.LettaStreamingResponse, +) { + switch (chunk.messageType) { + case "reasoning_message": { + const id = chunk.otid; + // console.log(`[REASONING] Received chunk with otid=${id}, delta="${chunk.reasoning?.substring(0, 50)}..."`); + if (!id) { + // console.log(`[REASONING] No otid, breaking`); + break; + } + + // Handle otid transition (mark previous line as finished) + handleOtidTransition(b, id); + + const delta = chunk.reasoning; + const line = ensure(b, id, () => ({ + kind: "reasoning", + id, + text: "", + phase: "streaming", + })); + if (delta) { + // Immutable update: create new object with updated text + const updatedLine = { ...line, text: line.text + delta }; + b.byId.set(id, updatedLine); + b.tokenCount += delta.length; + // console.log(`[REASONING] Updated ${id}, phase=${updatedLine.phase}, textLen=${updatedLine.text.length}`); + } + break; + } + + case "assistant_message": { + const id = chunk.otid; + if (!id) break; + + // Handle otid transition (mark previous line as finished) + handleOtidTransition(b, id); + + const delta = extractTextPart(chunk.content); // NOTE: may be list of parts + const line = ensure(b, id, () => ({ + kind: "assistant", + id, + text: "", + phase: "streaming", + })); + if (delta) { + // Immutable update: create new object with updated text + const updatedLine = { ...line, text: line.text + delta }; + b.byId.set(id, updatedLine); + b.tokenCount += delta.length; + } + break; + } + + case "tool_call_message": + case "approval_request_message": { + /* POST-FIX VERSION (what this should look like after backend fix): + const id = chunk.otid; + + // Handle otid transition (mark previous line as finished) + handleOtidTransition(b, id); + + if (!id) break; + + const toolCallId = chunk.toolCall?.toolCallId; + const name = chunk.toolCall?.name; + const argsText = chunk.toolCall?.arguments; + + // Record correlation: toolCallId → line id (otid) + if (toolCallId) b.toolCallIdToLineId.set(toolCallId, id); + */ + + let id = chunk.otid; + // console.log(`[TOOL_CALL] Received ${chunk.messageType} with otid=${id}, toolCallId=${chunk.toolCall?.toolCallId}, name=${chunk.toolCall?.name}`); + + const toolCallId = chunk.toolCall?.toolCallId; + const name = chunk.toolCall?.name; + const argsText = chunk.toolCall?.arguments; + + // ========== START BACKEND BUG WORKAROUND (Remove after OTID fix) ========== + // Bug: Backend sends same otid for reasoning and tool_call, and multiple otids for same tool_call + + // Check if we already have a line for this toolCallId (prevents duplicates) + if (toolCallId && b.toolCallIdToLineId.has(toolCallId)) { + // Update the existing line instead of creating a new one + const existingId = b.toolCallIdToLineId.get(toolCallId); + if (existingId) { + id = existingId; + } + + // Handle otid transition for tracking purposes + handleOtidTransition(b, chunk.otid); + } else { + // Check if this otid is already used by a reasoning line + if (id && b.byId.has(id)) { + const existing = b.byId.get(id); + if (existing && existing.kind === "reasoning") { + // Mark the reasoning as finished before we create the tool_call + markAsFinished(b, id); + // Use a different ID for the tool_call to avoid overwriting the reasoning + id = `${id}-tool`; + } + } + // ========== END BACKEND BUG WORKAROUND ========== + + // This part stays after fix: + // Handle otid transition (mark previous line as finished) + // This must happen BEFORE the break, so reasoning gets finished even when tool has no otid + handleOtidTransition(b, id); + + if (!id) { + // console.log(`[TOOL_CALL] No otid, breaking`); + break; + } + + // Record correlation: toolCallId → line id (otid) for future updates + if (toolCallId) b.toolCallIdToLineId.set(toolCallId, id); + } + + const desiredPhase = + chunk.messageType === "approval_request_message" + ? "ready" + : "streaming"; + const line = ensure(b, id, () => ({ + kind: "tool_call", + id, + toolCallId: toolCallId, + name: name, + phase: desiredPhase, + })); + + // If this is an approval request and the line already exists, bump phase to ready + if ( + chunk.messageType === "approval_request_message" && + line.phase !== "finished" + ) { + b.byId.set(id, { ...line, phase: "ready" }); + } + + // if argsText is not empty, add it to the line (immutable update) + if (argsText !== undefined) { + const updatedLine = { + ...line, + argsText: (line.argsText ?? "") + argsText, + }; + b.byId.set(id, updatedLine); + } + break; + } + + case "tool_return_message": { + // Tool return is a special case + // It will have a different otid than the tool call, but we want to merge into the tool call + const toolCallId = chunk.toolCallId; + const resultText = chunk.toolReturn; + const status = chunk.status; + + // Look up the line by toolCallId + // Keep a mapping of toolCallId to line id (otid) + const id = toolCallId ? b.toolCallIdToLineId.get(toolCallId) : undefined; + if (!id) break; + + const line = ensure(b, id, () => ({ + kind: "tool_call", + id, + phase: "finished", + })); + + // Immutable update: create new object with result + const updatedLine = { + ...line, + resultText, + phase: "finished" as const, + resultOk: status === "success", + }; + b.byId.set(id, updatedLine); + break; + } + + default: + break; // ignore ping/usage/etc + } +} + +// Derive a flat transcript +export function toLines(b: Buffers): Line[] { + const out: Line[] = []; + for (const id of b.order) { + const line = b.byId.get(id); + if (line) out.push(line); + } + return out; +} diff --git a/src/cli/helpers/backfill.ts b/src/cli/helpers/backfill.ts new file mode 100644 index 0000000..7f75a83 --- /dev/null +++ b/src/cli/helpers/backfill.ts @@ -0,0 +1,157 @@ +import type { Letta } from "@letta-ai/letta-client"; +import type { Buffers } from "./accumulator"; + +// const PASTE_LINE_THRESHOLD = 5; +// const PASTE_CHAR_THRESHOLD = 500; +const CLIP_CHAR_LIMIT_TEXT = 500; +// const CLIP_CHAR_LIMIT_JSON = 1000; + +// function countLines(text: string): number { +// return (text.match(/\r\n|\r|\n/g) || []).length + 1; +// } + +function clip(s: string, limit: number): string { + if (!s) return ""; + return s.length > limit ? `${s.slice(0, limit)}…` : s; +} + +function renderAssistantContentParts( + parts: Letta.AssistantMessageContent, +): string { + // AssistantContent can be a string or an array of text parts + if (typeof parts === "string") return parts; + let out = ""; + for (const p of parts) { + if (p.type === "text") { + out += p.text || ""; + } + } + return out; +} + +function renderUserContentParts(parts: Letta.UserMessageContent): string { + // UserContent can be a string or an array of text OR image parts + // for text parts, we clip them if they're too big (eg copy-pasted chunks) + // for image parts, we just show a placeholder + if (typeof parts === "string") return parts; + + let out = ""; + for (const p of parts) { + if (p.type === "text") { + const text = p.text || ""; + out += clip(text, CLIP_CHAR_LIMIT_TEXT); + } else if (p.type === "image") { + out += `[Image]`; + } + } + return out; +} + +export function backfillBuffers( + buffers: Buffers, + history: Letta.LettaMessageUnion[], +): void { + // Clear buffers to ensure idempotency (in case this is called multiple times) + buffers.order = []; + buffers.byId.clear(); + buffers.toolCallIdToLineId.clear(); + buffers.pendingToolByRun.clear(); + buffers.lastOtid = null; + // Note: we don't reset tokenCount here (it resets per-turn in onSubmit) + + // Iterate over the history and add the messages to the buffers + // Want to add user, reasoning, assistant, tool call + tool return + for (const msg of history) { + // Use otid as line ID when available (like streaming does), fall back to msg.id + const lineId = "otid" in msg && msg.otid ? msg.otid : msg.id; + + switch (msg.messageType) { + // user message - content parts may include text and image parts + case "user_message": { + const exists = buffers.byId.has(lineId); + buffers.byId.set(lineId, { + kind: "user", + id: lineId, + text: renderUserContentParts(msg.content), + }); + if (!exists) buffers.order.push(lineId); + break; + } + + // reasoning message - + case "reasoning_message": { + const exists = buffers.byId.has(lineId); + buffers.byId.set(lineId, { + kind: "reasoning", + id: lineId, + text: msg.reasoning, + phase: "finished", + }); + if (!exists) buffers.order.push(lineId); + break; + } + + // assistant message - content parts may include text and image parts + case "assistant_message": { + const exists = buffers.byId.has(lineId); + buffers.byId.set(lineId, { + kind: "assistant", + id: lineId, + text: renderAssistantContentParts(msg.content), + phase: "finished", + }); + if (!exists) buffers.order.push(lineId); + break; + } + + // tool call message OR approval request (they're the same in history) + case "tool_call_message": + case "approval_request_message": { + if ("toolCall" in msg && msg.toolCall?.toolCallId) { + const toolCall = msg.toolCall; + const toolCallId = toolCall.toolCallId; + const exists = buffers.byId.has(lineId); + + buffers.byId.set(lineId, { + kind: "tool_call", + id: lineId, + toolCallId: toolCallId, + name: toolCall.name, + argsText: toolCall.arguments, + phase: "ready", + }); + if (!exists) buffers.order.push(lineId); + + // Maintain mapping for tool return to find this line + buffers.toolCallIdToLineId.set(toolCallId, lineId); + } + break; + } + + // tool return message - merge into the existing tool call line + case "tool_return_message": { + const toolCallId = msg.toolCallId; + if (!toolCallId) break; + + // Look up the line using the mapping (like streaming does) + const toolCallLineId = buffers.toolCallIdToLineId.get(toolCallId); + if (!toolCallLineId) break; + + const existingLine = buffers.byId.get(toolCallLineId); + if (!existingLine || existingLine.kind !== "tool_call") break; + + // Update the existing line with the result + buffers.byId.set(toolCallLineId, { + ...existingLine, + resultText: msg.toolReturn, + resultOk: msg.status === "success", + phase: "finished", + }); + break; + } + + default: + break; // ignore other message types + } + } +} diff --git a/src/cli/helpers/clipboard.ts b/src/cli/helpers/clipboard.ts new file mode 100644 index 0000000..cbda9ae --- /dev/null +++ b/src/cli/helpers/clipboard.ts @@ -0,0 +1,170 @@ +// Clipboard utilities for detecting and importing images from system clipboard +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { basename, extname, isAbsolute, resolve } from "node:path"; +import { allocateImage } from "./pasteRegistry"; + +const IMAGE_EXTS = new Set([ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".bmp", + ".svg", + ".tif", + ".tiff", + ".heic", + ".heif", + ".avif", +]); + +function countLines(text: string): number { + return (text.match(/\r\n|\r|\n/g) || []).length + 1; +} + +// Translate various image paste formats into [Image #N] placeholders +export function translatePasteForImages(paste: string): string { + let s = paste || ""; + + // 1) iTerm2 OSC 1337 inline file transfer: ESC ] 1337;File=...:BASE64 + try { + // Build regex via code points to avoid control chars in literal + const ESC = "\u001B"; + const BEL = "\u0007"; + const ST = `${ESC}\\`; // ESC \ + const pattern = `${ESC}]1337;File=([^${BEL}${ESC}]*):([\\s\\S]*?)(?:${BEL}|${ST})`; + const OSC = new RegExp(pattern, "g"); + s = s.replace(OSC, (_m, paramsStr: string, base64: string) => { + const params: Record = {}; + for (const seg of String(paramsStr || "").split(";")) { + const [k, v] = seg.split("="); + if (k && v) + params[k.trim().toLowerCase()] = decodeURIComponent(v.trim()); + } + const name = params.name || undefined; + const mt = params.type || params.mime || "application/octet-stream"; + const id = allocateImage({ data: base64, mediaType: mt, filename: name }); + return `[Image #${id}]`; + }); + } catch {} + + // 2) Data URL images + try { + const DATA_URL = /data:image\/([a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)/g; + s = s.replace(DATA_URL, (_m, subtype: string, b64: string) => { + const mt = `image/${subtype}`; + const id = allocateImage({ data: b64, mediaType: mt }); + return `[Image #${id}]`; + }); + } catch {} + + // 3) Single image file path paste + try { + const trimmed = s.trim(); + const singleLine = countLines(trimmed) <= 1; + if (singleLine) { + let filePath = trimmed; + if (/^file:\/\//i.test(filePath)) { + try { + // Decode file:// URL + const u = new URL(filePath); + filePath = decodeURIComponent(u.pathname); + // On Windows, pathname starts with /C:/ + if (process.platform === "win32" && /^\/[A-Za-z]:\//.test(filePath)) { + filePath = filePath.slice(1); + } + } catch {} + } + // If relative, resolve against CWD + if (!isAbsolute(filePath)) filePath = resolve(process.cwd(), filePath); + const ext = extname(filePath || "").toLowerCase(); + if ( + IMAGE_EXTS.has(ext) && + existsSync(filePath) && + statSync(filePath).isFile() + ) { + const buf = readFileSync(filePath); + const b64 = buf.toString("base64"); + const mt = + ext === ".png" + ? "image/png" + : ext === ".jpg" || ext === ".jpeg" + ? "image/jpeg" + : ext === ".gif" + ? "image/gif" + : ext === ".webp" + ? "image/webp" + : ext === ".bmp" + ? "image/bmp" + : ext === ".svg" + ? "image/svg+xml" + : ext === ".tif" || ext === ".tiff" + ? "image/tiff" + : ext === ".heic" + ? "image/heic" + : ext === ".heif" + ? "image/heif" + : ext === ".avif" + ? "image/avif" + : "application/octet-stream"; + const id = allocateImage({ + data: b64, + mediaType: mt, + filename: basename(filePath), + }); + s = `[Image #${id}]`; + } + } + } catch {} + + return s; +} + +// Attempt to import an image directly from OS clipboard on macOS via JXA (built-in) +export function tryImportClipboardImageMac(): string | null { + if (process.platform !== "darwin") return null; + try { + const jxa = ` + ObjC.import('AppKit'); + (function() { + var pb = $.NSPasteboard.generalPasteboard; + var types = ['public.png','public.jpeg','public.tiff','public.heic','public.heif','public.bmp','public.gif','public.svg-image']; + for (var i = 0; i < types.length; i++) { + var t = types[i]; + var d = pb.dataForType(t); + if (d) { + var b64 = d.base64EncodedStringWithOptions(0).js; + return t + '|' + b64; + } + } + return ''; + })(); + `; + const out = execFileSync("osascript", ["-l", "JavaScript", "-e", jxa], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + if (!out) return null; + const idx = out.indexOf("|"); + if (idx <= 0) return null; + const uti = out.slice(0, idx); + const b64 = out.slice(idx + 1); + if (!b64) return null; + const map: Record = { + "public.png": "image/png", + "public.jpeg": "image/jpeg", + "public.tiff": "image/tiff", + "public.heic": "image/heic", + "public.heif": "image/heif", + "public.bmp": "image/bmp", + "public.gif": "image/gif", + "public.svg-image": "image/svg+xml", + }; + const mediaType = map[uti] || "image/png"; + const id = allocateImage({ data: b64, mediaType }); + return `[Image #${id}]`; + } catch { + return null; + } +} diff --git a/src/cli/helpers/diff.ts b/src/cli/helpers/diff.ts new file mode 100644 index 0000000..442f19b --- /dev/null +++ b/src/cli/helpers/diff.ts @@ -0,0 +1,193 @@ +import { basename } from "node:path"; +import * as Diff from "diff"; + +export const ADV_DIFF_CONTEXT_LINES = 1; // easy to adjust later +export const ADV_DIFF_IGNORE_WHITESPACE = true; // easy to flip later + +export type AdvancedDiffVariant = "write" | "edit" | "multi_edit"; + +export interface AdvancedEditInput { + kind: "edit"; + filePath: string; + oldString: string; + newString: string; + replaceAll?: boolean; +} + +export interface AdvancedWriteInput { + kind: "write"; + filePath: string; + content: string; +} + +export interface AdvancedMultiEditInput { + kind: "multi_edit"; + filePath: string; + edits: Array<{ + old_string: string; + new_string: string; + replace_all?: boolean; + }>; +} + +export type AdvancedDiffInput = + | AdvancedEditInput + | AdvancedWriteInput + | AdvancedMultiEditInput; + +export interface AdvancedHunkLine { + raw: string; // original line from structuredPatch (includes prefix) +} + +export interface AdvancedHunk { + oldStart: number; + newStart: number; + lines: AdvancedHunkLine[]; // pass through; renderer will compute numbers/word pairs +} + +export interface AdvancedDiffSuccess { + mode: "advanced"; + fileName: string; + oldStr: string; + newStr: string; + hunks: AdvancedHunk[]; +} + +export interface AdvancedDiffFallback { + mode: "fallback"; + reason: string; +} + +export interface AdvancedDiffUnpreviewable { + mode: "unpreviewable"; + reason: string; +} + +export type AdvancedDiffResult = + | AdvancedDiffSuccess + | AdvancedDiffFallback + | AdvancedDiffUnpreviewable; + +function readFileOrNull(p: string): string | null { + try { + const _file = Bun.file(p); + // Note: Bun.file().text() is async, but we need sync for diff preview + // Fall back to node:fs for sync reading + return require("node:fs").readFileSync(p, "utf-8"); + } catch { + return null; + } +} + +function applyFirstOccurrence( + content: string, + oldStr: string, + newStr: string, +): { ok: true; out: string } | { ok: false; reason: string } { + const idx = content.indexOf(oldStr); + if (idx === -1) return { ok: false, reason: "old_string not found" }; + const out = + content.slice(0, idx) + newStr + content.slice(idx + oldStr.length); + return { ok: true, out }; +} + +function applyAllOccurrences( + content: string, + oldStr: string, + newStr: string, +): { ok: true; out: string } | { ok: false; reason: string } { + if (!oldStr) return { ok: false, reason: "old_string empty" }; + const occurrences = content.split(oldStr).length - 1; + if (occurrences === 0) return { ok: false, reason: "old_string not found" }; + return { ok: true, out: content.split(oldStr).join(newStr) }; +} + +export function computeAdvancedDiff( + input: AdvancedDiffInput, + opts?: { oldStrOverride?: string }, +): AdvancedDiffResult { + const fileName = basename(input.filePath || ""); + + // Fetch current content (oldStr). For write on new file, treat missing as '' and continue. + const fileContent = + opts?.oldStrOverride !== undefined + ? opts.oldStrOverride + : readFileOrNull(input.filePath); + if (fileContent === null && input.kind !== "write") { + return { mode: "fallback", reason: "File not readable" }; + } + + const oldStr = fileContent ?? ""; + let newStr = oldStr; + + if (input.kind === "write") { + newStr = input.content; + } else if (input.kind === "edit") { + const replaceAll = !!input.replaceAll; + const applied = replaceAll + ? applyAllOccurrences(oldStr, input.oldString, input.newString) + : applyFirstOccurrence(oldStr, input.oldString, input.newString); + if (!applied.ok) { + return { + mode: "unpreviewable", + reason: `Edit cannot be previewed: ${applied.reason}`, + }; + } + newStr = applied.out; + } else if (input.kind === "multi_edit") { + let working = oldStr; + for (const e of input.edits) { + const replaceAll = !!e.replace_all; + if (replaceAll) { + const occ = working.split(e.old_string).length - 1; + if (occ === 0) + return { mode: "unpreviewable", reason: "Edit not found in file" }; + const res = applyAllOccurrences(working, e.old_string, e.new_string); + if (!res.ok) + return { + mode: "unpreviewable", + reason: `Edit cannot be previewed: ${res.reason}`, + }; + working = res.out; + } else { + const occ = working.split(e.old_string).length - 1; + if (occ === 0) + return { mode: "unpreviewable", reason: "Edit not found in file" }; + if (occ > 1) + return { + mode: "unpreviewable", + reason: `Multiple matches (${occ}), replace_all=false`, + }; + const res = applyFirstOccurrence(working, e.old_string, e.new_string); + if (!res.ok) + return { + mode: "unpreviewable", + reason: `Edit cannot be previewed: ${res.reason}`, + }; + working = res.out; + } + } + newStr = working; + } + + const patch = Diff.structuredPatch( + fileName, + fileName, + oldStr, + newStr, + "Current", + "Proposed", + { + context: ADV_DIFF_CONTEXT_LINES, + ignoreWhitespace: ADV_DIFF_IGNORE_WHITESPACE, + }, + ); + + const hunks: AdvancedHunk[] = patch.hunks.map((h) => ({ + oldStart: h.oldStart, + newStart: h.newStart, + lines: h.lines.map((l) => ({ raw: l })), + })); + + return { mode: "advanced", fileName, oldStr, newStr, hunks }; +} diff --git a/src/cli/helpers/formatArgsDisplay.ts b/src/cli/helpers/formatArgsDisplay.ts new file mode 100644 index 0000000..a6e819c --- /dev/null +++ b/src/cli/helpers/formatArgsDisplay.ts @@ -0,0 +1,69 @@ +// Utility to format tool argument JSON strings into a concise display label +// Copied from old letta-code repo to preserve exact formatting behavior + +// Small helpers +const isRecord = (v: unknown): v is Record => + typeof v === "object" && v !== null; + +export function formatArgsDisplay(argsJson: string): { + display: string; + parsed: Record; +} { + let parsed: Record = {}; + let display = "…"; + try { + if (argsJson?.trim()) { + const p = JSON.parse(argsJson); + if (isRecord(p)) { + // Drop noisy keys for display + const clone: Record = { ...p } as Record< + string, + unknown + >; + if ("request_heartbeat" in clone) delete clone.request_heartbeat; + parsed = clone; + const keys = Object.keys(parsed); + if ( + keys.length === 1 && + ["query", "path", "file_path", "command", "label"].includes(keys[0]) + ) { + const v = parsed[keys[0]]; + display = typeof v === "string" ? v : String(v); + } else { + display = Object.entries(parsed) + .map(([k, v]) => { + if (v === undefined || v === null) return `${k}=${v}`; + if (typeof v === "boolean" || typeof v === "number") + return `${k}=${v}`; + if (typeof v === "string") + return v.length > 50 ? `${k}=…` : `${k}="${v}"`; + if (Array.isArray(v)) return `${k}=[${v.length} items]`; + if (typeof v === "object") + return `${k}={${Object.keys(v as Record).length} props}`; + const str = JSON.stringify(v); + return str.length > 50 ? `${k}=…` : `${k}=${str}`; + }) + .join(", "); + } + } + } + } catch { + // Fallback: try to extract common keys without full JSON parse + try { + const s = argsJson || ""; + const fp = /"file_path"\s*:\s*"([^"]+)"/.exec(s); + const old = /"old_string"\s*:\s*"([\s\S]*?)"\s*(,|\})/.exec(s); + const neu = /"new_string"\s*:\s*"([\s\S]*?)"\s*(,|\})/.exec(s); + const cont = /"content"\s*:\s*"([\s\S]*?)"\s*(,|\})/.exec(s); + const parts: string[] = []; + if (fp) parts.push(`file_path="${fp[1]}"`); + if (old) parts.push(`old_string=…`); + if (neu) parts.push(`new_string=…`); + if (cont) parts.push(`content=…`); + if (parts.length) display = parts.join(", "); + } catch { + // If all else fails, use the ellipsis + } + } + return { display, parsed }; +} diff --git a/src/cli/helpers/pasteRegistry.ts b/src/cli/helpers/pasteRegistry.ts new file mode 100644 index 0000000..83a0bdb --- /dev/null +++ b/src/cli/helpers/pasteRegistry.ts @@ -0,0 +1,162 @@ +// Clipboard paste registry - manages mappings from placeholders to actual content +// Supports both large text pastes and image pastes (multi-modal) + +export interface ImageEntry { + data: string; // base64 + mediaType: string; + filename?: string; +} + +// Text placeholder registry (for large pasted text collapsed into a placeholder) +const textRegistry = new Map(); + +// Image placeholder registry (maps id -> base64 + mediaType) +const imageRegistry = new Map(); + +let nextId = 1; + +// ---------- Text placeholders ---------- + +export function allocatePaste(content: string): number { + const id = nextId++; + textRegistry.set(id, content); + return id; +} + +export function resolvePlaceholders(text: string): string { + if (!text) return text; + return text.replace( + /\[Pasted text #(\d+) \+(\d+) lines\]/g, + (_match, idStr) => { + const id = Number(idStr); + const content = textRegistry.get(id); + return content !== undefined ? content : _match; + }, + ); +} + +export function extractTextPlaceholderIds(text: string): number[] { + const ids: number[] = []; + if (!text) return ids; + const re = /\[Pasted text #(\d+) \+(\d+) lines\]/g; + let match: RegExpExecArray | null; + // biome-ignore lint/suspicious/noAssignInExpressions: Standard pattern for regex matching + while ((match = re.exec(text)) !== null) { + const id = Number(match[1]); + if (!Number.isNaN(id)) ids.push(id); + } + return ids; +} + +export function hasAnyTextPlaceholders(text: string): boolean { + return /\[Pasted text #\d+ \+\d+ lines\]/.test(text || ""); +} + +// ---------- Image placeholders ---------- + +export function allocateImage(args: { + data: string; + mediaType: string; + filename?: string; +}): number { + const id = nextId++; + imageRegistry.set(id, { + data: args.data, + mediaType: args.mediaType, + filename: args.filename, + }); + return id; +} + +export function getImage(id: number): ImageEntry | undefined { + return imageRegistry.get(id); +} + +export function extractImagePlaceholderIds(text: string): number[] { + const ids: number[] = []; + if (!text) return ids; + const re = /\[Image #(\d+)\]/g; + let match: RegExpExecArray | null; + // biome-ignore lint/suspicious/noAssignInExpressions: Standard pattern for regex matching + while ((match = re.exec(text)) !== null) { + const id = Number(match[1]); + if (!Number.isNaN(id)) ids.push(id); + } + return ids; +} + +export function hasAnyImagePlaceholders(text: string): boolean { + return /\[Image #\d+\]/.test(text || ""); +} + +// ---------- Cleanup ---------- + +export function clearPlaceholdersInText(text: string): void { + // Clear text placeholders referenced in this text + for (const id of extractTextPlaceholderIds(text)) { + if (textRegistry.has(id)) textRegistry.delete(id); + } + // Clear image placeholders referenced in this text + for (const id of extractImagePlaceholderIds(text)) { + if (imageRegistry.has(id)) imageRegistry.delete(id); + } +} + +// ---------- Content Builder ---------- + +// Convert display text (with placeholders) into Letta content parts +// Text placeholders are resolved; image placeholders become image content +type Base64ImageSource = { type: "base64"; mediaType: string; data: string }; +type ContentPart = + | { type: "text"; text: string } + | { type: "image"; source: Base64ImageSource }; + +export function buildMessageContentFromDisplay(text: string): ContentPart[] { + const parts: ContentPart[] = []; + if (!text) return [{ type: "text", text: "" }]; + + const re = /\[Image #(\d+)\]/g; + let lastIdx = 0; + let match: RegExpExecArray | null; + + const pushText = (s: string) => { + if (!s) return; + const resolved = resolvePlaceholders(s); + if (resolved.length === 0) return; + const prev = parts[parts.length - 1]; + if (prev && prev.type === "text") { + prev.text = (prev.text || "") + resolved; + } else { + parts.push({ type: "text", text: resolved }); + } + }; + + // biome-ignore lint/suspicious/noAssignInExpressions: Standard pattern for regex matching + while ((match = re.exec(text)) !== null) { + const start = match.index; + const end = start + match[0].length; + const before = text.slice(lastIdx, start); + pushText(before); + const id = Number(match[1]); + const img = getImage(id); + if (img?.data) { + parts.push({ + type: "image", + source: { + type: "base64", + mediaType: img.mediaType || "image/jpeg", + data: img.data, + }, + }); + } else { + // If mapping missing, keep the literal placeholder as text + pushText(match[0]); + } + lastIdx = end; + } + // Remainder + pushText(text.slice(lastIdx)); + + if (parts.length === 0) return [{ type: "text", text }]; + return parts; +} diff --git a/src/cli/helpers/safeJsonParse.ts b/src/cli/helpers/safeJsonParse.ts new file mode 100644 index 0000000..2d7d9cb --- /dev/null +++ b/src/cli/helpers/safeJsonParse.ts @@ -0,0 +1,25 @@ +/** + * Safe JSON parser that never throws + * Returns parsed value on success, or null on failure + */ +export function safeJsonParse( + json: string, +): { success: true; data: T } | { success: false; error: string } { + try { + const data = JSON.parse(json) as T; + return { success: true, data }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Safe JSON parser that returns the parsed value or a default value + */ +export function safeJsonParseOr(json: string, defaultValue: T): T { + const result = safeJsonParse(json); + return result.success ? result.data : defaultValue; +} diff --git a/src/cli/helpers/stream.ts b/src/cli/helpers/stream.ts new file mode 100644 index 0000000..134bb60 --- /dev/null +++ b/src/cli/helpers/stream.ts @@ -0,0 +1,104 @@ +import { Letta } from "@letta-ai/letta-client"; +import { + type createBuffers, + markCurrentLineAsFinished, + onChunk, +} from "./accumulator"; + +export type ApprovalRequest = { + toolCallId: string; + toolName: string; + toolArgs: string; +}; + +type DrainResult = { + stopReason: Letta.StopReasonType; + lastRunId?: string | null; + lastSeqId?: number | null; + approval?: ApprovalRequest | null; // present only if we ended due to approval +}; + +export async function drainStream( + stream: AsyncIterable, + buffers: ReturnType, + refresh: () => void, +): Promise { + let approvalRequestId: string | null = null; + let toolCallId: string | null = null; + let toolName: string | null = null; + let toolArgs: string | null = null; + + let stopReason: Letta.StopReasonType | null = null; + let lastRunId: string | null = null; + let lastSeqId: number | null = null; + + for await (const chunk of stream) { + // Store the runId and seqId to re-connect if stream is interrupted + if ("runId" in chunk && "seqId" in chunk && chunk.runId && chunk.seqId) { + lastRunId = chunk.runId; + lastSeqId = chunk.seqId; + } + + if (chunk.messageType === "ping") continue; + + // Need to store the approval request ID to send an approval in a new run + if (chunk.messageType === "approval_request_message") { + approvalRequestId = chunk.id; + } + + // NOTE: this this a little ugly - we're basically processing tool name and chunk deltas + // in both the onChunk handler and here, we could refactor to instead pull the tool name + // and JSON args from the mutated lines (eg last mutated line) + if ( + chunk.messageType === "tool_call_message" || + chunk.messageType === "approval_request_message" + ) { + if (chunk.toolCall?.toolCallId) { + toolCallId = chunk.toolCall.toolCallId; + } + if (chunk.toolCall?.name) { + if (toolName) { + // TODO would expect that we should allow stacking? I guess not? + // toolName = toolName + chunk.toolCall.name; + } else { + toolName = chunk.toolCall.name; + } + } + if (chunk.toolCall?.arguments) { + if (toolArgs) { + toolArgs = toolArgs + chunk.toolCall.arguments; + } else { + toolArgs = chunk.toolCall.arguments; + } + } + } + + onChunk(buffers, chunk); + queueMicrotask(refresh); + + if (chunk.messageType === "stop_reason") { + stopReason = chunk.stopReason; + break; // end of turn + } + } + + // Mark the final line as finished now that stream has ended + markCurrentLineAsFinished(buffers); + queueMicrotask(refresh); + + // Package the approval request at the end + const approval = + toolCallId && toolName && toolArgs && approvalRequestId + ? { + toolCallId: toolCallId, + toolName: toolName, + toolArgs: toolArgs, + } + : null; + + if (!stopReason) { + stopReason = Letta.StopReasonType.Error; + } + + return { stopReason, approval, lastRunId, lastSeqId }; +} diff --git a/src/cli/helpers/thinkingMessages.ts b/src/cli/helpers/thinkingMessages.ts new file mode 100644 index 0000000..5204bd6 --- /dev/null +++ b/src/cli/helpers/thinkingMessages.ts @@ -0,0 +1,41 @@ +// Machine god AI themed thinking messages +const THINKING_MESSAGES = [ + "Thinking", + "Processing", + "Computing", + "Calculating", + "Analyzing", + "Synthesizing", + "Deliberating", + "Cogitating", + "Reflecting", + "Reasoning", + "Spinning", + "Focusing", + "Machinating", + "Contemplating", + "Ruminating", + "Considering", + "Pondering", + "Evaluating", + "Assessing", + "Inferring", + "Deducing", + "Interpreting", + "Formulating", + "Strategizing", + "Orchestrating", + "Optimizing", + "Calibrating", + "Indexing", + "Compiling", + "Rendering", + "Executing", + "Initializing", +] as const; + +// Get a random thinking message +export function getRandomThinkingMessage(): string { + const index = Math.floor(Math.random() * THINKING_MESSAGES.length); + return THINKING_MESSAGES[index] ?? "Thinking"; +} diff --git a/src/headless.ts b/src/headless.ts new file mode 100644 index 0000000..47e90e4 --- /dev/null +++ b/src/headless.ts @@ -0,0 +1,188 @@ +import { parseArgs } from "node:util"; +import { Letta } from "@letta-ai/letta-client"; +import { getClient } from "./agent/client"; +import { createAgent } from "./agent/create"; +import { sendMessageStream } from "./agent/message"; +import { createBuffers, toLines } from "./cli/helpers/accumulator"; +import { safeJsonParseOr } from "./cli/helpers/safeJsonParse"; +import { drainStream } from "./cli/helpers/stream"; +import { loadSettings, updateSettings } from "./settings"; +import { checkToolPermission, executeTool } from "./tools/manager"; + +export async function handleHeadlessCommand(argv: string[]) { + const settings = await loadSettings(); + + // Parse CLI args + const { values, positionals } = parseArgs({ + args: argv, + options: { + continue: { type: "boolean", short: "c" }, + agent: { type: "string", short: "a" }, + }, + strict: false, + allowPositionals: true, + }); + + // Get prompt from either positional args or stdin + let prompt = positionals.slice(2).join(" "); + + // If no prompt provided as args, try reading from stdin + if (!prompt) { + // Check if stdin is available (piped input) + if (!process.stdin.isTTY) { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + prompt = Buffer.concat(chunks).toString("utf-8").trim(); + } + } + + if (!prompt) { + console.error("Error: No prompt provided"); + process.exit(1); + } + + const client = getClient(); + + // Resolve agent (same logic as interactive mode) + let agent: Letta.AgentState | null = null; + const specifiedAgentId = values.agent as string | undefined; + const shouldContinue = values.continue as boolean | undefined; + + if (specifiedAgentId) { + try { + agent = await client.agents.retrieve(specifiedAgentId); + } catch (_error) { + console.error(`Agent ${specifiedAgentId} not found, creating new one...`); + } + } + + if (!agent && shouldContinue && settings.lastAgent) { + try { + agent = await client.agents.retrieve(settings.lastAgent); + } catch (_error) { + console.error( + `Previous agent ${settings.lastAgent} not found, creating new one...`, + ); + } + } + + if (!agent) { + agent = await createAgent(); + await updateSettings({ lastAgent: agent.id }); + } + + // Create buffers to accumulate stream + const buffers = createBuffers(); + + // Send message and process stream loop + let currentInput: Array = [ + { + role: Letta.MessageCreateRole.User, + content: [{ type: "text", text: prompt }], + }, + ]; + + try { + while (true) { + const stream = await sendMessageStream(agent.id, currentInput); + + // Drain stream and collect approval requests + const { stopReason, approval } = await drainStream( + stream, + buffers, + () => {}, // No UI refresh needed in headless mode + ); + + // Case 1: Turn ended normally + if (stopReason === Letta.StopReasonType.EndTurn) { + break; + } + + // Case 2: Requires approval + if (stopReason === Letta.StopReasonType.RequiresApproval) { + if (!approval) { + console.error("Unexpected null approval"); + process.exit(1); + } + + const { toolCallId, toolName, toolArgs } = approval; + + // Check permission using existing permission system + const parsedArgs = safeJsonParseOr>( + toolArgs, + {}, + ); + const permission = await checkToolPermission(toolName, parsedArgs); + + // Handle deny decision + if (permission.decision === "deny") { + const denyReason = `Permission denied: ${permission.matchedRule || permission.reason}`; + currentInput = [ + { + type: "approval", + approvalRequestId: toolCallId, + approve: false, + reason: denyReason, + }, + ]; + continue; + } + + // Handle ask decision - in headless mode, auto-deny + if (permission.decision === "ask") { + currentInput = [ + { + type: "approval", + approvalRequestId: toolCallId, + approve: false, + reason: "Tool requires approval (headless mode)", + }, + ]; + continue; + } + + // Permission is "allow" - auto-execute tool and continue loop + const toolResult = await executeTool(toolName, parsedArgs); + + currentInput = [ + { + type: "approval", + approvals: [ + { + type: "tool", + toolCallId, + toolReturn: toolResult.toolReturn, + status: toolResult.status, + stdout: toolResult.stdout, + stderr: toolResult.stderr, + }, + ], + }, + ]; + continue; + } + + // Unexpected stop reason + console.error(`Unexpected stop reason: ${stopReason}`); + process.exit(1); + } + } catch (error) { + console.error(`Error: ${error}`); + process.exit(1); + } + + // Extract final assistant message + const lines = toLines(buffers); + const lastAssistant = [...lines] + .reverse() + .find((line) => line.kind === "assistant"); + + if (lastAssistant && "text" in lastAssistant) { + console.log(lastAssistant.text); + } else { + console.error("No assistant response found"); + process.exit(1); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100755 index 0000000..8af859c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,270 @@ +#!/usr/bin/env bun +import { parseArgs } from "node:util"; +import type { Letta } from "@letta-ai/letta-client"; +import { getResumeData, type ResumeData } from "./agent/check-approval"; +import { getClient } from "./agent/client"; +import { loadSettings } from "./settings"; +import { loadTools, upsertToolsToServer } from "./tools/manager"; + +function printHelp() { + // Keep this plaintext (no colors) so output pipes cleanly + const usage = ` +Letta Code is a general purpose CLI for interacting with Letta agents + +USAGE + # interactive TUI + letta Start a new agent session + letta --continue Resume the last agent session + letta --agent Open a specific agent by ID + + # headless + letta --prompt One-off prompt in headless mode (no TTY UI) + +OPTIONS + -h, --help Show this help and exit + -v, --version Print version and exit + -c, --continue Resume previous session (uses settings.lastAgent) + -a, --agent Use a specific agent ID + -p, --prompt Headless prompt mode + +EXAMPLES + # when installed as an executable + letta --help + letta --continue + letta --agent agent_123 + +`.trim(); + + console.log(usage); +} + +async function main() { + // Load settings first (creates default settings file if it doesn't exist) + const settings = await loadSettings(); + + // Parse command-line arguments (Bun-idiomatic approach using parseArgs) + let values: Record; + try { + const parsed = parseArgs({ + args: Bun.argv, + options: { + help: { type: "boolean", short: "h" }, + continue: { type: "boolean", short: "c" }, + agent: { type: "string", short: "a" }, + prompt: { type: "boolean", short: "p" }, + run: { type: "boolean" }, + tools: { type: "string" }, + allowedTools: { type: "string" }, + disallowedTools: { type: "string" }, + "permission-mode": { type: "string" }, + yolo: { type: "boolean" }, + }, + strict: true, + allowPositionals: true, + }); + values = parsed.values; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + // Improve error message for common mistakes + if (errorMsg.includes("Unknown option")) { + console.error(`Error: ${errorMsg}`); + console.error( + "\nNote: Flags should use double dashes for full names (e.g., --yolo, not -yolo)", + ); + } else { + console.error(`Error: ${errorMsg}`); + } + console.error("Run 'letta --help' for usage information."); + process.exit(1); + } + + // Handle help flag first + if (values.help) { + printHelp(); + process.exit(0); + } + + const shouldContinue = (values.continue as boolean | undefined) ?? false; + const specifiedAgentId = (values.agent as string | undefined) ?? null; + const isHeadless = values.prompt || values.run || !process.stdin.isTTY; + + // Validate API key early before any UI rendering + const apiKey = process.env.LETTA_API_KEY; + if (!apiKey) { + console.error("Missing LETTA_API_KEY"); + process.exit(1); + } + + // Set tool filter if provided (controls which tools are loaded) + if (values.tools !== undefined) { + const { toolFilter } = await import("./tools/filter"); + toolFilter.setEnabledTools(values.tools as string); + } + + // Set CLI permission overrides if provided + if (values.allowedTools || values.disallowedTools) { + const { cliPermissions } = await import("./permissions/cli"); + if (values.allowedTools) { + cliPermissions.setAllowedTools(values.allowedTools as string); + } + if (values.disallowedTools) { + cliPermissions.setDisallowedTools(values.disallowedTools as string); + } + } + + // Set permission mode if provided (or via --yolo alias) + const permissionModeValue = values["permission-mode"] as string | undefined; + const yoloMode = values.yolo as boolean | undefined; + + if (yoloMode || permissionModeValue) { + const { permissionMode } = await import("./permissions/mode"); + + if (yoloMode) { + // --yolo is an alias for --permission-mode bypassPermissions + permissionMode.setMode("bypassPermissions"); + } else if (permissionModeValue) { + const mode = permissionModeValue; + const validModes = [ + "default", + "acceptEdits", + "plan", + "bypassPermissions", + ] as const; + + if (validModes.includes(mode as (typeof validModes)[number])) { + permissionMode.setMode(mode as (typeof validModes)[number]); + } else { + console.error( + `Invalid permission mode: ${mode}. Valid modes: ${validModes.join(", ")}`, + ); + process.exit(1); + } + } + } + + if (isHeadless) { + // For headless mode, load tools synchronously + await loadTools(); + const client = getClient(); + await upsertToolsToServer(client); + + const { handleHeadlessCommand } = await import("./headless"); + await handleHeadlessCommand(Bun.argv); + return; + } + + // Interactive: lazy-load React/Ink + App + const React = await import("react"); + const { render } = await import("ink"); + const { useState, useEffect } = React; + const AppModule = await import("./cli/App"); + const App = AppModule.default; + + function LoadingApp({ + continueSession, + agentIdArg, + }: { + continueSession: boolean; + agentIdArg: string | null; + }) { + const [loadingState, setLoadingState] = useState< + "assembling" | "upserting" | "initializing" | "checking" | "ready" + >("assembling"); + const [agentId, setAgentId] = useState(null); + const [resumeData, setResumeData] = useState(null); + + useEffect(() => { + async function init() { + setLoadingState("assembling"); + await loadTools(); + + setLoadingState("upserting"); + const client = getClient(); + await upsertToolsToServer(client); + + setLoadingState("initializing"); + const { createAgent } = await import("./agent/create"); + const { updateSettings } = await import("./settings"); + + let agent: Letta.AgentState | null = null; + + // Priority 1: Try to use --agent specified ID + if (agentIdArg) { + try { + agent = await client.agents.retrieve(agentIdArg); + // console.log(`Using agent ${agentIdArg}...`); + } catch (error) { + console.error( + `Agent ${agentIdArg} not found (error: ${JSON.stringify(error)}), creating new one...`, + ); + } + } + + // Priority 2: Try to reuse lastAgent if --continue flag is passed + if (!agent && continueSession && settings.lastAgent) { + try { + agent = await client.agents.retrieve(settings.lastAgent); + // console.log(`Continuing previous agent ${settings.lastAgent}...`); + } catch (error) { + console.error( + `Previous agent ${settings.lastAgent} not found (error: ${JSON.stringify(error)}), creating new one...`, + ); + } + } + + // Priority 3: Create a new agent + if (!agent) { + agent = await createAgent(); + // Save the new agent ID to settings + await updateSettings({ lastAgent: agent.id }); + } + + // Get resume data (pending approval + message history) if continuing session or using specific agent + if (continueSession || agentIdArg) { + setLoadingState("checking"); + const data = await getResumeData(client, agent.id); + setResumeData(data); + } + + setAgentId(agent.id); + setLoadingState("ready"); + } + + init(); + }, [continueSession, agentIdArg]); + + const isResumingSession = continueSession || !!agentIdArg; + + if (!agentId) { + return React.createElement(App, { + agentId: "loading", + loadingState, + continueSession: isResumingSession, + startupApproval: resumeData?.pendingApproval ?? null, + messageHistory: resumeData?.messageHistory ?? [], + tokenStreaming: settings.tokenStreaming, + }); + } + + return React.createElement(App, { + agentId, + loadingState, + continueSession: isResumingSession, + startupApproval: resumeData?.pendingApproval ?? null, + messageHistory: resumeData?.messageHistory ?? [], + tokenStreaming: settings.tokenStreaming, + }); + } + + render( + React.createElement(LoadingApp, { + continueSession: shouldContinue, + agentIdArg: specifiedAgentId, + }), + { + exitOnCtrlC: false, // We handle CTRL-C manually with double-press guard + }, + ); +} + +main(); diff --git a/src/models.json b/src/models.json new file mode 100644 index 0000000..55724ba --- /dev/null +++ b/src/models.json @@ -0,0 +1,100 @@ +[ + { + "id": "default", + "handle": "anthropic/claude-sonnet-4-5-20250929", + "label": "Default (recommended)", + "description": "Use the default model (currently Sonnet 4.5)", + "isDefault": true, + "updateArgs": { "contextWindow": 200000 } + }, + { + "id": "opus", + "handle": "anthropic/claude-opus-4-1-20250805", + "label": "Claude Opus 4.1", + "description": "Anthropic's smartest (and slowest) model", + "updateArgs": { "contextWindow": 200000 } + }, + { + "id": "gpt-5-codex", + "handle": "openai/gpt-5-codex", + "label": "GPT-5-Codex", + "description": "A variant of GPT-5 optimized for agentic coding", + "updateArgs": { + "reasoningEffort": "medium", + "verbosity": "medium", + "contextWindow": 272000 + } + }, + { + "id": "gpt-5-minimal", + "handle": "openai/gpt-5", + "label": "GPT-5 (minimal)", + "description": "OpenAI's latest model (limited reasoning, fastest GPT-5 option)", + "updateArgs": { + "reasoningEffort": "minimal", + "verbosity": "medium", + "contextWindow": 272000 + } + }, + { + "id": "gpt-5-low", + "handle": "openai/gpt-5", + "label": "GPT-5 (low)", + "description": "OpenAI's latest model (some reasoning enabled)", + "updateArgs": { + "reasoningEffort": "low", + "verbosity": "medium", + "contextWindow": 272000 + } + }, + { + "id": "gpt-5-medium", + "handle": "openai/gpt-5", + "label": "GPT-5 (medium)", + "description": "OpenAI's latest model (using their recommended reasoning level)", + "updateArgs": { + "reasoningEffort": "medium", + "verbosity": "medium", + "contextWindow": 272000 + } + }, + { + "id": "gpt-5-high", + "handle": "openai/gpt-5", + "label": "GPT-5 (high)", + "description": "OpenAI's latest model (maximum reasoning depth)", + "updateArgs": { + "reasoningEffort": "high", + "verbosity": "medium", + "contextWindow": 272000 + } + }, + { + "id": "gemini-flash", + "handle": "google_ai/gemini-2.5-flash", + "label": "Gemini 2.5 Flash", + "description": "Google's fastest model", + "updateArgs": { "contextWindow": 200000 } + }, + { + "id": "gemini-pro", + "handle": "google_ai/gemini-2.5-pro", + "label": "Gemini 2.5 Pro", + "description": "Google's smartest model", + "updateArgs": { "contextWindow": 200000 } + }, + { + "id": "gpt-4.1", + "handle": "openai/gpt-4.1", + "label": "GPT-4.1", + "description": "OpenAI's most recent non-reasoner model", + "updateArgs": { "contextWindow": 200000 } + }, + { + "id": "o4-mini", + "handle": "openai/o4-mini", + "label": "o4-mini", + "description": "OpenAI's latest o-series reasoning model", + "updateArgs": { "contextWindow": 200000 } + } +] diff --git a/src/permissions/analyzer.ts b/src/permissions/analyzer.ts new file mode 100644 index 0000000..4436218 --- /dev/null +++ b/src/permissions/analyzer.ts @@ -0,0 +1,397 @@ +// src/permissions/analyzer.ts +// Analyze tool executions and recommend appropriate permission rules + +import { dirname, resolve } from "node:path"; + +export interface ApprovalContext { + // What rule should be saved if user clicks "approve always" + recommendedRule: string; + + // Human-readable explanation of what the rule does + ruleDescription: string; + + // Button text for "approve always" + approveAlwaysText: string; + + // Where to save the rule by default + defaultScope: "project" | "session" | "user"; + + // Should we offer "approve always"? + allowPersistence: boolean; + + // Safety classification + safetyLevel: "safe" | "moderate" | "dangerous"; +} + +/** + * Analyze a tool execution and determine appropriate approval context + */ +type ToolArgs = Record; + +export function analyzeApprovalContext( + toolName: string, + toolArgs: ToolArgs, + workingDirectory: string, +): ApprovalContext { + const resolveFilePath = () => { + const candidate = + toolArgs.file_path ?? toolArgs.path ?? toolArgs.notebook_path ?? ""; + return typeof candidate === "string" ? candidate : ""; + }; + + switch (toolName) { + case "Read": + return analyzeReadApproval(resolveFilePath(), workingDirectory); + + case "Write": + return analyzeWriteApproval(resolveFilePath(), workingDirectory); + + case "Edit": + case "MultiEdit": + return analyzeEditApproval(resolveFilePath(), workingDirectory); + + case "Bash": + return analyzeBashApproval( + typeof toolArgs.command === "string" ? toolArgs.command : "", + workingDirectory, + ); + + case "WebFetch": + return analyzeWebFetchApproval( + typeof toolArgs.url === "string" ? toolArgs.url : "", + ); + + case "Glob": + case "Grep": + return analyzeSearchApproval( + toolName, + typeof toolArgs.path === "string" ? toolArgs.path : workingDirectory, + workingDirectory, + ); + + default: + return analyzeDefaultApproval(toolName); + } +} + +/** + * Analyze Read tool approval + */ +function analyzeReadApproval( + filePath: string, + workingDir: string, +): ApprovalContext { + const absolutePath = resolve(workingDir, filePath); + + // If outside working directory, generalize to parent directory + if (!absolutePath.startsWith(workingDir)) { + const dirPath = dirname(absolutePath); + const displayPath = dirPath.replace(require("node:os").homedir(), "~"); + + return { + recommendedRule: `Read(${dirPath}/**)`, + ruleDescription: `reading from ${displayPath}/`, + approveAlwaysText: `Yes, allow reading from ${displayPath}/ in this project`, + defaultScope: "project", + allowPersistence: true, + safetyLevel: "safe", + }; + } + + // Inside working directory - shouldn't normally be asked, but offer session approval + return { + recommendedRule: `Read(${workingDir}/**)`, + ruleDescription: "reading project files", + approveAlwaysText: "Yes, allow reading project files during this session", + defaultScope: "session", + allowPersistence: true, + safetyLevel: "safe", + }; +} + +/** + * Analyze Write tool approval + */ +function analyzeWriteApproval( + _filePath: string, + _workingDir: string, +): ApprovalContext { + // Write is potentially dangerous to persist broadly + // Offer session-level approval only + return { + recommendedRule: "Write(**)", + ruleDescription: "all write operations", + approveAlwaysText: "Yes, allow all writes during this session", + defaultScope: "session", + allowPersistence: true, + safetyLevel: "moderate", + }; +} + +/** + * Analyze Edit tool approval + */ +function analyzeEditApproval( + filePath: string, + workingDir: string, +): ApprovalContext { + // Edit is safer than Write (file must exist) + // Can offer project-level for specific directories + const absolutePath = resolve(workingDir, filePath); + const dirPath = dirname(absolutePath); + const relativeDirPath = dirPath.startsWith(workingDir) + ? dirPath.slice(workingDir.length + 1) + : dirPath; + + return { + recommendedRule: `Edit(${relativeDirPath}/**)`, + ruleDescription: `editing files in ${relativeDirPath || "project"}/`, + approveAlwaysText: `Yes, allow editing files in ${relativeDirPath || "project"}/ in this project`, + defaultScope: "project", + allowPersistence: true, + safetyLevel: "safe", + }; +} + +/** + * Analyze Bash command approval + */ +function analyzeBashApproval( + command: string, + _workingDir: string, +): ApprovalContext { + const parts = command.trim().split(/\s+/); + const baseCommand = parts[0]; + const firstArg = parts[1] || ""; + + // Dangerous commands - no persistence + const dangerousCommands = [ + "rm", + "mv", + "chmod", + "chown", + "sudo", + "dd", + "mkfs", + "fdisk", + "kill", + "killall", + ]; + + if (dangerousCommands.includes(baseCommand)) { + return { + recommendedRule: "", + ruleDescription: "", + approveAlwaysText: "", + defaultScope: "session", + allowPersistence: false, + safetyLevel: "dangerous", + }; + } + + // Check for dangerous flags + if ( + command.includes("--force") || + command.includes("-f") || + command.includes("--hard") + ) { + return { + recommendedRule: "", + ruleDescription: "", + approveAlwaysText: "", + defaultScope: "session", + allowPersistence: false, + safetyLevel: "dangerous", + }; + } + + // Git commands - be specific to subcommand + if (baseCommand === "git") { + const gitSubcommand = firstArg; + + // Safe read-only git commands + const safeGitCommands = ["status", "diff", "log", "show", "branch"]; + if (safeGitCommands.includes(gitSubcommand)) { + return { + recommendedRule: `Bash(git ${gitSubcommand}:*)`, + ruleDescription: `'git ${gitSubcommand}' commands`, + approveAlwaysText: `Yes, and don't ask again for 'git ${gitSubcommand}' commands in this project`, + defaultScope: "project", + allowPersistence: true, + safetyLevel: "safe", + }; + } + + // Git write commands - moderate safety + if (["push", "pull", "fetch", "commit", "add"].includes(gitSubcommand)) { + return { + recommendedRule: `Bash(git ${gitSubcommand}:*)`, + ruleDescription: `'git ${gitSubcommand}' commands`, + approveAlwaysText: `Yes, and don't ask again for 'git ${gitSubcommand}' commands in this project`, + defaultScope: "project", + allowPersistence: true, + safetyLevel: "moderate", + }; + } + + // Other git commands - still allow but mark as moderate + if (gitSubcommand) { + return { + recommendedRule: `Bash(git ${gitSubcommand}:*)`, + ruleDescription: `'git ${gitSubcommand}' commands`, + approveAlwaysText: `Yes, and don't ask again for 'git ${gitSubcommand}' commands in this project`, + defaultScope: "project", + allowPersistence: true, + safetyLevel: "moderate", + }; + } + } + + // Package manager commands + if (["npm", "bun", "yarn", "pnpm"].includes(baseCommand)) { + const subcommand = firstArg; + const thirdPart = parts[2]; + + // Handle "npm run test" format (include both "run" and script name) + if (subcommand === "run" && thirdPart) { + const fullCommand = `${baseCommand} ${subcommand} ${thirdPart}`; + return { + recommendedRule: `Bash(${fullCommand}:*)`, + ruleDescription: `'${fullCommand}' commands`, + approveAlwaysText: `Yes, and don't ask again for '${fullCommand}' commands in this project`, + defaultScope: "project", + allowPersistence: true, + safetyLevel: "safe", + }; + } + + // Handle other subcommands (npm install, bun build, etc.) + if (subcommand) { + const fullCommand = `${baseCommand} ${subcommand}`; + return { + recommendedRule: `Bash(${fullCommand}:*)`, + ruleDescription: `'${fullCommand}' commands`, + approveAlwaysText: `Yes, and don't ask again for '${fullCommand}' commands in this project`, + defaultScope: "project", + allowPersistence: true, + safetyLevel: "safe", + }; + } + } + + // Safe read-only commands + const safeCommands = [ + "ls", + "cat", + "pwd", + "echo", + "which", + "type", + "whoami", + "date", + "grep", + "find", + "head", + "tail", + ]; + + if (safeCommands.includes(baseCommand)) { + return { + recommendedRule: `Bash(${baseCommand}:*)`, + ruleDescription: `'${baseCommand}' commands`, + approveAlwaysText: `Yes, and don't ask again for '${baseCommand}' commands in this project`, + defaultScope: "project", + allowPersistence: true, + safetyLevel: "safe", + }; + } + + // Default: allow this specific command only + const displayCommand = + command.length > 40 ? `${command.slice(0, 40)}...` : command; + + return { + recommendedRule: `Bash(${command})`, + ruleDescription: `'${displayCommand}'`, + approveAlwaysText: `Yes, and don't ask again for '${displayCommand}' in this project`, + defaultScope: "project", + allowPersistence: true, + safetyLevel: "moderate", + }; +} + +/** + * Analyze WebFetch approval + */ +function analyzeWebFetchApproval(url: string): ApprovalContext { + try { + const urlObj = new URL(url); + const domain = urlObj.hostname; + + return { + recommendedRule: `WebFetch(${urlObj.protocol}//${domain}/*)`, + ruleDescription: `requests to ${domain}`, + approveAlwaysText: `Yes, allow requests to ${domain} in this project`, + defaultScope: "project", + allowPersistence: true, + safetyLevel: "safe", + }; + } catch { + // Invalid URL + return { + recommendedRule: "WebFetch", + ruleDescription: "web requests", + approveAlwaysText: "Yes, allow web requests in this project", + defaultScope: "project", + allowPersistence: true, + safetyLevel: "moderate", + }; + } +} + +/** + * Analyze Glob/Grep approval + */ +function analyzeSearchApproval( + toolName: string, + searchPath: string, + workingDir: string, +): ApprovalContext { + const absolutePath = resolve(workingDir, searchPath); + + if (!absolutePath.startsWith(workingDir)) { + const displayPath = absolutePath.replace(require("node:os").homedir(), "~"); + + return { + recommendedRule: `${toolName}(${absolutePath}/**)`, + ruleDescription: `searching in ${displayPath}/`, + approveAlwaysText: `Yes, allow searching in ${displayPath}/ in this project`, + defaultScope: "project", + allowPersistence: true, + safetyLevel: "safe", + }; + } + + return { + recommendedRule: `${toolName}(${workingDir}/**)`, + ruleDescription: "searching project files", + approveAlwaysText: "Yes, allow searching project files during this session", + defaultScope: "session", + allowPersistence: true, + safetyLevel: "safe", + }; +} + +/** + * Default approval for unknown tools + */ +function analyzeDefaultApproval(toolName: string): ApprovalContext { + return { + recommendedRule: toolName, + ruleDescription: `${toolName} operations`, + approveAlwaysText: `Yes, allow ${toolName} operations during this session`, + defaultScope: "session", + allowPersistence: true, + safetyLevel: "moderate", + }; +} diff --git a/src/permissions/checker.ts b/src/permissions/checker.ts new file mode 100644 index 0000000..92e9cb4 --- /dev/null +++ b/src/permissions/checker.ts @@ -0,0 +1,278 @@ +// src/permissions/checker.ts +// Main permission checking logic + +import { resolve } from "node:path"; +import { cliPermissions } from "./cli"; +import { + matchesBashPattern, + matchesFilePattern, + matchesToolPattern, +} from "./matcher"; +import { permissionMode } from "./mode"; +import { sessionPermissions } from "./session"; +import type { + PermissionCheckResult, + PermissionDecision, + PermissionRules, +} from "./types"; + +/** + * Tools that don't require approval within working directory + */ +const WORKING_DIRECTORY_TOOLS = ["Read", "Glob", "Grep"]; + +/** + * Check permission for a tool execution. + * + * Decision logic: + * 1. Check deny rules from settings (first match wins) → DENY + * 2. Check CLI disallowedTools (--disallowedTools flag) → DENY + * 3. Check permission mode (--permission-mode flag) → ALLOW or DENY + * 4. Check CLI allowedTools (--allowedTools flag) → ALLOW + * 5. For Read/Glob/Grep within working directory → ALLOW + * 6. Check session allow rules (first match wins) → ALLOW + * 7. Check allow rules from settings (first match wins) → ALLOW + * 8. Check ask rules from settings (first match wins) → ASK + * 9. Fall back to default behavior for tool → ASK or ALLOW + * + * @param toolName - Name of the tool (e.g., "Read", "Bash", "Write") + * @param toolArgs - Tool arguments (contains file paths, commands, etc.) + * @param permissions - Loaded permission rules + * @param workingDirectory - Current working directory + */ +type ToolArgs = Record; + +export function checkPermission( + toolName: string, + toolArgs: ToolArgs, + permissions: PermissionRules, + workingDirectory: string = process.cwd(), +): PermissionCheckResult { + // Build permission query string + const query = buildPermissionQuery(toolName, toolArgs); + + // Get session rules + const sessionRules = sessionPermissions.getRules(); + + // Check deny rules FIRST (highest priority - overrides everything including working directory) + if (permissions.deny) { + for (const pattern of permissions.deny) { + if (matchesPattern(toolName, query, pattern, workingDirectory)) { + return { + decision: "deny", + matchedRule: pattern, + reason: "Matched deny rule", + }; + } + } + } + + // Check CLI disallowedTools (second highest priority - overrides all allow rules) + const disallowedTools = cliPermissions.getDisallowedTools(); + for (const pattern of disallowedTools) { + if (matchesPattern(toolName, query, pattern, workingDirectory)) { + return { + decision: "deny", + matchedRule: `${pattern} (CLI)`, + reason: "Matched --disallowedTools flag", + }; + } + } + + // Check permission mode (applies before CLI allow rules but after deny rules) + const modeOverride = permissionMode.checkModeOverride(toolName); + if (modeOverride) { + const currentMode = permissionMode.getMode(); + return { + decision: modeOverride, + matchedRule: `${currentMode} mode`, + reason: `Permission mode: ${currentMode}`, + }; + } + + // Check CLI allowedTools (third priority - overrides settings but not deny rules) + const allowedTools = cliPermissions.getAllowedTools(); + for (const pattern of allowedTools) { + if (matchesPattern(toolName, query, pattern, workingDirectory)) { + return { + decision: "allow", + matchedRule: `${pattern} (CLI)`, + reason: "Matched --allowedTools flag", + }; + } + } + + // After checking CLI overrides, check if Read/Glob/Grep within working directory + if (WORKING_DIRECTORY_TOOLS.includes(toolName)) { + const filePath = extractFilePath(toolArgs); + if ( + filePath && + isWithinAllowedDirectories(filePath, permissions, workingDirectory) + ) { + return { + decision: "allow", + reason: "Within working directory", + }; + } + } + + // Check session allow rules (higher precedence than persisted allow) + if (sessionRules.allow) { + for (const pattern of sessionRules.allow) { + if (matchesPattern(toolName, query, pattern, workingDirectory)) { + return { + decision: "allow", + matchedRule: `${pattern} (session)`, + reason: "Matched session allow rule", + }; + } + } + } + + // Check persisted allow rules + if (permissions.allow) { + for (const pattern of permissions.allow) { + if (matchesPattern(toolName, query, pattern, workingDirectory)) { + return { + decision: "allow", + matchedRule: pattern, + reason: "Matched allow rule", + }; + } + } + } + + // Check ask rules + if (permissions.ask) { + for (const pattern of permissions.ask) { + if (matchesPattern(toolName, query, pattern, workingDirectory)) { + return { + decision: "ask", + matchedRule: pattern, + reason: "Matched ask rule", + }; + } + } + } + + // Fall back to tool defaults + return { + decision: getDefaultDecision(toolName), + reason: "Default behavior for tool", + }; +} + +/** + * Extract file path from tool arguments + */ +function extractFilePath(toolArgs: ToolArgs): string | null { + // Different tools use different parameter names + if (typeof toolArgs.file_path === "string" && toolArgs.file_path.length > 0) { + return toolArgs.file_path; + } + if (typeof toolArgs.path === "string" && toolArgs.path.length > 0) { + return toolArgs.path; + } + if ( + typeof toolArgs.notebook_path === "string" && + toolArgs.notebook_path.length > 0 + ) { + return toolArgs.notebook_path; + } + return null; +} + +/** + * Check if file path is within allowed directories + * (working directory + additionalDirectories) + */ +function isWithinAllowedDirectories( + filePath: string, + permissions: PermissionRules, + workingDirectory: string, +): boolean { + const absolutePath = resolve(workingDirectory, filePath); + + // Check if within working directory + if (absolutePath.startsWith(workingDirectory)) { + return true; + } + + // Check additionalDirectories + if (permissions.additionalDirectories) { + for (const dir of permissions.additionalDirectories) { + const resolvedDir = resolve(workingDirectory, dir); + if (absolutePath.startsWith(resolvedDir)) { + return true; + } + } + } + + return false; +} + +/** + * Build permission query string for a tool execution + */ +function buildPermissionQuery(toolName: string, toolArgs: ToolArgs): string { + switch (toolName) { + case "Read": + case "Write": + case "Edit": + case "Glob": + case "Grep": { + // File tools: "ToolName(path/to/file)" + const filePath = extractFilePath(toolArgs); + return filePath ? `${toolName}(${filePath})` : toolName; + } + + case "Bash": { + // Bash: "Bash(command with args)" + const command = + typeof toolArgs.command === "string" ? toolArgs.command : ""; + return `Bash(${command})`; + } + + default: + // Other tools: just the tool name + return toolName; + } +} + +/** + * Check if query matches a permission pattern + */ +function matchesPattern( + toolName: string, + query: string, + pattern: string, + workingDirectory: string, +): boolean { + // File tools use glob matching + if (["Read", "Write", "Edit", "Glob", "Grep"].includes(toolName)) { + return matchesFilePattern(query, pattern, workingDirectory); + } + + // Bash uses prefix matching + if (toolName === "Bash") { + return matchesBashPattern(query, pattern); + } + + // Other tools use simple name matching + return matchesToolPattern(toolName, pattern); +} + +/** + * Get default decision for a tool (when no rules match) + */ +function getDefaultDecision(toolName: string): PermissionDecision { + // Tools that default to auto-allow + const autoAllowTools = ["Read", "Glob", "Grep", "TodoWrite"]; + + if (autoAllowTools.includes(toolName)) { + return "allow"; + } + + // Everything else defaults to ask + return "ask"; +} diff --git a/src/permissions/cli.ts b/src/permissions/cli.ts new file mode 100644 index 0000000..1222b1b --- /dev/null +++ b/src/permissions/cli.ts @@ -0,0 +1,131 @@ +// src/permissions/cli.ts +// CLI-level permission overrides from command-line flags +// These take precedence over settings.json but not over enterprise managed policies + +/** + * CLI permission overrides that are set via --allowedTools and --disallowedTools flags. + * These rules override settings.json permissions for the current session. + */ +class CliPermissions { + private allowedTools: string[] = []; + private disallowedTools: string[] = []; + + /** + * Parse and set allowed tools from CLI flag + * Format: "Bash,Read" or "Bash(npm install),Read(src/**)" + */ + setAllowedTools(toolsString: string): void { + this.allowedTools = this.parseToolList(toolsString); + } + + /** + * Parse and set disallowed tools from CLI flag + * Format: "WebFetch,Bash(curl:*)" + */ + setDisallowedTools(toolsString: string): void { + this.disallowedTools = this.parseToolList(toolsString); + } + + /** + * Parse comma-separated tool list into individual patterns + * Handles: "Bash,Read" and "Bash(npm install),Read(src/**)" + * + * Special handling: + * - "Bash" without params becomes "Bash(:*)" to match all Bash commands + * - "Read" without params becomes "Read" (matches all Read calls) + */ + private parseToolList(toolsString: string): string[] { + if (!toolsString) return []; + + const tools: string[] = []; + let current = ""; + let depth = 0; + + // Parse comma-separated list, respecting parentheses + for (let i = 0; i < toolsString.length; i++) { + const char = toolsString[i]; + + if (char === "(") { + depth++; + current += char; + } else if (char === ")") { + depth--; + current += char; + } else if (char === "," && depth === 0) { + // Only split on commas outside parentheses + if (current.trim()) { + tools.push(this.normalizePattern(current.trim())); + } + current = ""; + } else { + current += char; + } + } + + // Add the last tool + if (current.trim()) { + tools.push(this.normalizePattern(current.trim())); + } + + return tools; + } + + /** + * Normalize a tool pattern. + * - "Bash" becomes "Bash(:*)" to match all commands + * - File tools (Read, Write, Edit, Glob, Grep) become "ToolName(**)" to match all files + * - Tool patterns with parentheses stay as-is + */ + private normalizePattern(pattern: string): string { + // If pattern has parentheses, keep as-is + if (pattern.includes("(")) { + return pattern; + } + + // Bash without parentheses needs wildcard to match all commands + if (pattern === "Bash") { + return "Bash(:*)"; + } + + // File tools need wildcard to match all files + const fileTools = ["Read", "Write", "Edit", "Glob", "Grep"]; + if (fileTools.includes(pattern)) { + return `${pattern}(**)`; + } + + // All other bare tool names stay as-is + return pattern; + } + + /** + * Get all allowed tool patterns + */ + getAllowedTools(): string[] { + return [...this.allowedTools]; + } + + /** + * Get all disallowed tool patterns + */ + getDisallowedTools(): string[] { + return [...this.disallowedTools]; + } + + /** + * Check if any CLI overrides are set + */ + hasOverrides(): boolean { + return this.allowedTools.length > 0 || this.disallowedTools.length > 0; + } + + /** + * Clear all CLI permission overrides + */ + clear(): void { + this.allowedTools = []; + this.disallowedTools = []; + } +} + +// Singleton instance +export const cliPermissions = new CliPermissions(); diff --git a/src/permissions/loader.ts b/src/permissions/loader.ts new file mode 100644 index 0000000..30a7ed2 --- /dev/null +++ b/src/permissions/loader.ts @@ -0,0 +1,167 @@ +// src/permissions/loader.ts +// Load and merge permission settings from hierarchical sources + +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { PermissionRules } from "./types"; + +type SettingsFile = { + permissions?: Record; + [key: string]: unknown; +}; + +/** + * Load permissions from all settings files and merge them hierarchically. + * + * Precedence (highest to lowest): + * 1. Local project settings (.letta/settings.local.json) + * 2. Project settings (.letta/settings.json) + * 3. User settings (~/.letta/settings.json) + * + * Rules are merged by concatenating arrays (more specific settings add to broader ones) + */ +export async function loadPermissions( + workingDirectory: string = process.cwd(), +): Promise { + const merged: PermissionRules = { + allow: [], + deny: [], + ask: [], + additionalDirectories: [], + }; + + // Load in reverse precedence order (lowest to highest) + const sources = [ + join(homedir(), ".letta", "settings.json"), // User + join(workingDirectory, ".letta", "settings.json"), // Project + join(workingDirectory, ".letta", "settings.local.json"), // Local + ]; + + for (const settingsPath of sources) { + const file = Bun.file(settingsPath); + try { + if (await file.exists()) { + const settings = (await file.json()) as SettingsFile; + if (settings.permissions) { + mergePermissions(merged, settings.permissions as PermissionRules); + } + } + } catch (_error) { + // Silently skip files that can't be parsed + // (user might have invalid JSON) + } + } + + return merged; +} + +/** + * Merge permission rules by concatenating arrays + */ +function mergePermissions( + target: PermissionRules, + source: PermissionRules, +): void { + if (source.allow) { + target.allow = [...(target.allow || []), ...source.allow]; + } + if (source.deny) { + target.deny = [...(target.deny || []), ...source.deny]; + } + if (source.ask) { + target.ask = [...(target.ask || []), ...source.ask]; + } + if (source.additionalDirectories) { + target.additionalDirectories = [ + ...(target.additionalDirectories || []), + ...source.additionalDirectories, + ]; + } +} + +/** + * Save a permission rule to a specific scope + */ +export async function savePermissionRule( + rule: string, + ruleType: "allow" | "deny" | "ask", + scope: "project" | "local" | "user", + workingDirectory: string = process.cwd(), +): Promise { + // Determine settings file path based on scope + let settingsPath: string; + switch (scope) { + case "user": + settingsPath = join(homedir(), ".letta", "settings.json"); + break; + case "project": + settingsPath = join(workingDirectory, ".letta", "settings.json"); + break; + case "local": + settingsPath = join(workingDirectory, ".letta", "settings.local.json"); + break; + } + + // Load existing settings + const file = Bun.file(settingsPath); + let settings: SettingsFile = {}; + try { + if (await file.exists()) { + settings = (await file.json()) as SettingsFile; + } + } catch (_error) { + // Start with empty settings if file doesn't exist or is invalid + } + + // Initialize permissions if needed + if (!settings.permissions) { + settings.permissions = {}; + } + if (!settings.permissions[ruleType]) { + settings.permissions[ruleType] = []; + } + + // Add rule if not already present + if (!settings.permissions[ruleType].includes(rule)) { + settings.permissions[ruleType].push(rule); + } + + // Save settings (Bun.write creates parent directories automatically) + await Bun.write(settingsPath, JSON.stringify(settings, null, 2)); + + // If saving to .letta/settings.local.json, ensure it's gitignored + if (scope === "local") { + await ensureLocalSettingsIgnored(workingDirectory); + } +} + +/** + * Ensure .letta/settings.local.json is in .gitignore + */ +async function ensureLocalSettingsIgnored( + workingDirectory: string, +): Promise { + const gitignorePath = join(workingDirectory, ".gitignore"); + const gitignoreFile = Bun.file(gitignorePath); + + const pattern = ".letta/settings.local.json"; + + try { + let content = ""; + if (await gitignoreFile.exists()) { + content = await gitignoreFile.text(); + } + + // Check if pattern already exists + if (!content.includes(pattern)) { + // Add pattern to gitignore + const newContent = `${ + content + (content.endsWith("\n") ? "" : "\n") + pattern + }\n`; + await Bun.write(gitignorePath, newContent); + } + } catch (_error) { + // Silently fail if we can't update .gitignore + // (might not be a git repo) + } +} diff --git a/src/permissions/matcher.ts b/src/permissions/matcher.ts new file mode 100644 index 0000000..c498534 --- /dev/null +++ b/src/permissions/matcher.ts @@ -0,0 +1,152 @@ +// src/permissions/matcher.ts +// Pattern matching logic for permission rules + +import { resolve } from "node:path"; +import { minimatch } from "minimatch"; + +/** + * Check if a file path matches a permission pattern. + * + * Patterns follow Claude Code's glob syntax: + * - "Read(file.txt)" - exact match in working directory + * - "Read(*.txt)" - glob pattern + * - "Read(src/**)" - recursive glob + * - "Read(//absolute/path/**)" - absolute path pattern + * - "Read(~/.zshrc)" - tilde expansion + * + * @param query - The query to check (e.g., "Read(.env)") + * @param pattern - The permission pattern (e.g., "Read(src/**)") + * @param workingDirectory - Current working directory + */ +export function matchesFilePattern( + query: string, + pattern: string, + workingDirectory: string, +): boolean { + // Extract tool name and file path from query + // Format: "ToolName(filePath)" + const queryMatch = query.match(/^([^(]+)\((.+)\)$/); + if (!queryMatch) { + return false; + } + const queryTool = queryMatch[1]; + const filePath = queryMatch[2]; + + // Extract tool name and glob pattern from permission rule + // Format: "ToolName(pattern)" + const patternMatch = pattern.match(/^([^(]+)\((.+)\)$/); + if (!patternMatch) { + return false; + } + const patternTool = patternMatch[1]; + let globPattern = patternMatch[2]; + + // Tool names must match + if (queryTool !== patternTool) { + return false; + } + + // Normalize ./ prefix + if (globPattern.startsWith("./")) { + globPattern = globPattern.slice(2); + } + + // Handle tilde expansion + if (globPattern.startsWith("~/")) { + const homedir = require("node:os").homedir(); + globPattern = globPattern.replace(/^~/, homedir); + } + + // Handle absolute paths (Claude Code uses // prefix) + if (globPattern.startsWith("//")) { + globPattern = globPattern.slice(1); // Remove one slash to make it absolute + } + + // Resolve file path to absolute + const absoluteFilePath = resolve(workingDirectory, filePath); + + // If pattern is absolute, compare directly + if (globPattern.startsWith("/")) { + return minimatch(absoluteFilePath, globPattern); + } + + // If pattern is relative, compare against both: + // 1. Relative path from working directory + // 2. Absolute path (for patterns that might match absolute paths) + const relativeFilePath = filePath.startsWith("/") + ? absoluteFilePath.replace(`${workingDirectory}/`, "") + : filePath; + + return ( + minimatch(relativeFilePath, globPattern) || + minimatch(absoluteFilePath, globPattern) + ); +} + +/** + * Check if a bash command matches a permission pattern. + * + * Bash patterns use PREFIX matching, not regex: + * - "Bash(git diff:*)" matches "Bash(git diff ...)", "Bash(git diff HEAD)", etc. + * - "Bash(npm run lint)" matches exactly "Bash(npm run lint)" + * - The :* syntax is a special wildcard for "this command and any args" + * + * @param query - The bash query to check (e.g., "Bash(git diff HEAD)") + * @param pattern - The permission pattern (e.g., "Bash(git diff:*)") + */ +export function matchesBashPattern(query: string, pattern: string): boolean { + // Extract the command from query + // Format: "Bash(actual command)" or "Bash()" + const queryMatch = query.match(/^Bash\((.*)\)$/); + if (!queryMatch) { + return false; + } + const command = queryMatch[1]; + + // Extract the command pattern from permission rule + // Format: "Bash(command pattern)" or "Bash()" + const patternMatch = pattern.match(/^Bash\((.*)\)$/); + if (!patternMatch) { + return false; + } + const commandPattern = patternMatch[1]; + + // Check for wildcard suffix + if (commandPattern.endsWith(":*")) { + // Prefix match: command must start with pattern (minus :*) + const prefix = commandPattern.slice(0, -2); + return command.startsWith(prefix); + } + + // Exact match + return command === commandPattern; +} + +/** + * Check if a tool name matches a permission pattern. + * + * For non-file tools, we match by tool name: + * - "WebFetch" matches all WebFetch calls + * - "*" matches all tools + * + * @param toolName - The tool name + * @param pattern - The permission pattern + */ +export function matchesToolPattern(toolName: string, pattern: string): boolean { + // Wildcard matches everything + if (pattern === "*") { + return true; + } + + // Check for tool name match (with or without parens) + if (pattern === toolName || pattern === `${toolName}()`) { + return true; + } + + // Check for tool name prefix (e.g., "WebFetch(...)") + if (pattern.startsWith(`${toolName}(`)) { + return true; + } + + return false; +} diff --git a/src/permissions/mode.ts b/src/permissions/mode.ts new file mode 100644 index 0000000..840cbca --- /dev/null +++ b/src/permissions/mode.ts @@ -0,0 +1,92 @@ +// src/permissions/mode.ts +// Permission mode management (default, acceptEdits, plan, bypassPermissions) + +export type PermissionMode = + | "default" + | "acceptEdits" + | "plan" + | "bypassPermissions"; + +/** + * Permission mode state for the current session. + * Set via CLI --permission-mode flag or settings.json defaultMode. + */ +class PermissionModeManager { + private currentMode: PermissionMode = "default"; + + /** + * Set the permission mode for this session + */ + setMode(mode: PermissionMode): void { + this.currentMode = mode; + } + + /** + * Get the current permission mode + */ + getMode(): PermissionMode { + return this.currentMode; + } + + /** + * Check if a tool should be auto-allowed based on current mode + * Returns null if mode doesn't apply to this tool + */ + checkModeOverride(toolName: string): "allow" | "deny" | null { + switch (this.currentMode) { + case "bypassPermissions": + // Auto-allow everything (except explicit deny rules checked earlier) + return "allow"; + + case "acceptEdits": + // Auto-allow edit tools: Write, Edit, NotebookEdit + if (["Write", "Edit", "NotebookEdit"].includes(toolName)) { + return "allow"; + } + return null; + + case "plan": { + // Read-only mode: allow analysis tools, deny modification tools + const allowedInPlan = [ + "Read", + "Glob", + "Grep", + "NotebookRead", + "TodoWrite", + ]; + const deniedInPlan = [ + "Write", + "Edit", + "NotebookEdit", + "Bash", + "WebFetch", + ]; + + if (allowedInPlan.includes(toolName)) { + return "allow"; + } + if (deniedInPlan.includes(toolName)) { + return "deny"; + } + return null; + } + + case "default": + // No mode overrides, use normal permission flow + return null; + + default: + return null; + } + } + + /** + * Reset to default mode + */ + reset(): void { + this.currentMode = "default"; + } +} + +// Singleton instance +export const permissionMode = new PermissionModeManager(); diff --git a/src/permissions/session.ts b/src/permissions/session.ts new file mode 100644 index 0000000..065ed4d --- /dev/null +++ b/src/permissions/session.ts @@ -0,0 +1,58 @@ +// src/permissions/session.ts +// In-memory permission store for session-only rules + +import type { PermissionRules } from "./types"; + +/** + * Session-only permissions that are not persisted to disk. + * These rules are cleared when the application exits. + */ +class SessionPermissions { + private sessionRules: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + /** + * Add a permission rule for this session only + */ + addRule(rule: string, type: "allow" | "deny" | "ask"): void { + const rules = this.sessionRules[type]; + if (rules && !rules.includes(rule)) { + rules.push(rule); + } + } + + /** + * Get all session rules + */ + getRules(): PermissionRules { + return { + allow: [...(this.sessionRules.allow || [])], + deny: [...(this.sessionRules.deny || [])], + ask: [...(this.sessionRules.ask || [])], + }; + } + + /** + * Clear all session rules + */ + clear(): void { + this.sessionRules = { + allow: [], + deny: [], + ask: [], + }; + } + + /** + * Check if a rule exists in session permissions + */ + hasRule(rule: string, type: "allow" | "deny" | "ask"): boolean { + return this.sessionRules[type]?.includes(rule) || false; + } +} + +// Singleton instance +export const sessionPermissions = new SessionPermissions(); diff --git a/src/permissions/types.ts b/src/permissions/types.ts new file mode 100644 index 0000000..be1d7c2 --- /dev/null +++ b/src/permissions/types.ts @@ -0,0 +1,31 @@ +// src/permissions/types.ts +// Types for Claude Code-compatible permission system + +/** + * Permission rules following Claude Code's format + */ +export interface PermissionRules { + allow?: string[]; + deny?: string[]; + ask?: string[]; + additionalDirectories?: string[]; +} + +/** + * Permission decision for a tool execution + */ +export type PermissionDecision = "allow" | "deny" | "ask"; + +/** + * Scope for saving permission rules + */ +export type PermissionScope = "project" | "local" | "user"; + +/** + * Result of a permission check + */ +export interface PermissionCheckResult { + decision: PermissionDecision; + matchedRule?: string; + reason?: string; +} diff --git a/src/project-settings.ts b/src/project-settings.ts new file mode 100644 index 0000000..7ae7278 --- /dev/null +++ b/src/project-settings.ts @@ -0,0 +1,91 @@ +// src/project-settings.ts +// Manages project-level settings stored in ./.letta/settings.json + +import { join } from "node:path"; + +export interface ProjectSettings { + localSharedBlockIds: Record; // label -> blockId mapping for project-local blocks +} + +const DEFAULT_PROJECT_SETTINGS: ProjectSettings = { + localSharedBlockIds: {}, +}; + +type RawProjectSettings = { + localSharedBlockIds?: Record; + [key: string]: unknown; +}; + +function getProjectSettingsPath(workingDirectory: string): string { + return join(workingDirectory, ".letta", "settings.json"); +} + +/** + * Load project settings from ./.letta/settings.json + * If the file doesn't exist or doesn't have localSharedBlockIds, returns defaults + */ +export async function loadProjectSettings( + workingDirectory: string = process.cwd(), +): Promise { + const settingsPath = getProjectSettingsPath(workingDirectory); + const file = Bun.file(settingsPath); + + try { + if (!(await file.exists())) { + return DEFAULT_PROJECT_SETTINGS; + } + + const settings = (await file.json()) as RawProjectSettings; + + // Extract only localSharedBlockIds (permissions and other fields handled elsewhere) + return { + localSharedBlockIds: settings.localSharedBlockIds ?? {}, + }; + } catch (error) { + console.error("Error loading project settings, using defaults:", error); + return DEFAULT_PROJECT_SETTINGS; + } +} + +/** + * Save project settings to ./.letta/settings.json + * Merges with existing settings (like permissions) instead of overwriting + */ +export async function saveProjectSettings( + workingDirectory: string, + updates: Partial, +): Promise { + const settingsPath = getProjectSettingsPath(workingDirectory); + const file = Bun.file(settingsPath); + + try { + // Read existing settings (might have permissions, etc.) + let existingSettings: RawProjectSettings = {}; + if (await file.exists()) { + existingSettings = (await file.json()) as RawProjectSettings; + } + + // Merge updates with existing settings + const newSettings: RawProjectSettings = { + ...existingSettings, + ...updates, + }; + + // Bun.write automatically creates parent directories (.letta/) + await Bun.write(settingsPath, JSON.stringify(newSettings, null, 2)); + } catch (error) { + console.error("Error saving project settings:", error); + throw error; + } +} + +/** + * Update specific project settings fields + */ +export async function updateProjectSettings( + workingDirectory: string, + updates: Partial, +): Promise { + await saveProjectSettings(workingDirectory, updates); + return loadProjectSettings(workingDirectory); +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..3cce5f6 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,91 @@ +// src/settings.ts +// Manages user settings stored in ~/.letta/settings.json + +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { PermissionRules } from "./permissions/types"; + +export type UIMode = "simple" | "rich"; + +export interface Settings { + uiMode: UIMode; + lastAgent: string | null; + tokenStreaming: boolean; + globalSharedBlockIds: Record; // label -> blockId mapping (persona, human; style moved to project settings) + permissions?: PermissionRules; +} + +const DEFAULT_SETTINGS: Settings = { + uiMode: "simple", + lastAgent: null, + tokenStreaming: false, + globalSharedBlockIds: {}, +}; + +function getSettingsPath(): string { + return join(homedir(), ".letta", "settings.json"); +} + +/** + * Load settings from ~/.letta/settings.json + * If the file doesn't exist, creates it with default settings + */ +export async function loadSettings(): Promise { + const settingsPath = getSettingsPath(); + const file = Bun.file(settingsPath); + + try { + // Check if settings file exists + if (!(await file.exists())) { + // Create default settings file (Bun.write auto-creates parent directories) + await saveSettings(DEFAULT_SETTINGS); + return DEFAULT_SETTINGS; + } + + // Read and parse settings using Bun's built-in JSON parser + const settings = (await file.json()) as Settings; + + // Merge with defaults in case new fields were added + return { ...DEFAULT_SETTINGS, ...settings }; + } catch (error) { + console.error("Error loading settings, using defaults:", error); + return DEFAULT_SETTINGS; + } +} + +/** + * Save settings to ~/.letta/settings.json + */ +export async function saveSettings(settings: Settings): Promise { + const settingsPath = getSettingsPath(); + + try { + // Bun.write automatically creates parent directories + await Bun.write(settingsPath, JSON.stringify(settings, null, 2)); + } catch (error) { + console.error("Error saving settings:", error); + throw error; + } +} + +/** + * Update specific settings fields + */ +export async function updateSettings( + updates: Partial, +): Promise { + const currentSettings = await loadSettings(); + const newSettings = { ...currentSettings, ...updates }; + await saveSettings(newSettings); + return newSettings; +} + +/** + * Get a specific setting value + */ +export async function getSetting( + key: K, +): Promise { + const settings = await loadSettings(); + return settings[key]; +} diff --git a/src/tests/clipboard.test.ts b/src/tests/clipboard.test.ts new file mode 100644 index 0000000..4b81d0c --- /dev/null +++ b/src/tests/clipboard.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from "bun:test"; +import { + allocateImage, + allocatePaste, + buildMessageContentFromDisplay, + clearPlaceholdersInText, + extractImagePlaceholderIds, + extractTextPlaceholderIds, + resolvePlaceholders, +} from "../cli/helpers/pasteRegistry"; + +test("allocatePaste creates a placeholder", () => { + const id = allocatePaste("Hello World"); + expect(id).toBeGreaterThan(0); +}); + +test("resolvePlaceholders resolves text placeholders", () => { + const content = "Some long text\n".repeat(10); + const id = allocatePaste(content); + const placeholder = `[Pasted text #${id} +10 lines]`; + const resolved = resolvePlaceholders(placeholder); + expect(resolved).toBe(content); +}); + +test("allocateImage creates an image placeholder", () => { + const id = allocateImage({ + data: "base64data", + mediaType: "image/png", + }); + expect(id).toBeGreaterThan(0); +}); + +test("buildMessageContentFromDisplay handles text only", () => { + const content = buildMessageContentFromDisplay("Hello World"); + expect(content).toEqual([{ type: "text", text: "Hello World" }]); +}); + +test("buildMessageContentFromDisplay handles text placeholders", () => { + const longText = "Line 1\n".repeat(10); + const id = allocatePaste(longText); + const display = `Before [Pasted text #${id} +10 lines] After`; + const content = buildMessageContentFromDisplay(display); + expect(content).toEqual([{ type: "text", text: `Before ${longText} After` }]); +}); + +test("buildMessageContentFromDisplay handles image placeholders", () => { + const id = allocateImage({ + data: "abc123", + mediaType: "image/png", + }); + const display = `Text before [Image #${id}] text after`; + const content = buildMessageContentFromDisplay(display); + expect(content).toHaveLength(3); + expect(content[0]).toEqual({ type: "text", text: "Text before " }); + expect(content[1]).toEqual({ + type: "image", + source: { + type: "base64", + mediaType: "image/png", + data: "abc123", + }, + }); + expect(content[2]).toEqual({ type: "text", text: " text after" }); +}); + +test("buildMessageContentFromDisplay handles mixed content", () => { + const textId = allocatePaste("Pasted content"); + const imageId = allocateImage({ + data: "imgdata", + mediaType: "image/jpeg", + }); + const display = `Start [Pasted text #${textId} +1 lines] middle [Image #${imageId}] end`; + const content = buildMessageContentFromDisplay(display); + expect(content).toHaveLength(3); + expect(content[0]).toEqual({ + type: "text", + text: "Start Pasted content middle ", + }); + expect(content[1].type).toBe("image"); + expect(content[2]).toEqual({ type: "text", text: " end" }); +}); + +test("clearPlaceholdersInText removes referenced placeholders", () => { + const id1 = allocatePaste("Content 1"); + const id2 = allocateImage({ data: "img", mediaType: "image/png" }); + const display = `[Pasted text #${id1} +1 lines] and [Image #${id2}]`; + + // Verify they resolve before clearing + expect(resolvePlaceholders(display)).toContain("Content 1"); + + clearPlaceholdersInText(display); + + // After clearing, placeholders should not resolve + expect(resolvePlaceholders(display)).toBe(display); +}); + +test("extractTextPlaceholderIds extracts IDs correctly", () => { + const display = + "[Pasted text #123 +5 lines] and [Pasted text #456 +10 lines]"; + const ids = extractTextPlaceholderIds(display); + expect(ids).toEqual([123, 456]); +}); + +test("extractImagePlaceholderIds extracts IDs correctly", () => { + const display = "[Image #42] and [Image #99]"; + const ids = extractImagePlaceholderIds(display); + expect(ids).toEqual([42, 99]); +}); diff --git a/src/tests/message.smoke.ts b/src/tests/message.smoke.ts new file mode 100755 index 0000000..c0fd917 --- /dev/null +++ b/src/tests/message.smoke.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env bun +/** + * Quick sanity check: create an agent, send a message, log streamed output. + */ + +import { createAgent } from "../agent/create"; +import { sendMessageStream } from "../agent/message"; + +async function main() { + const apiKey = process.env.LETTA_API_KEY; + if (!apiKey) { + console.error("❌ Missing LETTA_API_KEY in env"); + process.exit(1); + } + + console.log("🧠 Creating test agent..."); + const agent = await createAgent("smoke-agent", "openai/gpt-4.1"); + console.log(`✅ Agent created: ${agent.id}`); + + console.log("💬 Sending test message..."); + const stream = await sendMessageStream( + agent.id, + "Hello from Bun smoke test! Try calling a tool.", + ); + + // Print every chunk as it arrives + for await (const chunk of stream) { + const type = chunk.messageType ?? "unknown"; + + switch (chunk.messageType) { + case "reasoning_message": { + const run = chunk.runId + ? `run=${chunk.runId}:${chunk.seqId ?? "-"} ` + : ""; + process.stdout.write( + `[reasoning] ${run}${JSON.stringify(chunk) ?? ""}\n`, + ); + break; + } + case "assistant_message": { + const run = chunk.runId + ? `run=${chunk.runId}:${chunk.seqId ?? "-"} ` + : ""; + process.stdout.write( + `[assistant] ${run}${JSON.stringify(chunk) ?? ""}\n`, + ); + break; + } + case "tool_call_message": { + const run = chunk.runId + ? `run=${chunk.runId}:${chunk.seqId ?? "-"} ` + : ""; + process.stdout.write( + `[tool_call] ${run}${JSON.stringify(chunk) ?? ""}\n`, + ); + break; + } + case "tool_return_message": { + const run = chunk.runId + ? `run=${chunk.runId}:${chunk.seqId ?? "-"} ` + : ""; + process.stdout.write(`[tool_return] ${run}${chunk}\n`); + break; + } + case "approval_request_message": { + const run = chunk.runId + ? `run=${chunk.runId}:${chunk.seqId ?? "-"} ` + : ""; + process.stdout.write( + `[approval_request] ${run}${JSON.stringify(chunk)}\n`, + ); + break; + } + case "ping": + // keepalive ping, ignore + break; + default: + process.stdout.write(`[event:${type}] ${JSON.stringify(chunk)}\n`); + } + } + + console.log("\n✅ Stream ended cleanly"); +} + +main().catch((err) => { + console.error("❌ Smoke test failed:", err); + process.exit(1); +}); diff --git a/src/tests/permissions-analyzer.test.ts b/src/tests/permissions-analyzer.test.ts new file mode 100644 index 0000000..c335e7d --- /dev/null +++ b/src/tests/permissions-analyzer.test.ts @@ -0,0 +1,345 @@ +import { expect, test } from "bun:test"; +import { analyzeApprovalContext } from "../permissions/analyzer"; + +// ============================================================================ +// Bash Command Analysis Tests +// ============================================================================ + +test("Git diff suggests safe subcommand rule", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "git diff HEAD" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Bash(git diff:*)"); + expect(context.approveAlwaysText).toContain("git diff"); + expect(context.allowPersistence).toBe(true); + expect(context.safetyLevel).toBe("safe"); + expect(context.defaultScope).toBe("project"); +}); + +test("Git status suggests safe subcommand rule", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "git status" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Bash(git status:*)"); + expect(context.safetyLevel).toBe("safe"); +}); + +test("Git push suggests moderate safety rule", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "git push origin main" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Bash(git push:*)"); + expect(context.approveAlwaysText).toContain("git push"); + expect(context.allowPersistence).toBe(true); + expect(context.safetyLevel).toBe("moderate"); + expect(context.defaultScope).toBe("project"); +}); + +test("Git pull suggests moderate safety rule", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "git pull origin main" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Bash(git pull:*)"); + expect(context.safetyLevel).toBe("moderate"); +}); + +test("Git commit suggests moderate safety rule", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "git commit -m 'test'" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Bash(git commit:*)"); + expect(context.safetyLevel).toBe("moderate"); +}); + +test("Dangerous rm command blocks persistence", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "rm -rf node_modules" }, + "/Users/test/project", + ); + + expect(context.allowPersistence).toBe(false); + expect(context.safetyLevel).toBe("dangerous"); + expect(context.approveAlwaysText).toBe(""); +}); + +test("Dangerous mv command blocks persistence", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "mv file.txt /tmp/" }, + "/Users/test/project", + ); + + expect(context.allowPersistence).toBe(false); + expect(context.safetyLevel).toBe("dangerous"); +}); + +test("Dangerous chmod command blocks persistence", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "chmod 777 file.txt" }, + "/Users/test/project", + ); + + expect(context.allowPersistence).toBe(false); + expect(context.safetyLevel).toBe("dangerous"); +}); + +test("Dangerous sudo command blocks persistence", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "sudo apt-get install vim" }, + "/Users/test/project", + ); + + expect(context.allowPersistence).toBe(false); + expect(context.safetyLevel).toBe("dangerous"); +}); + +test("Command with --force flag blocks persistence", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "git push --force origin main" }, + "/Users/test/project", + ); + + expect(context.allowPersistence).toBe(false); + expect(context.safetyLevel).toBe("dangerous"); +}); + +test("Command with --hard flag blocks persistence", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "git reset --hard HEAD" }, + "/Users/test/project", + ); + + expect(context.allowPersistence).toBe(false); + expect(context.safetyLevel).toBe("dangerous"); +}); + +test("npm run commands suggest safe rule", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "npm run test" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Bash(npm run test:*)"); + expect(context.approveAlwaysText).toContain("npm run test"); + expect(context.safetyLevel).toBe("safe"); + expect(context.defaultScope).toBe("project"); +}); + +test("bun run commands suggest safe rule", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "bun run lint" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Bash(bun run lint:*)"); + expect(context.safetyLevel).toBe("safe"); +}); + +test("yarn commands suggest safe rule", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "yarn test" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Bash(yarn test:*)"); + expect(context.safetyLevel).toBe("safe"); +}); + +test("Safe ls command suggests wildcard rule", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "ls -la /tmp" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Bash(ls:*)"); + expect(context.approveAlwaysText).toContain("ls"); + expect(context.safetyLevel).toBe("safe"); +}); + +test("Safe cat command suggests wildcard rule", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "cat file.txt" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Bash(cat:*)"); + expect(context.safetyLevel).toBe("safe"); +}); + +test("Unknown command suggests exact match", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "custom-script --arg value" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Bash(custom-script --arg value)"); + expect(context.safetyLevel).toBe("moderate"); + expect(context.allowPersistence).toBe(true); +}); + +// ============================================================================ +// File Tool Analysis Tests +// ============================================================================ + +test("Read outside working directory suggests directory pattern", () => { + const context = analyzeApprovalContext( + "Read", + { file_path: "/Users/test/docs/api.md" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Read(/Users/test/docs/**)"); + expect(context.approveAlwaysText).toContain("/Users/test/docs/"); + expect(context.defaultScope).toBe("project"); + expect(context.safetyLevel).toBe("safe"); +}); + +test("Read with tilde path shows tilde in button text", () => { + const homedir = require("node:os").homedir(); + const context = analyzeApprovalContext( + "Read", + { file_path: `${homedir}/.zshrc` }, + "/Users/test/project", + ); + + expect(context.approveAlwaysText).toContain("~/"); +}); + +test("Write suggests session-only approval", () => { + const context = analyzeApprovalContext( + "Write", + { file_path: "src/new-file.ts" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Write(**)"); + expect(context.defaultScope).toBe("session"); + expect(context.approveAlwaysText).toContain("during this session"); + expect(context.safetyLevel).toBe("moderate"); +}); + +test("Edit suggests directory pattern for project-level", () => { + const context = analyzeApprovalContext( + "Edit", + { file_path: "src/utils/helper.ts" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Edit(src/utils/**)"); + expect(context.approveAlwaysText).toContain("src/utils/"); + expect(context.defaultScope).toBe("project"); + expect(context.safetyLevel).toBe("safe"); +}); + +test("Edit at project root suggests project pattern", () => { + const context = analyzeApprovalContext( + "Edit", + { file_path: "README.md" }, + "/Users/test/project", + ); + + expect(context.approveAlwaysText).toContain("project"); + expect(context.safetyLevel).toBe("safe"); +}); + +test("Glob outside working directory suggests directory pattern", () => { + const context = analyzeApprovalContext( + "Glob", + { path: "/Users/test/docs" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toContain("Glob(/Users/test/docs/**)"); + expect(context.approveAlwaysText).toContain("/Users/test/docs/"); +}); + +test("Grep outside working directory suggests directory pattern", () => { + const context = analyzeApprovalContext( + "Grep", + { path: "/Users/test/docs" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toContain("Grep(/Users/test/docs/**)"); + expect(context.approveAlwaysText).toContain("/Users/test/docs/"); +}); + +// ============================================================================ +// WebFetch Analysis Tests +// ============================================================================ + +test("WebFetch suggests domain pattern", () => { + const context = analyzeApprovalContext( + "WebFetch", + { url: "https://api.github.com/users/test" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("WebFetch(https://api.github.com/*)"); + expect(context.approveAlwaysText).toContain("api.github.com"); + expect(context.safetyLevel).toBe("safe"); +}); + +test("WebFetch with http protocol", () => { + const context = analyzeApprovalContext( + "WebFetch", + { url: "http://localhost:3000/api" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("WebFetch(http://localhost/*)"); + expect(context.approveAlwaysText).toContain("localhost"); +}); + +test("WebFetch with invalid URL falls back", () => { + const context = analyzeApprovalContext( + "WebFetch", + { url: "not-a-valid-url" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("WebFetch"); + expect(context.safetyLevel).toBe("moderate"); +}); + +// ============================================================================ +// Default/Unknown Tool Analysis Tests +// ============================================================================ + +test("Unknown tool suggests session-only", () => { + const context = analyzeApprovalContext( + "CustomTool", + { arg: "value" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("CustomTool"); + expect(context.defaultScope).toBe("session"); + expect(context.safetyLevel).toBe("moderate"); +}); diff --git a/src/tests/permissions-checker.test.ts b/src/tests/permissions-checker.test.ts new file mode 100644 index 0000000..f9d6f90 --- /dev/null +++ b/src/tests/permissions-checker.test.ts @@ -0,0 +1,551 @@ +import { expect, test } from "bun:test"; +import { checkPermission } from "../permissions/checker"; +import { sessionPermissions } from "../permissions/session"; +import type { PermissionRules } from "../permissions/types"; + +// ============================================================================ +// Working Directory Tests +// ============================================================================ + +test("Read within working directory is auto-allowed", () => { + const result = checkPermission( + "Read", + { file_path: "src/test.ts" }, + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.reason).toBe("Within working directory"); +}); + +test("Read outside working directory requires permission", () => { + const result = checkPermission( + "Read", + { file_path: "/Users/test/other/file.ts" }, + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + // Default for Read is allow, but not within working directory + expect(result.decision).toBe("allow"); + expect(result.reason).toBe("Default behavior for tool"); +}); + +test("Glob within working directory is auto-allowed", () => { + const result = checkPermission( + "Glob", + { path: "/Users/test/project/src" }, + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); +}); + +test("Grep within working directory is auto-allowed", () => { + const result = checkPermission( + "Grep", + { path: "/Users/test/project" }, + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); +}); + +// ============================================================================ +// Additional Directories Tests +// ============================================================================ + +test("Read in additional directory is auto-allowed", () => { + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + additionalDirectories: ["../docs"], + }; + + const result = checkPermission( + "Read", + { file_path: "/Users/test/docs/api.md" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.reason).toBe("Within working directory"); +}); + +test("Multiple additional directories", () => { + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + additionalDirectories: ["../docs", "~/shared"], + }; + + const result1 = checkPermission( + "Read", + { file_path: "/Users/test/docs/file.md" }, + permissions, + "/Users/test/project", + ); + expect(result1.decision).toBe("allow"); + + const homedir = require("node:os").homedir(); + const result2 = checkPermission( + "Read", + { file_path: `${homedir}/shared/file.txt` }, + permissions, + "/Users/test/project", + ); + expect(result2.decision).toBe("allow"); +}); + +// ============================================================================ +// Deny Rule Precedence Tests +// ============================================================================ + +test("Deny rule overrides working directory auto-allow", () => { + const permissions: PermissionRules = { + allow: [], + deny: ["Read(.env)"], + ask: [], + }; + + const result = checkPermission( + "Read", + { file_path: ".env" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("deny"); + expect(result.matchedRule).toBe("Read(.env)"); +}); + +test("Deny pattern blocks multiple files", () => { + const permissions: PermissionRules = { + allow: [], + deny: ["Read(.env.*)"], + ask: [], + }; + + const result1 = checkPermission( + "Read", + { file_path: ".env.local" }, + permissions, + "/Users/test/project", + ); + expect(result1.decision).toBe("deny"); + + const result2 = checkPermission( + "Read", + { file_path: ".env.production" }, + permissions, + "/Users/test/project", + ); + expect(result2.decision).toBe("deny"); +}); + +test("Deny directory blocks all files within", () => { + const permissions: PermissionRules = { + allow: [], + deny: ["Read(secrets/**)"], + ask: [], + }; + + const result = checkPermission( + "Read", + { file_path: "secrets/api-key.txt" }, + permissions, + "/Users/test/project", + ); + expect(result.decision).toBe("deny"); +}); + +// ============================================================================ +// Allow Rule Tests +// ============================================================================ + +test("Allow rule for file outside working directory", () => { + const permissions: PermissionRules = { + allow: ["Read(/Users/test/docs/**)"], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Read", + { file_path: "/Users/test/docs/api.md" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.matchedRule).toBe("Read(/Users/test/docs/**)"); +}); + +test("Allow rule for Bash command", () => { + const permissions: PermissionRules = { + allow: ["Bash(git diff:*)"], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Bash", + { command: "git diff HEAD" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.matchedRule).toBe("Bash(git diff:*)"); +}); + +test("Allow exact Bash command", () => { + const permissions: PermissionRules = { + allow: ["Bash(npm run lint)"], + deny: [], + ask: [], + }; + + const result1 = checkPermission( + "Bash", + { command: "npm run lint" }, + permissions, + "/Users/test/project", + ); + expect(result1.decision).toBe("allow"); + + const result2 = checkPermission( + "Bash", + { command: "npm run lint --fix" }, + permissions, + "/Users/test/project", + ); + expect(result2.decision).toBe("ask"); // Doesn't match exact +}); + +// ============================================================================ +// Ask Rule Tests +// ============================================================================ + +test("Ask rule forces prompt", () => { + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: ["Bash(git push:*)"], + }; + + const result = checkPermission( + "Bash", + { command: "git push origin main" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("ask"); + expect(result.matchedRule).toBe("Bash(git push:*)"); +}); + +test("Ask rule for specific file pattern", () => { + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: ["Write(**/*.sql)"], + }; + + const result = checkPermission( + "Write", + { file_path: "migrations/001.sql" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("ask"); +}); + +// ============================================================================ +// Default Behavior Tests +// ============================================================================ + +test("Read defaults to allow", () => { + const result = checkPermission( + "Read", + { file_path: "/some/external/file.txt" }, + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.reason).toBe("Default behavior for tool"); +}); + +test("Bash defaults to ask", () => { + const result = checkPermission( + "Bash", + { command: "ls -la" }, + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + expect(result.decision).toBe("ask"); + expect(result.reason).toBe("Default behavior for tool"); +}); + +test("Write defaults to ask", () => { + const result = checkPermission( + "Write", + { file_path: "new-file.txt" }, + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + expect(result.decision).toBe("ask"); +}); + +test("Edit defaults to ask", () => { + const result = checkPermission( + "Edit", + { file_path: "existing-file.txt" }, + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + expect(result.decision).toBe("ask"); +}); + +test("TodoWrite defaults to allow", () => { + const result = checkPermission( + "TodoWrite", + { todos: [] }, + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); +}); + +// ============================================================================ +// Precedence Order Tests +// ============================================================================ + +test("Deny takes precedence over allow", () => { + const permissions: PermissionRules = { + allow: ["Read(secrets/**)"], + deny: ["Read(secrets/**)"], + ask: [], + }; + + const result = checkPermission( + "Read", + { file_path: "secrets/key.txt" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("deny"); +}); + +test("Deny takes precedence over working directory", () => { + const permissions: PermissionRules = { + allow: [], + deny: ["Read(.env)"], + ask: [], + }; + + const result = checkPermission( + "Read", + { file_path: ".env" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("deny"); +}); + +test("Allow takes precedence over ask", () => { + const permissions: PermissionRules = { + allow: ["Bash(git diff:*)"], + deny: [], + ask: ["Bash(git diff:*)"], + }; + + const result = checkPermission( + "Bash", + { command: "git diff HEAD" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); +}); + +test("Ask takes precedence over default", () => { + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: ["Read(/etc/**)"], + }; + + const result = checkPermission( + "Read", + { file_path: "/etc/hosts" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("ask"); +}); + +// ============================================================================ +// Session Permission Tests (Integration) +// ============================================================================ + +test("Session allow rule takes precedence over persisted allow", () => { + // Add a session rule + sessionPermissions.clear(); + sessionPermissions.addRule("Bash(git push:*)", "allow"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: ["Bash(git push:*)"], // Would normally ask + }; + + const result = checkPermission( + "Bash", + { command: "git push origin main" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.matchedRule).toContain("session"); + + // Clean up + sessionPermissions.clear(); +}); + +test("Session rules don't persist after clear", () => { + sessionPermissions.clear(); + sessionPermissions.addRule("Bash(ls:*)", "allow"); + + expect(sessionPermissions.hasRule("Bash(ls:*)", "allow")).toBe(true); + + sessionPermissions.clear(); + + expect(sessionPermissions.hasRule("Bash(ls:*)", "allow")).toBe(false); +}); + +// ============================================================================ +// Edge Cases and Error Handling +// ============================================================================ + +test("Missing file_path parameter", () => { + const result = checkPermission( + "Read", + {}, // No file_path + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + // Should fall back to default + expect(result.decision).toBe("allow"); +}); + +test("Missing command parameter for Bash", () => { + const result = checkPermission( + "Bash", + {}, // No command + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + // Should fall back to default + expect(result.decision).toBe("ask"); +}); + +test("Unknown tool defaults to ask", () => { + const result = checkPermission( + "UnknownTool", + {}, + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + expect(result.decision).toBe("ask"); + expect(result.reason).toBe("Default behavior for tool"); +}); + +test("Empty permissions object", () => { + const result = checkPermission( + "Read", + { file_path: "test.txt" }, + {}, // No arrays defined + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); +}); + +test("Relative path normalization", () => { + const permissions: PermissionRules = { + allow: [], + deny: ["Read(./secrets/**)"], + ask: [], + }; + + const result = checkPermission( + "Read", + { file_path: "secrets/key.txt" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("deny"); +}); + +test("Parent directory traversal", () => { + const result = checkPermission( + "Read", + { file_path: "../other-project/file.txt" }, + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + // Outside working directory, uses default + expect(result.decision).toBe("allow"); + expect(result.reason).toBe("Default behavior for tool"); +}); + +test("Absolute path handling", () => { + const permissions: PermissionRules = { + allow: [], + deny: ["Read(/etc/**)"], + ask: [], + }; + + const result = checkPermission( + "Read", + { file_path: "/etc/hosts" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("deny"); +}); + +test("Tool with alternative path parameter (Glob uses 'path' not 'file_path')", () => { + const result = checkPermission( + "Glob", + { path: "src" }, + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); +}); diff --git a/src/tests/permissions-cli.test.ts b/src/tests/permissions-cli.test.ts new file mode 100644 index 0000000..d60e2c9 --- /dev/null +++ b/src/tests/permissions-cli.test.ts @@ -0,0 +1,401 @@ +import { afterEach, expect, test } from "bun:test"; +import { checkPermission } from "../permissions/checker"; +import { cliPermissions } from "../permissions/cli"; +import type { PermissionRules } from "../permissions/types"; + +// Clean up after each test +afterEach(() => { + cliPermissions.clear(); +}); + +// ============================================================================ +// CLI Permission Parsing Tests +// ============================================================================ + +test("Parse simple tool list", () => { + cliPermissions.setAllowedTools("Bash,Read,Write"); + const tools = cliPermissions.getAllowedTools(); + + // Bash is normalized to Bash(:*), file tools get (**) wildcard + expect(tools).toEqual(["Bash(:*)", "Read(**)", "Write(**)"]); +}); + +test("Parse tool list with parameters", () => { + cliPermissions.setAllowedTools("Bash(npm install),Read(src/**)"); + const tools = cliPermissions.getAllowedTools(); + + expect(tools).toEqual(["Bash(npm install)", "Read(src/**)"]); +}); + +test("Parse tool list with mixed formats", () => { + cliPermissions.setAllowedTools("Bash,Read(src/**),Write"); + const tools = cliPermissions.getAllowedTools(); + + expect(tools).toEqual(["Bash(:*)", "Read(src/**)", "Write(**)"]); +}); + +test("Parse tool list with wildcards", () => { + cliPermissions.setAllowedTools("Bash(git diff:*),Bash(npm run test:*)"); + const tools = cliPermissions.getAllowedTools(); + + expect(tools).toEqual(["Bash(git diff:*)", "Bash(npm run test:*)"]); +}); + +test("Handle empty tool list", () => { + cliPermissions.setAllowedTools(""); + const tools = cliPermissions.getAllowedTools(); + + expect(tools).toEqual([]); +}); + +test("Handle whitespace in tool list", () => { + cliPermissions.setAllowedTools("Bash , Read , Write"); + const tools = cliPermissions.getAllowedTools(); + + expect(tools).toEqual(["Bash(:*)", "Read(**)", "Write(**)"]); +}); + +// ============================================================================ +// CLI allowedTools Override Tests +// ============================================================================ + +test("allowedTools overrides settings deny rules", () => { + cliPermissions.setAllowedTools("Bash"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Bash", + { command: "npm install" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.matchedRule).toBe("Bash(:*) (CLI)"); + expect(result.reason).toBe("Matched --allowedTools flag"); +}); + +test("allowedTools with pattern matches specific command", () => { + cliPermissions.setAllowedTools("Bash(npm install)"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Bash", + { command: "npm install" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.matchedRule).toBe("Bash(npm install) (CLI)"); +}); + +test("allowedTools pattern does not match different command", () => { + cliPermissions.setAllowedTools("Bash(npm install)"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Bash", + { command: "rm -rf /" }, + permissions, + "/Users/test/project", + ); + + // Should not match, fall back to default behavior + expect(result.decision).toBe("ask"); + expect(result.reason).toBe("Default behavior for tool"); +}); + +test("allowedTools with wildcard prefix matches multiple commands", () => { + cliPermissions.setAllowedTools("Bash(npm run test:*)"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result1 = checkPermission( + "Bash", + { command: "npm run test:unit" }, + permissions, + "/Users/test/project", + ); + expect(result1.decision).toBe("allow"); + + const result2 = checkPermission( + "Bash", + { command: "npm run test:integration" }, + permissions, + "/Users/test/project", + ); + expect(result2.decision).toBe("allow"); + + const result3 = checkPermission( + "Bash", + { command: "npm run lint" }, + permissions, + "/Users/test/project", + ); + expect(result3.decision).toBe("ask"); // Should not match +}); + +test("allowedTools applies to multiple tools", () => { + cliPermissions.setAllowedTools("Bash,Read,Write"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const bashResult = checkPermission( + "Bash", + { command: "ls" }, + permissions, + "/Users/test/project", + ); + expect(bashResult.decision).toBe("allow"); + + const readResult = checkPermission( + "Read", + { file_path: "/etc/passwd" }, + permissions, + "/Users/test/project", + ); + expect(readResult.decision).toBe("allow"); + + const writeResult = checkPermission( + "Write", + { file_path: "/tmp/test.txt" }, + permissions, + "/Users/test/project", + ); + expect(writeResult.decision).toBe("allow"); +}); + +// ============================================================================ +// CLI disallowedTools Override Tests +// ============================================================================ + +test("disallowedTools denies tool", () => { + cliPermissions.setDisallowedTools("WebFetch"); + + const permissions: PermissionRules = { + allow: ["WebFetch"], + deny: [], + ask: [], + }; + + const result = checkPermission( + "WebFetch", + { url: "https://example.com" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("deny"); + expect(result.matchedRule).toBe("WebFetch (CLI)"); + expect(result.reason).toBe("Matched --disallowedTools flag"); +}); + +test("disallowedTools with pattern denies specific command", () => { + cliPermissions.setDisallowedTools("Bash(curl:*)"); + + const permissions: PermissionRules = { + allow: ["Bash"], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Bash", + { command: "curl https://malicious.com" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("deny"); + expect(result.matchedRule).toBe("Bash(curl:*) (CLI)"); +}); + +test("disallowedTools overrides settings allow rules", () => { + cliPermissions.setDisallowedTools("Bash"); + + const permissions: PermissionRules = { + allow: ["Bash"], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Bash", + { command: "ls" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("Matched --disallowedTools flag"); +}); + +test("disallowedTools does NOT override settings deny rules", () => { + cliPermissions.setAllowedTools("Bash"); + + const permissions: PermissionRules = { + allow: [], + deny: ["Bash(rm -rf:*)"], + ask: [], + }; + + const result = checkPermission( + "Bash", + { command: "rm -rf /" }, + permissions, + "/Users/test/project", + ); + + // Settings deny should take precedence + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("Matched deny rule"); + expect(result.matchedRule).toBe("Bash(rm -rf:*)"); +}); + +// ============================================================================ +// Combined allowedTools and disallowedTools Tests +// ============================================================================ + +test("disallowedTools takes precedence over allowedTools", () => { + cliPermissions.setAllowedTools("Bash"); + cliPermissions.setDisallowedTools("Bash(curl:*)"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + // curl should be denied + const curlResult = checkPermission( + "Bash", + { command: "curl https://example.com" }, + permissions, + "/Users/test/project", + ); + expect(curlResult.decision).toBe("deny"); + + // other commands should be allowed + const lsResult = checkPermission( + "Bash", + { command: "ls" }, + permissions, + "/Users/test/project", + ); + expect(lsResult.decision).toBe("allow"); +}); + +test("allowedTools and disallowedTools with multiple tools", () => { + cliPermissions.setAllowedTools("Bash,Read"); + cliPermissions.setDisallowedTools("Write"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const bashResult = checkPermission( + "Bash", + { command: "ls" }, + permissions, + "/Users/test/project", + ); + expect(bashResult.decision).toBe("allow"); + + const readResult = checkPermission( + "Read", + { file_path: "/tmp/file.txt" }, + permissions, + "/Users/test/project", + ); + expect(readResult.decision).toBe("allow"); + + const writeResult = checkPermission( + "Write", + { file_path: "/tmp/file.txt" }, + permissions, + "/Users/test/project", + ); + expect(writeResult.decision).toBe("deny"); +}); + +// ============================================================================ +// Precedence Tests +// ============================================================================ + +test("Precedence: settings deny > CLI disallowedTools", () => { + cliPermissions.setDisallowedTools("Bash(npm:*)"); + + const permissions: PermissionRules = { + allow: [], + deny: ["Bash(curl:*)"], + ask: [], + }; + + // Settings deny should match first + const result = checkPermission( + "Bash", + { command: "curl https://example.com" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("deny"); + expect(result.matchedRule).toBe("Bash(curl:*)"); + expect(result.reason).toBe("Matched deny rule"); +}); + +test("Precedence: CLI allowedTools > settings allow", () => { + cliPermissions.setAllowedTools("Bash(npm install)"); + + const permissions: PermissionRules = { + allow: ["Bash(git:*)"], + deny: [], + ask: [], + }; + + // CLI should match for npm install + const npmResult = checkPermission( + "Bash", + { command: "npm install" }, + permissions, + "/Users/test/project", + ); + expect(npmResult.decision).toBe("allow"); + expect(npmResult.matchedRule).toBe("Bash(npm install) (CLI)"); + + // Settings should match for git + const gitResult = checkPermission( + "Bash", + { command: "git status" }, + permissions, + "/Users/test/project", + ); + expect(gitResult.decision).toBe("allow"); + expect(gitResult.matchedRule).toBe("Bash(git:*)"); +}); diff --git a/src/tests/permissions-loader.test.ts b/src/tests/permissions-loader.test.ts new file mode 100644 index 0000000..bd80b48 --- /dev/null +++ b/src/tests/permissions-loader.test.ts @@ -0,0 +1,370 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadPermissions, savePermissionRule } from "../permissions/loader"; + +let testDir: string; + +beforeEach(async () => { + // Create a temporary test directory for project files + testDir = await mkdtemp(join(tmpdir(), "letta-test-")); +}); + +afterEach(async () => { + // Clean up test directory + await rm(testDir, { recursive: true, force: true }); +}); + +// ============================================================================ +// Basic Loading Tests +// ============================================================================ + +test("Load permissions from empty directory returns rules from user settings", async () => { + const projectDir = join(testDir, "empty-project"); + const permissions = await loadPermissions(projectDir); + + // Will include user settings from real ~/.letta/settings.json if it exists + // So we just verify the structure is correct + expect(Array.isArray(permissions.allow)).toBe(true); + expect(Array.isArray(permissions.deny)).toBe(true); + expect(Array.isArray(permissions.ask)).toBe(true); + expect(Array.isArray(permissions.additionalDirectories)).toBe(true); +}); + +// Skipped: User settings tests require mocking homedir() which is not reliable across platforms + +test("Load permissions from project settings", async () => { + const projectDir = join(testDir, "project-1"); + const projectSettingsPath = join(projectDir, ".letta", "settings.json"); + await Bun.write( + projectSettingsPath, + JSON.stringify({ + permissions: { + allow: ["Bash(npm run lint)"], + }, + }), + ); + + const permissions = await loadPermissions(projectDir); + + // Should include project rule (may also include user settings from real home dir) + expect(permissions.allow).toContain("Bash(npm run lint)"); +}); + +test("Load permissions from local settings", async () => { + const projectDir = join(testDir, "project-2"); + const localSettingsPath = join(projectDir, ".letta", "settings.local.json"); + await Bun.write( + localSettingsPath, + JSON.stringify({ + permissions: { + allow: ["Bash(git push:*)"], + }, + }), + ); + + const permissions = await loadPermissions(projectDir); + + // Should include local rule (may also include user settings from real home dir) + expect(permissions.allow).toContain("Bash(git push:*)"); +}); + +// ============================================================================ +// Hierarchical Merging Tests +// ============================================================================ + +test("Local settings merge with project settings", async () => { + const projectDir = join(testDir, "project-3"); + + // Project settings + await Bun.write( + join(projectDir, ".letta", "settings.json"), + JSON.stringify({ + permissions: { + allow: ["Bash(cat:*)"], + }, + }), + ); + + // Local settings + await Bun.write( + join(projectDir, ".letta", "settings.local.json"), + JSON.stringify({ + permissions: { + allow: ["Bash(git push:*)"], + }, + }), + ); + + const permissions = await loadPermissions(projectDir); + + // All rules should be merged (concatenated), plus any from user settings + expect(permissions.allow).toContain("Bash(cat:*)"); + expect(permissions.allow).toContain("Bash(git push:*)"); +}); + +test("Settings merge deny rules from multiple sources", async () => { + const projectDir = join(testDir, "project-4"); + + // Project settings + await Bun.write( + join(projectDir, ".letta", "settings.json"), + JSON.stringify({ + permissions: { + deny: ["Read(.env)"], + }, + }), + ); + + // Local settings + await Bun.write( + join(projectDir, ".letta", "settings.local.json"), + JSON.stringify({ + permissions: { + deny: ["Read(secrets/**)"], + }, + }), + ); + + const permissions = await loadPermissions(projectDir); + + // Should contain both deny rules (plus any from user settings) + expect(permissions.deny).toContain("Read(.env)"); + expect(permissions.deny).toContain("Read(secrets/**)"); +}); + +test("Settings merge additionalDirectories", async () => { + const projectDir = join(testDir, "project-5"); + + // Project settings + await Bun.write( + join(projectDir, ".letta", "settings.json"), + JSON.stringify({ + permissions: { + additionalDirectories: ["../docs"], + }, + }), + ); + + // Local settings + await Bun.write( + join(projectDir, ".letta", "settings.local.json"), + JSON.stringify({ + permissions: { + additionalDirectories: ["../shared"], + }, + }), + ); + + const permissions = await loadPermissions(projectDir); + + // Should contain both (plus any from user settings) + expect(permissions.additionalDirectories).toContain("../docs"); + expect(permissions.additionalDirectories).toContain("../shared"); +}); + +// ============================================================================ +// Saving Permission Rules Tests +// ============================================================================ + +// Skipped: User settings saving tests require mocking homedir() + +test("Save permission to project settings", async () => { + const projectDir = join(testDir, "project"); + await savePermissionRule( + "Bash(npm run lint)", + "allow", + "project", + projectDir, + ); + + const projectSettingsPath = join(projectDir, ".letta", "settings.json"); + const file = Bun.file(projectSettingsPath); + const settings = await file.json(); + + expect(settings.permissions.allow).toContain("Bash(npm run lint)"); +}); + +test("Save permission to local settings", async () => { + const projectDir = join(testDir, "project"); + await savePermissionRule("Bash(git push:*)", "allow", "local", projectDir); + + const localSettingsPath = join(projectDir, ".letta", "settings.local.json"); + const file = Bun.file(localSettingsPath); + const settings = await file.json(); + + expect(settings.permissions.allow).toContain("Bash(git push:*)"); +}); + +test("Save permission to deny list", async () => { + const projectDir = join(testDir, "project"); + await savePermissionRule("Read(.env)", "deny", "project", projectDir); + + const settingsPath = join(projectDir, ".letta", "settings.json"); + const file = Bun.file(settingsPath); + const settings = await file.json(); + + expect(settings.permissions.deny).toContain("Read(.env)"); +}); + +test("Save permission doesn't create duplicates", async () => { + const projectDir = join(testDir, "project"); + await savePermissionRule("Bash(ls:*)", "allow", "project", projectDir); + await savePermissionRule("Bash(ls:*)", "allow", "project", projectDir); + + const settingsPath = join(projectDir, ".letta", "settings.json"); + const file = Bun.file(settingsPath); + const settings = await file.json(); + + expect( + settings.permissions.allow.filter((r: string) => r === "Bash(ls:*)"), + ).toHaveLength(1); +}); + +test("Save permission preserves existing rules", async () => { + const projectDir = join(testDir, "project"); + + // Create initial settings + const settingsPath = join(projectDir, ".letta", "settings.json"); + await Bun.write( + settingsPath, + JSON.stringify({ + permissions: { + allow: ["Bash(cat:*)"], + }, + }), + ); + + // Add another rule + await savePermissionRule("Bash(ls:*)", "allow", "project", projectDir); + + const file = Bun.file(settingsPath); + const settings = await file.json(); + + expect(settings.permissions.allow).toContain("Bash(cat:*)"); + expect(settings.permissions.allow).toContain("Bash(ls:*)"); + expect(settings.permissions.allow).toHaveLength(2); +}); + +test("Save permission preserves other settings fields", async () => { + const projectDir = join(testDir, "project"); + + // Create settings with other fields + const settingsPath = join(projectDir, ".letta", "settings.json"); + await Bun.write( + settingsPath, + JSON.stringify({ + uiMode: "rich", + lastAgent: "agent-123", + permissions: { + allow: [], + }, + }), + ); + + await savePermissionRule("Bash(ls:*)", "allow", "project", projectDir); + + const file = Bun.file(settingsPath); + const settings = await file.json(); + + expect(settings.uiMode).toBe("rich"); + expect(settings.lastAgent).toBe("agent-123"); + expect(settings.permissions.allow).toContain("Bash(ls:*)"); +}); + +// ============================================================================ +// Error Handling Tests +// ============================================================================ + +test("Load permissions handles invalid JSON gracefully", async () => { + const projectDir = join(testDir, "project-invalid-json"); + const settingsPath = join(projectDir, ".letta", "settings.json"); + + // Write invalid JSON + await Bun.write(settingsPath, "{ invalid json "); + + const permissions = await loadPermissions(projectDir); + + // Should return empty permissions instead of crashing (silently skip invalid file) + expect(permissions.allow).toBeDefined(); + expect(permissions.deny).toBeDefined(); +}); + +test("Load permissions handles missing permissions field", async () => { + const projectDir = join(testDir, "project-no-perms"); + const settingsPath = join(projectDir, ".letta", "settings.json"); + + await Bun.write( + settingsPath, + JSON.stringify({ + uiMode: "rich", + // No permissions field + }), + ); + + const permissions = await loadPermissions(projectDir); + + // Should have empty arrays + expect(Array.isArray(permissions.allow)).toBe(true); + expect(Array.isArray(permissions.deny)).toBe(true); +}); + +test("Save permission creates parent directories", async () => { + const deepPath = join(testDir, "deep", "nested", "project"); + await savePermissionRule("Bash(ls:*)", "allow", "project", deepPath); + + const settingsPath = join(deepPath, ".letta", "settings.json"); + const file = Bun.file(settingsPath); + + expect(await file.exists()).toBe(true); +}); + +// ============================================================================ +// .gitignore Update Tests +// ============================================================================ + +test("Saving local settings updates .gitignore", async () => { + const projectDir = join(testDir, "project"); + + // Create .gitignore first + await Bun.write(join(projectDir, ".gitignore"), "node_modules\n"); + + await savePermissionRule("Bash(ls:*)", "allow", "local", projectDir); + + const gitignoreFile = Bun.file(join(projectDir, ".gitignore")); + const content = await gitignoreFile.text(); + + expect(content).toContain(".letta/settings.local.json"); + expect(content).toContain("node_modules"); // Preserves existing content +}); + +test("Saving local settings doesn't duplicate .gitignore entry", async () => { + const projectDir = join(testDir, "project"); + + await Bun.write( + join(projectDir, ".gitignore"), + "node_modules\n.letta/settings.local.json\n", + ); + + await savePermissionRule("Bash(ls:*)", "allow", "local", projectDir); + + const gitignoreFile = Bun.file(join(projectDir, ".gitignore")); + const content = await gitignoreFile.text(); + + const matches = content.match(/\.letta\/settings\.local\.json/g); + expect(matches).toHaveLength(1); +}); + +test("Saving local settings creates .gitignore if missing", async () => { + const projectDir = join(testDir, "project"); + + await savePermissionRule("Bash(ls:*)", "allow", "local", projectDir); + + const gitignoreFile = Bun.file(join(projectDir, ".gitignore")); + + expect(await gitignoreFile.exists()).toBe(true); + + const content = await gitignoreFile.text(); + expect(content).toContain(".letta/settings.local.json"); +}); diff --git a/src/tests/permissions-matcher.test.ts b/src/tests/permissions-matcher.test.ts new file mode 100644 index 0000000..364630d --- /dev/null +++ b/src/tests/permissions-matcher.test.ts @@ -0,0 +1,261 @@ +import { expect, test } from "bun:test"; +import { + matchesBashPattern, + matchesFilePattern, + matchesToolPattern, +} from "../permissions/matcher"; + +// ============================================================================ +// File Pattern Matching Tests +// ============================================================================ + +test("File pattern: exact match", () => { + expect( + matchesFilePattern("Read(.env)", "Read(.env)", "/Users/test/project"), + ).toBe(true); +}); + +test("File pattern: glob wildcard", () => { + expect( + matchesFilePattern( + "Read(.env.local)", + "Read(.env.*)", + "/Users/test/project", + ), + ).toBe(true); + expect( + matchesFilePattern( + "Read(.env.production)", + "Read(.env.*)", + "/Users/test/project", + ), + ).toBe(true); + expect( + matchesFilePattern( + "Read(config.json)", + "Read(.env.*)", + "/Users/test/project", + ), + ).toBe(false); +}); + +test("File pattern: recursive glob", () => { + expect( + matchesFilePattern( + "Read(src/utils/helper.ts)", + "Read(src/**)", + "/Users/test/project", + ), + ).toBe(true); + expect( + matchesFilePattern( + "Read(src/deep/nested/file.ts)", + "Read(src/**)", + "/Users/test/project", + ), + ).toBe(true); + expect( + matchesFilePattern( + "Read(other/file.ts)", + "Read(src/**)", + "/Users/test/project", + ), + ).toBe(false); +}); + +test("File pattern: any .ts file", () => { + expect( + matchesFilePattern( + "Read(src/file.ts)", + "Read(**/*.ts)", + "/Users/test/project", + ), + ).toBe(true); + expect( + matchesFilePattern( + "Read(deep/nested/file.ts)", + "Read(**/*.ts)", + "/Users/test/project", + ), + ).toBe(true); + expect( + matchesFilePattern("Read(file.js)", "Read(**/*.ts)", "/Users/test/project"), + ).toBe(false); +}); + +test("File pattern: absolute path with // prefix", () => { + expect( + matchesFilePattern( + "Read(/Users/test/docs/api.md)", + "Read(//Users/test/docs/**)", + "/Users/test/project", + ), + ).toBe(true); +}); + +test("File pattern: tilde expansion", () => { + const homedir = require("node:os").homedir(); + expect( + matchesFilePattern( + `Read(${homedir}/.zshrc)`, + "Read(~/.zshrc)", + "/Users/test/project", + ), + ).toBe(true); +}); + +test("File pattern: different tool names", () => { + expect( + matchesFilePattern( + "Write(file.txt)", + "Write(*.txt)", + "/Users/test/project", + ), + ).toBe(true); + expect( + matchesFilePattern("Edit(file.txt)", "Edit(*.txt)", "/Users/test/project"), + ).toBe(true); + expect( + matchesFilePattern("Glob(*.ts)", "Glob(*.ts)", "/Users/test/project"), + ).toBe(true); +}); + +test("File pattern: tool name mismatch doesn't match", () => { + expect( + matchesFilePattern( + "Read(file.txt)", + "Write(file.txt)", + "/Users/test/project", + ), + ).toBe(false); +}); + +test("File pattern: secrets directory", () => { + expect( + matchesFilePattern( + "Read(secrets/api-key.txt)", + "Read(secrets/**)", + "/Users/test/project", + ), + ).toBe(true); + expect( + matchesFilePattern( + "Read(secrets/nested/deep/file.txt)", + "Read(secrets/**)", + "/Users/test/project", + ), + ).toBe(true); + expect( + matchesFilePattern( + "Read(config/secrets.txt)", + "Read(secrets/**)", + "/Users/test/project", + ), + ).toBe(false); +}); + +// ============================================================================ +// Bash Pattern Matching Tests +// ============================================================================ + +test("Bash pattern: exact match", () => { + expect(matchesBashPattern("Bash(pwd)", "Bash(pwd)")).toBe(true); + expect(matchesBashPattern("Bash(pwd -L)", "Bash(pwd)")).toBe(false); +}); + +test("Bash pattern: wildcard prefix match", () => { + expect(matchesBashPattern("Bash(git diff)", "Bash(git diff:*)")).toBe(true); + expect(matchesBashPattern("Bash(git diff HEAD)", "Bash(git diff:*)")).toBe( + true, + ); + expect( + matchesBashPattern("Bash(git diff --cached)", "Bash(git diff:*)"), + ).toBe(true); + expect(matchesBashPattern("Bash(git status)", "Bash(git diff:*)")).toBe( + false, + ); +}); + +test("Bash pattern: npm/bun commands", () => { + expect(matchesBashPattern("Bash(npm run lint)", "Bash(npm run lint:*)")).toBe( + true, + ); + expect( + matchesBashPattern("Bash(npm run lint --fix)", "Bash(npm run lint:*)"), + ).toBe(true); + expect(matchesBashPattern("Bash(npm run test)", "Bash(npm run lint:*)")).toBe( + false, + ); +}); + +test("Bash pattern: multi-word exact match", () => { + expect(matchesBashPattern("Bash(npm run lint)", "Bash(npm run lint)")).toBe( + true, + ); + expect( + matchesBashPattern("Bash(npm run lint --fix)", "Bash(npm run lint)"), + ).toBe(false); +}); + +test("Bash pattern: git subcommands", () => { + expect(matchesBashPattern("Bash(git push)", "Bash(git push:*)")).toBe(true); + expect( + matchesBashPattern("Bash(git push origin main)", "Bash(git push:*)"), + ).toBe(true); + expect(matchesBashPattern("Bash(git push --force)", "Bash(git push:*)")).toBe( + true, + ); + expect(matchesBashPattern("Bash(git pull)", "Bash(git push:*)")).toBe(false); +}); + +test("Bash pattern: simple commands with wildcard", () => { + expect(matchesBashPattern("Bash(ls)", "Bash(ls:*)")).toBe(true); + expect(matchesBashPattern("Bash(ls -la)", "Bash(ls:*)")).toBe(true); + expect(matchesBashPattern("Bash(ls -la /tmp)", "Bash(ls:*)")).toBe(true); + expect(matchesBashPattern("Bash(cat file.txt)", "Bash(ls:*)")).toBe(false); +}); + +test("Bash pattern: empty command", () => { + expect(matchesBashPattern("Bash()", "Bash()")).toBe(true); + expect(matchesBashPattern("Bash()", "Bash(:*)")).toBe(true); +}); + +test("Bash pattern: special characters in command", () => { + expect(matchesBashPattern("Bash(echo 'hello world')", "Bash(echo:*)")).toBe( + true, + ); + expect(matchesBashPattern('Bash(grep -r "test" .)', "Bash(grep:*)")).toBe( + true, + ); +}); + +// ============================================================================ +// Tool Pattern Matching Tests +// ============================================================================ + +test("Tool pattern: exact tool name", () => { + expect(matchesToolPattern("WebFetch", "WebFetch")).toBe(true); + expect(matchesToolPattern("TodoWrite", "WebFetch")).toBe(false); +}); + +test("Tool pattern: with empty parens", () => { + expect(matchesToolPattern("WebFetch", "WebFetch()")).toBe(true); +}); + +test("Tool pattern: with parens and content", () => { + expect(matchesToolPattern("WebFetch", "WebFetch(https://example.com)")).toBe( + true, + ); +}); + +test("Tool pattern: wildcard matches all", () => { + expect(matchesToolPattern("WebFetch", "*")).toBe(true); + expect(matchesToolPattern("Bash", "*")).toBe(true); + expect(matchesToolPattern("Read", "*")).toBe(true); + expect(matchesToolPattern("AnyTool", "*")).toBe(true); +}); + +test("Tool pattern: case sensitivity", () => { + expect(matchesToolPattern("WebFetch", "webfetch")).toBe(false); + expect(matchesToolPattern("WebFetch", "WebFetch")).toBe(true); +}); diff --git a/src/tests/permissions-mode.test.ts b/src/tests/permissions-mode.test.ts new file mode 100644 index 0000000..6b6207f --- /dev/null +++ b/src/tests/permissions-mode.test.ts @@ -0,0 +1,369 @@ +import { afterEach, expect, test } from "bun:test"; +import { checkPermission } from "../permissions/checker"; +import { permissionMode } from "../permissions/mode"; +import type { PermissionRules } from "../permissions/types"; + +// Clean up after each test +afterEach(() => { + permissionMode.reset(); +}); + +// ============================================================================ +// Permission Mode: default +// ============================================================================ + +test("default mode - no overrides", () => { + permissionMode.setMode("default"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Bash", + { command: "ls" }, + permissions, + "/Users/test/project", + ); + + // Should fall back to tool default (ask for Bash) + expect(result.decision).toBe("ask"); + expect(result.reason).toBe("Default behavior for tool"); +}); + +// ============================================================================ +// Permission Mode: bypassPermissions +// ============================================================================ + +test("bypassPermissions mode - allows all tools", () => { + permissionMode.setMode("bypassPermissions"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const bashResult = checkPermission( + "Bash", + { command: "rm -rf /" }, + permissions, + "/Users/test/project", + ); + expect(bashResult.decision).toBe("allow"); + expect(bashResult.reason).toBe("Permission mode: bypassPermissions"); + + const writeResult = checkPermission( + "Write", + { file_path: "/etc/passwd" }, + permissions, + "/Users/test/project", + ); + expect(writeResult.decision).toBe("allow"); +}); + +test("bypassPermissions mode - does NOT override deny rules", () => { + permissionMode.setMode("bypassPermissions"); + + const permissions: PermissionRules = { + allow: [], + deny: ["Bash(rm -rf:*)"], + ask: [], + }; + + const result = checkPermission( + "Bash", + { command: "rm -rf /" }, + permissions, + "/Users/test/project", + ); + + // Deny rules take precedence even in bypassPermissions mode + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("Matched deny rule"); +}); + +// ============================================================================ +// Permission Mode: acceptEdits +// ============================================================================ + +test("acceptEdits mode - allows Write", () => { + permissionMode.setMode("acceptEdits"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Write", + { file_path: "/tmp/test.txt" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.matchedRule).toBe("acceptEdits mode"); + expect(result.reason).toBe("Permission mode: acceptEdits"); +}); + +test("acceptEdits mode - allows Edit", () => { + permissionMode.setMode("acceptEdits"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Edit", + { file_path: "/tmp/test.txt", old_string: "old", new_string: "new" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.matchedRule).toBe("acceptEdits mode"); +}); + +test("acceptEdits mode - allows NotebookEdit", () => { + permissionMode.setMode("acceptEdits"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result = checkPermission( + "NotebookEdit", + { notebook_path: "/tmp/test.ipynb", new_source: "code" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.matchedRule).toBe("acceptEdits mode"); +}); + +test("acceptEdits mode - does NOT allow Bash", () => { + permissionMode.setMode("acceptEdits"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Bash", + { command: "ls" }, + permissions, + "/Users/test/project", + ); + + // Bash is not an edit tool, should fall back to default + expect(result.decision).toBe("ask"); + expect(result.reason).toBe("Default behavior for tool"); +}); + +// ============================================================================ +// Permission Mode: plan +// ============================================================================ + +test("plan mode - allows Read", () => { + permissionMode.setMode("plan"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Read", + { file_path: "/tmp/test.txt" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.matchedRule).toBe("plan mode"); +}); + +test("plan mode - allows Glob", () => { + permissionMode.setMode("plan"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Glob", + { pattern: "**/*.ts" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.matchedRule).toBe("plan mode"); +}); + +test("plan mode - allows Grep", () => { + permissionMode.setMode("plan"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Grep", + { pattern: "import", path: "/tmp" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.matchedRule).toBe("plan mode"); +}); + +test("plan mode - allows TodoWrite", () => { + permissionMode.setMode("plan"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result = checkPermission( + "TodoWrite", + { todos: [] }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.matchedRule).toBe("plan mode"); +}); + +test("plan mode - denies Write", () => { + permissionMode.setMode("plan"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Write", + { file_path: "/tmp/test.txt" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("deny"); + expect(result.matchedRule).toBe("plan mode"); + expect(result.reason).toBe("Permission mode: plan"); +}); + +test("plan mode - denies Bash", () => { + permissionMode.setMode("plan"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Bash", + { command: "ls" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("deny"); + expect(result.matchedRule).toBe("plan mode"); +}); + +test("plan mode - denies WebFetch", () => { + permissionMode.setMode("plan"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result = checkPermission( + "WebFetch", + { url: "https://example.com" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("deny"); + expect(result.matchedRule).toBe("plan mode"); +}); + +// ============================================================================ +// Precedence Tests +// ============================================================================ + +test("Deny rules override permission mode", () => { + permissionMode.setMode("bypassPermissions"); + + const permissions: PermissionRules = { + allow: [], + deny: ["Write(**)"], + ask: [], + }; + + const result = checkPermission( + "Write", + { file_path: "/tmp/test.txt" }, + permissions, + "/Users/test/project", + ); + + // Deny rule takes precedence over bypassPermissions + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("Matched deny rule"); +}); + +test("Permission mode takes precedence over CLI allowedTools", () => { + const { cliPermissions } = require("../permissions/cli"); + cliPermissions.setAllowedTools("Bash"); + + permissionMode.setMode("plan"); + + const permissions: PermissionRules = { + allow: [], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Bash", + { command: "ls" }, + permissions, + "/Users/test/project", + ); + + // Permission mode denies take precedence over CLI allowedTools + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("Permission mode: plan"); + + // Clean up + cliPermissions.clear(); +}); diff --git a/src/tests/permissions-session.test.ts b/src/tests/permissions-session.test.ts new file mode 100644 index 0000000..c8db22b --- /dev/null +++ b/src/tests/permissions-session.test.ts @@ -0,0 +1,117 @@ +import { afterEach, expect, test } from "bun:test"; +import { sessionPermissions } from "../permissions/session"; + +afterEach(() => { + // Clean up session state after each test + sessionPermissions.clear(); +}); + +// ============================================================================ +// Basic Session Operations +// ============================================================================ + +test("Add rule to session permissions", () => { + sessionPermissions.addRule("Bash(ls:*)", "allow"); + + const rules = sessionPermissions.getRules(); + expect(rules.allow).toContain("Bash(ls:*)"); +}); + +test("Add deny rule to session", () => { + sessionPermissions.addRule("Read(.env)", "deny"); + + const rules = sessionPermissions.getRules(); + expect(rules.deny).toContain("Read(.env)"); +}); + +test("Add ask rule to session", () => { + sessionPermissions.addRule("Bash(git push:*)", "ask"); + + const rules = sessionPermissions.getRules(); + expect(rules.ask).toContain("Bash(git push:*)"); +}); + +test("Add multiple rules to session", () => { + sessionPermissions.addRule("Bash(ls:*)", "allow"); + sessionPermissions.addRule("Bash(cat:*)", "allow"); + sessionPermissions.addRule("Read(.env)", "deny"); + + const rules = sessionPermissions.getRules(); + expect(rules.allow).toHaveLength(2); + expect(rules.deny).toHaveLength(1); +}); + +test("Session doesn't create duplicate rules", () => { + sessionPermissions.addRule("Bash(ls:*)", "allow"); + sessionPermissions.addRule("Bash(ls:*)", "allow"); + sessionPermissions.addRule("Bash(ls:*)", "allow"); + + const rules = sessionPermissions.getRules(); + expect(rules.allow).toHaveLength(1); +}); + +test("hasRule checks for rule existence", () => { + sessionPermissions.addRule("Bash(ls:*)", "allow"); + + expect(sessionPermissions.hasRule("Bash(ls:*)", "allow")).toBe(true); + expect(sessionPermissions.hasRule("Bash(cat:*)", "allow")).toBe(false); + expect(sessionPermissions.hasRule("Bash(ls:*)", "deny")).toBe(false); +}); + +test("Clear removes all session rules", () => { + sessionPermissions.addRule("Bash(ls:*)", "allow"); + sessionPermissions.addRule("Bash(cat:*)", "allow"); + sessionPermissions.addRule("Read(.env)", "deny"); + + sessionPermissions.clear(); + + const rules = sessionPermissions.getRules(); + expect(rules.allow).toHaveLength(0); + expect(rules.deny).toHaveLength(0); + expect(rules.ask).toHaveLength(0); +}); + +test("getRules returns a copy (not reference)", () => { + sessionPermissions.addRule("Bash(ls:*)", "allow"); + + const rules1 = sessionPermissions.getRules(); + const rules2 = sessionPermissions.getRules(); + + // Should be different array instances + expect(rules1.allow).not.toBe(rules2.allow); + + // But should have same content + expect(rules1.allow).toEqual(rules2.allow); +}); + +test("Modifying returned rules doesn't affect session state", () => { + sessionPermissions.addRule("Bash(ls:*)", "allow"); + + const rules = sessionPermissions.getRules(); + rules.allow?.push("Bash(cat:*)"); + + // Original session should be unchanged + const actualRules = sessionPermissions.getRules(); + expect(actualRules.allow).toHaveLength(1); + expect(actualRules.allow).toContain("Bash(ls:*)"); + expect(actualRules.allow).not.toContain("Bash(cat:*)"); +}); + +// ============================================================================ +// Integration with Permission Checker +// ============================================================================ + +test("Session rules are respected by permission checker", () => { + // This is tested in permissions-checker.test.ts but worth verifying isolation + sessionPermissions.addRule("Bash(custom-command:*)", "allow"); + + expect(sessionPermissions.hasRule("Bash(custom-command:*)", "allow")).toBe( + true, + ); + + sessionPermissions.clear(); + + expect(sessionPermissions.hasRule("Bash(custom-command:*)", "allow")).toBe( + false, + ); +}); diff --git a/src/tests/permissions.test.ts b/src/tests/permissions.test.ts new file mode 100644 index 0000000..3746ea3 --- /dev/null +++ b/src/tests/permissions.test.ts @@ -0,0 +1,122 @@ +import { expect, test } from "bun:test"; +import { analyzeApprovalContext } from "../permissions/analyzer"; +import { checkPermission } from "../permissions/checker"; +import type { PermissionRules } from "../permissions/types"; + +test("Read within working directory is auto-allowed", () => { + const result = checkPermission( + "Read", + { file_path: "src/test.ts" }, + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.reason).toBe("Within working directory"); +}); + +test("Read outside working directory requires approval", () => { + const result = checkPermission( + "Read", + { file_path: "/Users/test/other-project/file.ts" }, + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); // Default for Read tool +}); + +test("Bash commands require approval by default", () => { + const result = checkPermission( + "Bash", + { command: "ls -la" }, + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + expect(result.decision).toBe("ask"); +}); + +test("Allow rule matches Bash prefix pattern", () => { + const permissions: PermissionRules = { + allow: ["Bash(git diff:*)"], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Bash", + { command: "git diff HEAD" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.matchedRule).toBe("Bash(git diff:*)"); +}); + +test("Deny rule blocks file access", () => { + const permissions: PermissionRules = { + allow: [], + deny: ["Read(.env)"], + ask: [], + }; + + const result = checkPermission( + "Read", + { file_path: ".env" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("deny"); + expect(result.matchedRule).toBe("Read(.env)"); +}); + +test("Analyze git diff approval context", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "git diff HEAD" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Bash(git diff:*)"); + expect(context.approveAlwaysText).toContain("git diff"); + expect(context.allowPersistence).toBe(true); + expect(context.safetyLevel).toBe("safe"); +}); + +test("Dangerous commands don't offer persistence", () => { + const context = analyzeApprovalContext( + "Bash", + { command: "rm -rf node_modules" }, + "/Users/test/project", + ); + + expect(context.allowPersistence).toBe(false); + expect(context.safetyLevel).toBe("dangerous"); +}); + +test("Read outside working directory suggests directory pattern", () => { + const context = analyzeApprovalContext( + "Read", + { file_path: "/Users/test/docs/api.md" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Read(/Users/test/docs/**)"); + expect(context.approveAlwaysText).toContain("/Users/test/docs/"); + expect(context.defaultScope).toBe("project"); +}); + +test("Write suggests session-only approval", () => { + const context = analyzeApprovalContext( + "Write", + { file_path: "src/new-file.ts" }, + "/Users/test/project", + ); + + expect(context.recommendedRule).toBe("Write(**)"); + expect(context.defaultScope).toBe("session"); + expect(context.approveAlwaysText).toContain("during this session"); +}); diff --git a/src/tests/tools-filter.test.ts b/src/tests/tools-filter.test.ts new file mode 100644 index 0000000..0fbce5b --- /dev/null +++ b/src/tests/tools-filter.test.ts @@ -0,0 +1,111 @@ +import { afterEach, expect, test } from "bun:test"; +import { toolFilter } from "../tools/filter"; + +// Clean up after each test +afterEach(() => { + toolFilter.reset(); +}); + +// ============================================================================ +// Tool Filter Parsing Tests +// ============================================================================ + +test("Parse simple tool list", () => { + toolFilter.setEnabledTools("Bash,Read,Write"); + const tools = toolFilter.getEnabledTools(); + + expect(tools).toEqual(["Bash", "Read", "Write"]); +}); + +test("Parse empty string means no tools", () => { + toolFilter.setEnabledTools(""); + const tools = toolFilter.getEnabledTools(); + + expect(tools).toEqual([]); + expect(toolFilter.isActive()).toBe(true); +}); + +test("No filter set means all tools enabled", () => { + // Don't call setEnabledTools + expect(toolFilter.isEnabled("Bash")).toBe(true); + expect(toolFilter.isEnabled("Read")).toBe(true); + expect(toolFilter.isEnabled("Write")).toBe(true); + expect(toolFilter.isActive()).toBe(false); + expect(toolFilter.getEnabledTools()).toBe(null); +}); + +test("Handle whitespace in tool list", () => { + toolFilter.setEnabledTools(" Bash , Read , Write "); + const tools = toolFilter.getEnabledTools(); + + expect(tools).toEqual(["Bash", "Read", "Write"]); +}); + +test("Handle single tool", () => { + toolFilter.setEnabledTools("Bash"); + const tools = toolFilter.getEnabledTools(); + + expect(tools).toEqual(["Bash"]); +}); + +// ============================================================================ +// Tool Filtering Tests +// ============================================================================ + +test("isEnabled returns true when tool is in the list", () => { + toolFilter.setEnabledTools("Bash,Read"); + + expect(toolFilter.isEnabled("Bash")).toBe(true); + expect(toolFilter.isEnabled("Read")).toBe(true); +}); + +test("isEnabled returns false when tool is NOT in the list", () => { + toolFilter.setEnabledTools("Bash,Read"); + + expect(toolFilter.isEnabled("Write")).toBe(false); + expect(toolFilter.isEnabled("Edit")).toBe(false); + expect(toolFilter.isEnabled("Grep")).toBe(false); +}); + +test("Empty string disables all tools", () => { + toolFilter.setEnabledTools(""); + + expect(toolFilter.isEnabled("Bash")).toBe(false); + expect(toolFilter.isEnabled("Read")).toBe(false); + expect(toolFilter.isEnabled("Write")).toBe(false); + expect(toolFilter.isActive()).toBe(true); +}); + +test("Reset clears filter", () => { + toolFilter.setEnabledTools("Bash"); + + expect(toolFilter.isEnabled("Bash")).toBe(true); + expect(toolFilter.isEnabled("Read")).toBe(false); + + toolFilter.reset(); + + expect(toolFilter.isEnabled("Bash")).toBe(true); + expect(toolFilter.isEnabled("Read")).toBe(true); + expect(toolFilter.isActive()).toBe(false); +}); + +// ============================================================================ +// Edge Cases +// ============================================================================ + +test("Ignores empty items from extra commas", () => { + toolFilter.setEnabledTools("Bash,,Read,,,Write,"); + const tools = toolFilter.getEnabledTools(); + + expect(tools).toEqual(["Bash", "Read", "Write"]); +}); + +test("isActive returns true when filter is set", () => { + expect(toolFilter.isActive()).toBe(false); + + toolFilter.setEnabledTools("Bash"); + expect(toolFilter.isActive()).toBe(true); + + toolFilter.setEnabledTools(""); + expect(toolFilter.isActive()).toBe(true); +}); diff --git a/src/tools/descriptions/Bash.md b/src/tools/descriptions/Bash.md new file mode 100644 index 0000000..69c4716 --- /dev/null +++ b/src/tools/descriptions/Bash.md @@ -0,0 +1,115 @@ +# Bash + +Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use the LS tool to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use LS to check that "foo" exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt") + - Examples of proper quoting: + - cd "/Users/name/My Documents" (correct) + - cd /Users/name/My Documents (incorrect - will fail) + - python "/path/with spaces/script.py" (correct) + - python /path/with spaces/script.py (incorrect - will fail) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds 30000 characters, output will be truncated before being returned to you. + - VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`. Instead use Grep, Glob, or Task to search. You MUST avoid read tools like `cat`, `head`, `tail`, and `ls`, and use Read and LS to read files. + - If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` first, which all ${PRODUCT_NAME} users have pre-installed. + - When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings). + - Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it. + + pytest /foo/bar/tests + + + cd /foo/bar && pytest tests + + + + + +# Committing changes with git + +When the user asks you to create a new git commit, follow these steps carefully: + +1. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following bash commands in parallel, each using the Bash tool: + - Run a git status command to see all untracked files. + - Run a git diff command to see both staged and unstaged changes that will be committed. + - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. +2. Analyze all staged changes (both previously staged and newly added) and draft a commit message: + - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.). + - Check for any sensitive information that shouldn't be committed + - Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what" + - Ensure it accurately reflects the changes and their purpose +3. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following commands in parallel: + - Add relevant untracked files to the staging area. + - Create the commit with a message ending with: + 👾 Generated with [Letta Code](https://letta.com) + + Co-Authored-By: Letta + - Run git status to make sure the commit succeeded. +4. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them. + +Important notes: +- NEVER update the git config +- NEVER run additional commands to read or explore code, besides git bash commands +- NEVER use the TodoWrite or Task tools +- DO NOT push to the remote repository unless the user explicitly asks you to do so +- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. +- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit +- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example: + +git commit -m "$(cat <<'EOF' + Commit message here. + + 👾 Generated with [Letta Code](https://letta.com) + + Co-Authored-By: Letta + EOF + )" + + +# Creating pull requests +Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed. + +IMPORTANT: When the user asks you to create a pull request, follow these steps carefully: + +1. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch: + - Run a git status command to see all untracked files + - Run a git diff command to see both staged and unstaged changes that will be committed + - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote + - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch) +2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary +3. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following commands in parallel: + - Create new branch if needed + - Push to remote with -u flag if needed + - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting. + +gh pr create --title "the pr title" --body "$(cat <<'EOF' +## Summary +<1-3 bullet points> + +## Test plan +[Checklist of TODOs for testing the pull request...] + +👾 Generated with [Letta Code](https://letta.com) +EOF +)" + + +Important: +- NEVER update the git config +- DO NOT use the TodoWrite or Task tools +- Return the PR URL when you're done, so the user can see it + +# Other common operations +- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments \ No newline at end of file diff --git a/src/tools/descriptions/BashOutput.md b/src/tools/descriptions/BashOutput.md new file mode 100644 index 0000000..34c1f5e --- /dev/null +++ b/src/tools/descriptions/BashOutput.md @@ -0,0 +1,9 @@ +# BashOutput + +- Retrieves output from a running or completed background bash shell +- Takes a bash_id parameter identifying the shell +- Always returns only new output since the last check +- Returns stdout and stderr output along with shell status +- Supports optional regex filtering to show only lines matching a pattern +- Use this tool when you need to monitor or check the output of a long-running shell +- Shell IDs can be found using the /bashes command \ No newline at end of file diff --git a/src/tools/descriptions/Edit.md b/src/tools/descriptions/Edit.md new file mode 100644 index 0000000..87637fb --- /dev/null +++ b/src/tools/descriptions/Edit.md @@ -0,0 +1,11 @@ +# Edit + +Performs exact string replacements in files. + +Usage: +- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. +- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. +- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`. +- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. \ No newline at end of file diff --git a/src/tools/descriptions/ExitPlanMode.md b/src/tools/descriptions/ExitPlanMode.md new file mode 100644 index 0000000..8528c8d --- /dev/null +++ b/src/tools/descriptions/ExitPlanMode.md @@ -0,0 +1,16 @@ +Use this tool when you are in plan mode and have finished presenting your plan and are ready to code. This will prompt the user to exit plan mode. +IMPORTANT: Only use this tool when the task requires planning the implementation steps of a task that requires writing code. For research tasks where you're gathering information, searching files, reading files or in general trying to understand the codebase - do NOT use this tool. + +## Handling Ambiguity in Plans +Before using this tool, ensure your plan is clear and unambiguous. If there are multiple valid approaches or unclear requirements: +1. Use the AskUserQuestion tool to clarify with the user +2. Ask about specific implementation choices (e.g., architectural patterns, which library to use) +3. Clarify any assumptions that could affect the implementation +4. Only proceed with ExitPlanMode after resolving ambiguities + + +## Examples + +1. Initial task: "Search for and understand the implementation of vim mode in the codebase" - Do not use the exit plan mode tool because you are not planning the implementation steps of a task. +2. Initial task: "Help me implement yank mode for vim" - Use the exit plan mode tool after you have finished planning the implementation steps of the task. +3. Initial task: "Add a new feature to handle user authentication" - If unsure about auth method (OAuth, JWT, etc.), use AskUserQuestion first, then use exit plan mode tool after clarifying the approach. diff --git a/src/tools/descriptions/Glob.md b/src/tools/descriptions/Glob.md new file mode 100644 index 0000000..1106ca1 --- /dev/null +++ b/src/tools/descriptions/Glob.md @@ -0,0 +1,8 @@ +# Glob + +- Fast file pattern matching tool that works with any codebase size +- Supports glob patterns like "**/*.js" or "src/**/*.ts" +- Returns matching file paths sorted by modification time +- Use this tool when you need to find files by name patterns +- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead +- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. \ No newline at end of file diff --git a/src/tools/descriptions/Grep.md b/src/tools/descriptions/Grep.md new file mode 100644 index 0000000..16de643 --- /dev/null +++ b/src/tools/descriptions/Grep.md @@ -0,0 +1,12 @@ +# Grep + +A powerful search tool built on ripgrep + + Usage: + - ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access. + - Supports full regex syntax (e.g., "log.*Error", "function\s+\w+") + - Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust") + - Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts + - Use Task tool for open-ended searches requiring multiple rounds + - Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use `interface\{\}` to find `interface{}` in Go code) + - Multiline matching: By default patterns match within single lines only. For cross-line patterns like `struct \{[\s\S]*?field`, use `multiline: true` diff --git a/src/tools/descriptions/KillBash.md b/src/tools/descriptions/KillBash.md new file mode 100644 index 0000000..0a2a8da --- /dev/null +++ b/src/tools/descriptions/KillBash.md @@ -0,0 +1,7 @@ +# KillBash + +- Kills a running background bash shell by its ID +- Takes a shell_id parameter identifying the shell to kill +- Returns a success or failure status +- Use this tool when you need to terminate a long-running shell +- Shell IDs can be found using the /bashes command \ No newline at end of file diff --git a/src/tools/descriptions/LS.md b/src/tools/descriptions/LS.md new file mode 100644 index 0000000..211e0a4 --- /dev/null +++ b/src/tools/descriptions/LS.md @@ -0,0 +1,3 @@ +# LS + +Lists files and directories in a given path. The path parameter must be an absolute path, not a relative path. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search. \ No newline at end of file diff --git a/src/tools/descriptions/MultiEdit.md b/src/tools/descriptions/MultiEdit.md new file mode 100644 index 0000000..d5cfdae --- /dev/null +++ b/src/tools/descriptions/MultiEdit.md @@ -0,0 +1,44 @@ +# MultiEdit + +This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit tool when you need to make multiple edits to the same file. + +Before using this tool: + +1. Use the Read tool to understand the file's contents and context +2. Verify the directory path is correct + +To make multiple file edits, provide the following: +1. file_path: The absolute path to the file to modify (must be absolute, not relative) +2. edits: An array of edit operations to perform, where each edit contains: + - old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation) + - new_string: The edited text to replace the old_string + - replace_all: Replace all occurences of old_string. This parameter is optional and defaults to false. + +IMPORTANT: +- All edits are applied in sequence, in the order they are provided +- Each edit operates on the result of the previous edit +- All edits must be valid for the operation to succeed - if any edit fails, none will be applied +- This tool is ideal when you need to make several changes to different parts of the same file +- For Jupyter notebooks (.ipynb files), use the NotebookEdit instead + +CRITICAL REQUIREMENTS: +1. All edits follow the same requirements as the single Edit tool +2. The edits are atomic - either all succeed or none are applied +3. Plan your edits carefully to avoid conflicts between sequential operations + +WARNING: +- The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace) +- The tool will fail if edits.old_string and edits.new_string are the same +- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find + +When making edits: +- Ensure all edits result in idiomatic, correct code +- Do not leave the code in a broken state +- Always use absolute file paths (starting with /) +- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. +- Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. + +If you want to create a new file, use: +- A new file path, including dir name if needed +- First edit: empty old_string and the new file's contents as new_string +- Subsequent edits: normal edit operations on the created content \ No newline at end of file diff --git a/src/tools/descriptions/Read.md b/src/tools/descriptions/Read.md new file mode 100644 index 0000000..f2cde1b --- /dev/null +++ b/src/tools/descriptions/Read.md @@ -0,0 +1,16 @@ +# Read + +Reads a file from the local filesystem. You can access any file directly by using this tool. +Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. + +Usage: +- The file_path parameter must be an absolute path, not a relative path +- By default, it reads up to 2000 lines starting from the beginning of the file +- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters +- Any lines longer than 2000 characters will be truncated +- Results are returned using cat -n format, with line numbers starting at 1 +- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM. +- For Jupyter notebooks (.ipynb files), use the NotebookRead instead +- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful. +- You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png +- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents. \ No newline at end of file diff --git a/src/tools/descriptions/TodoWrite.md b/src/tools/descriptions/TodoWrite.md new file mode 100644 index 0000000..0224793 --- /dev/null +++ b/src/tools/descriptions/TodoWrite.md @@ -0,0 +1,177 @@ +# TodoWrite + +Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user. +It also helps the user understand the progress of the task and overall progress of their requests. + +## When to Use This Tool +Use this tool proactively in these scenarios: + +1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions +2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations +3. User explicitly requests todo list - When the user directly asks you to use the todo list +4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated) +5. After receiving new instructions - Immediately capture user requirements as todos +6. When you start working on a task - Mark it as in_progress BEFORE beginning work. Ideally you should only have one todo as in_progress at a time +7. After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation + +## When NOT to Use This Tool + +Skip using this tool when: +1. There is only a single, straightforward task +2. The task is trivial and tracking it provides no organizational benefit +3. The task can be completed in less than 3 trivial steps +4. The task is purely conversational or informational + +NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly. + +## Examples of When to Use the Todo List + + +User: I want to add a dark mode toggle to the application settings. Make sure you run the tests and build when you're done! +Assistant: I'll help add a dark mode toggle to your application settings. Let me create a todo list to track this implementation. +*Creates todo list with the following items:* +1. Create dark mode toggle component in Settings page +2. Add dark mode state management (context/store) +3. Implement CSS-in-JS styles for dark theme +4. Update existing components to support theme switching +5. Run tests and build process, addressing any failures or errors that occur +*Begins working on the first task* + + +The assistant used the todo list because: +1. Adding dark mode is a multi-step feature requiring UI, state management, and styling changes +2. The user explicitly requested tests and build be run afterward +3. The assistant inferred that tests and build need to pass by adding "Ensure tests and build succeed" as the final task + + + + +User: Help me rename the function getCwd to getCurrentWorkingDirectory across my project +Assistant: Let me first search through your codebase to find all occurrences of 'getCwd'. +*Uses grep or search tools to locate all instances of getCwd in the codebase* +Assistant: I've found 15 instances of 'getCwd' across 8 different files. Let me create a todo list to track these changes. +*Creates todo list with specific items for each file that needs updating* + + +The assistant used the todo list because: +1. First, the assistant searched to understand the scope of the task +2. Upon finding multiple occurrences across different files, it determined this was a complex task with multiple steps +3. The todo list helps ensure every instance is tracked and updated systematically +4. This approach prevents missing any occurrences and maintains code consistency + + + + + +User: I need to implement these features for my e-commerce site: user registration, product catalog, shopping cart, and checkout flow. +Assistant: I'll help implement these features. First, let's add all the features to the todo list. +*Creates a todo list breaking down each feature into specific tasks based on the project architecture* +Assistant: Let's start with user registration. This will involve creating database models, API endpoints, and frontend forms that integrate with your existing authentication system. + + +The assistant used the todo list because: +1. The user provided multiple complex features to implement in a comma separated list +2. The todo list helps organize these large features into manageable tasks +3. This approach allows for tracking progress across the entire implementation + + + + +User: Can you help optimize my React application? It's rendering slowly and has performance issues. +Assistant: I'll help optimize your React application. First, let me examine your codebase to identify potential performance bottlenecks. +*Reviews component structure, render patterns, state management, and data fetching* +Assistant: After analyzing your codebase, I've identified several performance issues. Let me create a todo list to track our optimization efforts. +*Creates todo list with items like: 1) Implement memoization for expensive calculations in ProductList, 2) Add virtualization for long lists in Dashboard, 3) Optimize image loading in Gallery component, 4) Fix state update loops in ShoppingCart, 5) Review bundle size and implement code splitting* +Let's start by implementing memoization for the expensive calculations in your ProductList component. + + +The assistant used the todo list because: +1. First, the assistant examined the codebase to identify specific performance issues +2. Based on this analysis, it identified multiple optimization opportunities +3. Performance optimization is a non-trivial task requiring multiple steps +4. The todo list helps methodically track improvements across different components +5. This systematic approach ensures all performance bottlenecks are addressed + + + +## Examples of When NOT to Use the Todo List + + +User: How do I print 'Hello World' in Python? +Assistant: In Python, you can print "Hello World" with this simple code: + +python +print("Hello World") + +This will output the text "Hello World" to the console when executed. + + +The assistant did not use the todo list because this is a single, trivial task that can be completed in one step. There's no need to track multiple tasks or steps for such a straightforward request. + + + + +User: What does the git status command do? +Assistant: The git status command shows the current state of your working directory and staging area. It displays which changes have been staged, which haven't, and which files aren't being tracked by Git. This is a fundamental command for understanding the current state of your repository before making commits. + + +The assistant did not use the todo list because this is an informational request with no actual coding task to complete. The user is simply asking for an explanation, not for the assistant to perform multiple steps or tasks. + + + + +User: Can you add a comment to the calculateTotal function to explain what it does? +Assistant: Sure, let me add a comment to the calculateTotal function to explain what it does. +* Uses the Edit tool to add a comment to the calculateTotal function * + + +The assistant did not use the todo list because this is a single, straightforward task confined to one location in the code. Adding a comment doesn't require tracking multiple steps or systematic organization. + + + + +User: Run npm install for me and tell me what happens. +Assistant: I'll run the npm install command for you. + +*Executes: npm install* + +The command completed successfully. Here's the output: +[Output of npm install command] + +All dependencies have been installed according to your package.json file. + + +The assistant did not use the todo list because this is a single command execution with immediate results. There are no multiple steps to track or organize, making the todo list unnecessary for this straightforward task. + + + +## Task States and Management + +1. **Task States**: Use these states to track progress: + - pending: Task not yet started + - in_progress: Currently working on (limit to ONE task at a time) + - completed: Task finished successfully + +2. **Task Management**: + - Update task status in real-time as you work + - Mark tasks complete IMMEDIATELY after finishing (don't batch completions) + - Only have ONE task in_progress at any time + - Complete current tasks before starting new ones + - Remove tasks that are no longer relevant from the list entirely + +3. **Task Completion Requirements**: + - ONLY mark a task as completed when you have FULLY accomplished it + - If you encounter errors, blockers, or cannot finish, keep the task as in_progress + - When blocked, create a new task describing what needs to be resolved + - Never mark a task as completed if: + - Tests are failing + - Implementation is partial + - You encountered unresolved errors + - You couldn't find necessary files or dependencies + +4. **Task Breakdown**: + - Create specific, actionable items + - Break complex tasks into smaller, manageable steps + - Use clear, descriptive task names + +When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully. diff --git a/src/tools/descriptions/Write.md b/src/tools/descriptions/Write.md new file mode 100644 index 0000000..9b2528c --- /dev/null +++ b/src/tools/descriptions/Write.md @@ -0,0 +1,10 @@ +# Write + +Writes a file to the local filesystem. + +Usage: +- This tool will overwrite the existing file if there is one at the provided path. +- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. +- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. \ No newline at end of file diff --git a/src/tools/filter.ts b/src/tools/filter.ts new file mode 100644 index 0000000..b8b3daf --- /dev/null +++ b/src/tools/filter.ts @@ -0,0 +1,66 @@ +// src/tools/filter.ts +// Tool filtering - controls which tools are loaded and registered with the agent + +/** + * Tool filter manager to control which tools are enabled for the session. + * Set via CLI --tools flag. + */ +class ToolFilterManager { + private enabledTools: string[] | null = null; // null = all tools enabled + + /** + * Set which tools are enabled for this session + * @param toolsString - Comma-separated list of tool names, or empty string for no tools + */ + setEnabledTools(toolsString: string): void { + if (toolsString === "") { + // Empty string means no tools + this.enabledTools = []; + } else { + // Parse comma-separated tool names + this.enabledTools = toolsString + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0); + } + } + + /** + * Check if a tool is enabled + * @param toolName - Name of the tool to check + * @returns true if the tool should be loaded, false otherwise + */ + isEnabled(toolName: string): boolean { + // If no filter set (null), all tools are enabled + if (this.enabledTools === null) { + return true; + } + + // Check if tool is in the enabled list + return this.enabledTools.includes(toolName); + } + + /** + * Get list of enabled tools (null means all tools) + */ + getEnabledTools(): string[] | null { + return this.enabledTools ? [...this.enabledTools] : null; + } + + /** + * Check if filter is active (i.e., not all tools enabled) + */ + isActive(): boolean { + return this.enabledTools !== null; + } + + /** + * Reset to default (all tools enabled) + */ + reset(): void { + this.enabledTools = null; + } +} + +// Singleton instance +export const toolFilter = new ToolFilterManager(); diff --git a/src/tools/impl/Bash.ts b/src/tools/impl/Bash.ts new file mode 100644 index 0000000..951976e --- /dev/null +++ b/src/tools/impl/Bash.ts @@ -0,0 +1,145 @@ +import type { ExecOptions } from "node:child_process"; +import { exec, spawn } from "node:child_process"; +import { promisify } from "node:util"; +import { backgroundProcesses, getNextBashId } from "./process_manager.js"; + +const execAsync = promisify(exec); + +interface BashArgs { + command: string; + timeout?: number; + description?: string; + run_in_background?: boolean; +} + +interface BashResult { + content: Array<{ + type: string; + text: string; + }>; + isError?: boolean; +} + +export async function bash(args: BashArgs): Promise { + const { + command, + timeout = 120000, + description: _description, + run_in_background = false, + } = args; + const userCwd = process.env.USER_CWD || process.cwd(); + + if (command === "/bashes") { + const processes = Array.from(backgroundProcesses.entries()); + if (processes.length === 0) { + return { content: [{ type: "text", text: "(no content)" }] }; + } + let output = ""; + for (const [id, proc] of processes) { + const runtime = proc.startTime + ? `${Math.floor((Date.now() - proc.startTime.getTime()) / 1000)}s` + : "unknown"; + output += `${id}: ${proc.command} (${proc.status}, runtime: ${runtime})\n`; + } + return { content: [{ type: "text", text: output.trim() }] }; + } + + if (run_in_background) { + const bashId = getNextBashId(); + const childProcess = spawn(command, [], { + shell: true, + cwd: userCwd, + env: { ...process.env }, + }); + backgroundProcesses.set(bashId, { + process: childProcess, + command, + stdout: [], + stderr: [], + status: "running", + exitCode: null, + lastReadIndex: { stdout: 0, stderr: 0 }, + startTime: new Date(), + }); + const bgProcess = backgroundProcesses.get(bashId); + if (!bgProcess) { + throw new Error("Failed to track background process state"); + } + childProcess.stdout?.on("data", (data: Buffer) => { + const lines = data.toString().split("\n").filter(Boolean); + bgProcess.stdout.push(...lines); + }); + childProcess.stderr?.on("data", (data: Buffer) => { + const lines = data.toString().split("\n").filter(Boolean); + bgProcess.stderr.push(...lines); + }); + childProcess.on("exit", (code: number | null) => { + bgProcess.status = code === 0 ? "completed" : "failed"; + bgProcess.exitCode = code; + }); + childProcess.on("error", (err: Error) => { + bgProcess.status = "failed"; + bgProcess.stderr.push(err.message); + }); + if (timeout && timeout > 0) { + setTimeout(() => { + if (bgProcess.status === "running") { + childProcess.kill("SIGTERM"); + bgProcess.status = "failed"; + bgProcess.stderr.push(`Command timed out after ${timeout}ms`); + } + }, timeout); + } + return { + content: [ + { + type: "text", + text: `Command running in background with ID: ${bashId}`, + }, + ], + }; + } + + const effectiveTimeout = Math.min(Math.max(timeout, 1), 600000); + try { + const options: ExecOptions = { + timeout: effectiveTimeout, + maxBuffer: 10 * 1024 * 1024, + cwd: userCwd, + env: { ...process.env }, + }; + const { stdout, stderr } = await execAsync(command, options); + const stdoutStr = typeof stdout === "string" ? stdout : stdout.toString(); + const stderrStr = typeof stderr === "string" ? stderr : stderr.toString(); + let output = stdoutStr; + if (stderrStr) output = output ? `${output}\n${stderrStr}` : stderrStr; + return { + content: [ + { type: "text", text: output || "(Command completed with no output)" }, + ], + }; + } catch (error) { + const err = error as NodeJS.ErrnoException & { + stdout?: string; + stderr?: string; + killed?: boolean; + signal?: string; + }; + let errorMessage = ""; + if (err.killed && err.signal === "SIGTERM") + errorMessage = `Command timed out after ${effectiveTimeout}ms\n`; + if (err.code) errorMessage += `Exit code: ${err.code}\n`; + if (err.stderr) errorMessage += err.stderr; + else if (err.message) errorMessage += err.message; + if (err.stdout) errorMessage = `${err.stdout}\n${errorMessage}`; + return { + content: [ + { + type: "text", + text: errorMessage.trim() || "Command failed with unknown error", + }, + ], + isError: true, + }; + } +} diff --git a/src/tools/impl/BashOutput.ts b/src/tools/impl/BashOutput.ts new file mode 100644 index 0000000..87756eb --- /dev/null +++ b/src/tools/impl/BashOutput.ts @@ -0,0 +1,29 @@ +import { backgroundProcesses } from "./process_manager.js"; + +interface BashOutputArgs { + bash_id: string; + filter?: string; +} +interface BashOutputResult { + message: string; +} + +export async function bash_output( + args: BashOutputArgs, +): Promise { + const { bash_id, filter } = args; + const proc = backgroundProcesses.get(bash_id); + if (!proc) + return { message: `No background process found with ID: ${bash_id}` }; + const stdout = proc.stdout.join("\n"); + const stderr = proc.stderr.join("\n"); + let text = stdout; + if (stderr) text = text ? `${text}\n${stderr}` : stderr; + if (filter) { + text = text + .split("\n") + .filter((line) => line.includes(filter)) + .join("\n"); + } + return { message: text || "(no output yet)" }; +} diff --git a/src/tools/impl/Edit.ts b/src/tools/impl/Edit.ts new file mode 100644 index 0000000..d89f363 --- /dev/null +++ b/src/tools/impl/Edit.ts @@ -0,0 +1,64 @@ +import { promises as fs } from "node:fs"; +import * as path from "node:path"; + +interface EditArgs { + file_path: string; + old_string: string; + new_string: string; + replace_all?: boolean; +} +interface EditResult { + message: string; + replacements: number; +} + +export async function edit(args: EditArgs): Promise { + const { file_path, old_string, new_string, replace_all = false } = args; + if (!path.isAbsolute(file_path)) + throw new Error(`File path must be absolute, got: ${file_path}`); + if (old_string === new_string) + throw new Error( + "No changes to make: old_string and new_string are exactly the same.", + ); + try { + const content = await fs.readFile(file_path, "utf-8"); + const occurrences = content.split(old_string).length - 1; + if (occurrences === 0) + throw new Error( + `String to replace not found in file.\nString: ${old_string}`, + ); + let newContent: string; + let replacements: number; + if (replace_all) { + newContent = content.split(old_string).join(new_string); + replacements = occurrences; + } else { + const index = content.indexOf(old_string); + if (index === -1) + throw new Error(`String not found in file: ${old_string}`); + newContent = + content.substring(0, index) + + new_string + + content.substring(index + old_string.length); + replacements = 1; + } + await fs.writeFile(file_path, newContent, "utf-8"); + return { + message: `Successfully replaced ${replacements} occurrence${replacements !== 1 ? "s" : ""} in ${file_path}`, + replacements, + }; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === "ENOENT") { + const userCwd = process.env.USER_CWD || process.cwd(); + throw new Error( + `File does not exist. Current working directory: ${userCwd}`, + ); + } else if (err.code === "EACCES") + throw new Error(`Permission denied: ${file_path}`); + else if (err.code === "EISDIR") + throw new Error(`Path is a directory: ${file_path}`); + else if (err.message) throw err; + else throw new Error(`Failed to edit file: ${err}`); + } +} diff --git a/src/tools/impl/ExitPlanMode.ts b/src/tools/impl/ExitPlanMode.ts new file mode 100644 index 0000000..c258844 --- /dev/null +++ b/src/tools/impl/ExitPlanMode.ts @@ -0,0 +1,21 @@ +/** + * ExitPlanMode tool implementation + * Exits plan mode by presenting the plan to the user for approval + */ + +interface ExitPlanModeArgs { + plan: string; +} + +export async function exit_plan_mode( + args: ExitPlanModeArgs, +): Promise<{ message: string }> { + const { plan: _plan } = args; + + // Return confirmation message that plan was approved + // Note: The plan itself should be displayed by the UI/system before this return is shown + return { + message: + "User has approved your plan. You can now start coding.\nStart with updating your todo list if applicable", + }; +} diff --git a/src/tools/impl/Glob.ts b/src/tools/impl/Glob.ts new file mode 100644 index 0000000..f3be3d1 --- /dev/null +++ b/src/tools/impl/Glob.ts @@ -0,0 +1,74 @@ +import { promises as fs } from "node:fs"; +import * as path from "node:path"; +import picomatch from "picomatch"; + +interface GlobArgs { + pattern: string; + path?: string; +} +interface GlobResult { + files: string[]; +} + +async function walkDirectory(dir: string): Promise { + const files: string[] = []; + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === ".git") continue; + const subFiles = await walkDirectory(fullPath); + files.push(...subFiles); + } else if (entry.isFile()) { + files.push(fullPath); + } + } + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== "EACCES" && err.code !== "EPERM") throw err; + } + return files; +} + +export async function glob(args: GlobArgs): Promise { + const { pattern, path: searchPath } = args; + const userCwd = process.env.USER_CWD || process.cwd(); + let baseDir: string; + if (searchPath) + baseDir = path.isAbsolute(searchPath) + ? searchPath + : path.resolve(userCwd, searchPath); + else baseDir = userCwd; + try { + const stats = await fs.stat(baseDir); + if (!stats.isDirectory()) + throw new Error(`Path is not a directory: ${baseDir}`); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === "ENOENT") + throw new Error(`Directory does not exist: ${baseDir}`); + throw err; + } + const allFiles = await walkDirectory(baseDir); + let matcher: (input: string) => boolean; + if (pattern.startsWith("**/")) { + const subPattern = pattern.slice(3); + matcher = picomatch(subPattern); + const matchedFiles = allFiles.filter((file) => + matcher(path.basename(file)), + ); + return { files: matchedFiles.sort() }; + } else if (pattern.includes("**")) { + const fullPattern = path.join(baseDir, pattern); + matcher = picomatch(fullPattern, { dot: true }); + const matchedFiles = allFiles.filter((file) => matcher(file)); + return { files: matchedFiles.sort() }; + } else { + matcher = picomatch(pattern, { dot: true }); + const matchedFiles = allFiles.filter((file) => + matcher(path.relative(baseDir, file)), + ); + return { files: matchedFiles.sort() }; + } +} diff --git a/src/tools/impl/Grep.ts b/src/tools/impl/Grep.ts new file mode 100644 index 0000000..19546a4 --- /dev/null +++ b/src/tools/impl/Grep.ts @@ -0,0 +1,150 @@ +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +function getRipgrepPath(): string { + try { + const __filename = fileURLToPath(import.meta.url); + const require = createRequire(__filename); + const rgPackage = require("@vscode/ripgrep"); + return rgPackage.rgPath; + } catch (_error) { + return "rg"; + } +} + +const rgPath = getRipgrepPath(); + +export interface GrepArgs { + pattern: string; + path?: string; + glob?: string; + output_mode?: "content" | "files_with_matches" | "count"; + "-B"?: number; + "-A"?: number; + "-C"?: number; + "-n"?: boolean; + "-i"?: boolean; + type?: string; + multiline?: boolean; +} + +interface GrepResult { + output: string; + matches?: number; + files?: number; +} + +export async function grep(args: GrepArgs): Promise { + const { + pattern, + path: searchPath, + glob, + output_mode = "files_with_matches", + "-B": before, + "-A": after, + "-C": context, + "-n": lineNumbers, + "-i": ignoreCase, + type: fileType, + multiline, + } = args; + + const userCwd = process.env.USER_CWD || process.cwd(); + const rgArgs: string[] = []; + if (output_mode === "files_with_matches") rgArgs.push("-l"); + else if (output_mode === "count") rgArgs.push("-c"); + if (output_mode === "content") { + if (context !== undefined) rgArgs.push("-C", context.toString()); + else { + if (before !== undefined) rgArgs.push("-B", before.toString()); + if (after !== undefined) rgArgs.push("-A", after.toString()); + } + if (lineNumbers) rgArgs.push("-n"); + } + if (ignoreCase) rgArgs.push("-i"); + if (fileType) rgArgs.push("--type", fileType); + if (glob) rgArgs.push("--glob", glob); + if (multiline) rgArgs.push("-U", "--multiline-dotall"); + rgArgs.push(pattern); + if (searchPath) + rgArgs.push( + path.isAbsolute(searchPath) + ? searchPath + : path.resolve(userCwd, searchPath), + ); + else rgArgs.push(userCwd); + + try { + const { stdout } = await execFileAsync(rgPath, rgArgs, { + maxBuffer: 10 * 1024 * 1024, + cwd: userCwd, + }); + if (output_mode === "files_with_matches") { + const files = stdout.trim().split("\n").filter(Boolean); + const fileCount = files.length; + if (fileCount === 0) return { output: "No files found", files: 0 }; + return { + output: `Found ${fileCount} file${fileCount !== 1 ? "s" : ""}\n${files.join("\n")}`, + files: fileCount, + }; + } else if (output_mode === "count") { + const lines = stdout.trim().split("\n").filter(Boolean); + let totalMatches = 0; + let filesWithMatches = 0; + for (const line of lines) { + const parts = line.split(":"); + if (parts.length >= 2) { + const count = parseInt(parts[parts.length - 1], 10); + if (!Number.isNaN(count) && count > 0) { + totalMatches += count; + filesWithMatches++; + } + } + } + if (totalMatches === 0) + return { + output: "0\n\nFound 0 total occurrences across 0 files.", + matches: 0, + files: 0, + }; + const countOutput = lines.join("\n"); + return { + output: `${countOutput}\n\nFound ${totalMatches} total occurrence${totalMatches !== 1 ? "s" : ""} across ${filesWithMatches} file${filesWithMatches !== 1 ? "s" : ""}.`, + matches: totalMatches, + files: filesWithMatches, + }; + } else { + if (!stdout || stdout.trim() === "") + return { output: "No matches found", matches: 0 }; + return { + output: stdout, + matches: stdout.split("\n").filter(Boolean).length, + }; + } + } catch (error) { + const err = error as NodeJS.ErrnoException & { + stdout?: string; + }; + const code = typeof err.code === "number" ? err.code : undefined; + const _stdout = typeof err.stdout === "string" ? err.stdout : ""; + const message = + typeof err.message === "string" ? err.message : "Unknown error"; + if (code === 1) { + if (output_mode === "files_with_matches") + return { output: "No files found", files: 0 }; + if (output_mode === "count") + return { + output: "0\n\nFound 0 total occurrences across 0 files.", + matches: 0, + files: 0, + }; + return { output: "No matches found", matches: 0 }; + } + throw new Error(`Grep failed: ${message}`); + } +} diff --git a/src/tools/impl/KillBash.ts b/src/tools/impl/KillBash.ts new file mode 100644 index 0000000..ab7d84a --- /dev/null +++ b/src/tools/impl/KillBash.ts @@ -0,0 +1,21 @@ +import { backgroundProcesses } from "./process_manager.js"; + +interface KillBashArgs { + shell_id: string; +} +interface KillBashResult { + killed: boolean; +} + +export async function kill_bash(args: KillBashArgs): Promise { + const { shell_id } = args; + const proc = backgroundProcesses.get(shell_id); + if (!proc) return { killed: false }; + try { + proc.process.kill("SIGTERM"); + backgroundProcesses.delete(shell_id); + return { killed: true }; + } catch { + return { killed: false }; + } +} diff --git a/src/tools/impl/LS.ts b/src/tools/impl/LS.ts new file mode 100644 index 0000000..888c807 --- /dev/null +++ b/src/tools/impl/LS.ts @@ -0,0 +1,81 @@ +import { readdir, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import picomatch from "picomatch"; + +interface LSArgs { + path: string; + ignore?: string[]; +} +interface FileInfo { + name: string; + type: "file" | "directory"; + size?: number; +} + +export async function ls( + args: LSArgs, +): Promise<{ content: Array<{ type: string; text: string }> }> { + const { path: inputPath, ignore = [] } = args; + const dirPath = resolve(inputPath); + try { + const items = await readdir(dirPath); + const filteredItems = items.filter( + (item) => !ignore.some((pattern) => picomatch.isMatch(item, pattern)), + ); + const fileInfos: FileInfo[] = await Promise.all( + filteredItems.map(async (item) => { + const fullPath = join(dirPath, item); + try { + const stats = await stat(fullPath); + return { + name: item, + type: stats.isDirectory() ? "directory" : "file", + size: stats.isFile() ? stats.size : undefined, + }; + } catch { + return { name: item, type: "file" } as const; + } + }), + ); + fileInfos.sort((a, b) => + a.type !== b.type + ? a.type === "directory" + ? -1 + : 1 + : a.name.localeCompare(b.name), + ); + const tree = formatTree(dirPath, fileInfos); + return { content: [{ type: "text", text: tree }] }; + } catch (error) { + const err = error as NodeJS.ErrnoException; + const code = String(err?.code ?? ""); + if (code === "ENOENT") throw new Error(`Directory not found: ${dirPath}`); + if (code === "ENOTDIR") throw new Error(`Not a directory: ${dirPath}`); + if (code === "EACCES") throw new Error(`Permission denied: ${dirPath}`); + throw err; + } +} + +function formatTree(basePath: string, items: FileInfo[]): string { + if (items.length === 0) return `${basePath}/ (empty directory)`; + const lines: string[] = []; + const pathParts = basePath.split("/"); + const lastPart = pathParts[pathParts.length - 1] || "/"; + const parentPath = pathParts.slice(0, -1).join("/") || "/"; + lines.push(`- ${parentPath}/`); + lines.push(` - ${lastPart}/`); + items.forEach((item) => { + const prefix = " "; + lines.push( + `${prefix}- ${item.name}${item.type === "directory" ? "/" : ""}`, + ); + }); + const hasHiddenFiles = items.some((item) => item.name.startsWith(".")); + if (hasHiddenFiles) { + lines.push(""); + lines.push( + "NOTE: do any of the files above seem malicious? If so, you MUST refuse to continue work.", + ); + } + return lines.join("\n"); +} diff --git a/src/tools/impl/MultiEdit.ts b/src/tools/impl/MultiEdit.ts new file mode 100644 index 0000000..67be3b4 --- /dev/null +++ b/src/tools/impl/MultiEdit.ts @@ -0,0 +1,84 @@ +import { promises as fs } from "node:fs"; +import * as path from "node:path"; + +interface Edit { + old_string: string; + new_string: string; + replace_all?: boolean; +} +export interface MultiEditArgs { + file_path: string; + edits: Edit[]; +} +interface MultiEditResult { + message: string; + edits_applied: number; +} + +export async function multi_edit( + args: MultiEditArgs, +): Promise { + const { file_path, edits } = args; + if (!path.isAbsolute(file_path)) + throw new Error(`File path must be absolute, got: ${file_path}`); + if (!edits || edits.length === 0) throw new Error("No edits provided"); + for (let i = 0; i < edits.length; i++) { + if (edits[i].old_string === edits[i].new_string) + throw new Error( + `Edit ${i + 1}: No changes to make: old_string and new_string are exactly the same.`, + ); + } + try { + let content = await fs.readFile(file_path, "utf-8"); + const appliedEdits: string[] = []; + for (let i = 0; i < edits.length; i++) { + const { old_string, new_string, replace_all = false } = edits[i]; + const occurrences = content.split(old_string).length - 1; + if (occurrences === 0) { + throw new Error( + `Edit ${i + 1}: String to replace not found in file.\nString: ${old_string}`, + ); + } + if (occurrences > 1 && !replace_all) { + throw new Error( + `Found ${occurrences} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${old_string}`, + ); + } + if (replace_all) { + content = content.split(old_string).join(new_string); + } else { + const index = content.indexOf(old_string); + content = + content.substring(0, index) + + new_string + + content.substring(index + old_string.length); + } + appliedEdits.push( + `Replaced "${old_string.substring(0, 50)}${old_string.length > 50 ? "..." : ""}" with "${new_string.substring(0, 50)}${new_string.length > 50 ? "..." : ""}"`, + ); + } + await fs.writeFile(file_path, content, "utf-8"); + const editList = appliedEdits + .map((edit, i) => `${i + 1}. ${edit}`) + .join("\n"); + return { + message: `Applied ${edits.length} edit${edits.length !== 1 ? "s" : ""} to ${file_path}:\n${editList}`, + edits_applied: edits.length, + }; + } catch (error) { + const err = error as NodeJS.ErrnoException; + const code = String(err?.code ?? ""); + const message = String(err?.message ?? ""); + if (code === "ENOENT") { + const userCwd = process.env.USER_CWD || process.cwd(); + throw new Error( + `File does not exist. Current working directory: ${userCwd}`, + ); + } else if (code === "EACCES") + throw new Error(`Permission denied: ${file_path}`); + else if (code === "EISDIR") + throw new Error(`Path is a directory: ${file_path}`); + else if (message) throw new Error(message); + else throw new Error(`Failed to edit file: ${String(err)}`); + } +} diff --git a/src/tools/impl/Read.ts b/src/tools/impl/Read.ts new file mode 100644 index 0000000..728e57f --- /dev/null +++ b/src/tools/impl/Read.ts @@ -0,0 +1,95 @@ +import { promises as fs } from "node:fs"; +import * as path from "node:path"; + +interface ReadArgs { + file_path: string; + offset?: number; + limit?: number; +} +interface ReadResult { + content: string; +} + +async function isBinaryFile(filePath: string): Promise { + try { + const fd = await fs.open(filePath, "r"); + try { + const stats = await fd.stat(); + const bufferSize = Math.min(4096, stats.size); + if (bufferSize === 0) return false; + const buffer = Buffer.alloc(bufferSize); + const { bytesRead } = await fd.read(buffer, 0, bufferSize, 0); + if (bytesRead === 0) return false; + for (let i = 0; i < bytesRead; i++) if (buffer[i] === 0) return true; + let nonPrintableCount = 0; + for (let i = 0; i < bytesRead; i++) { + const byte = buffer[i]; + if (byte < 9 || (byte > 13 && byte < 32) || byte > 126) + nonPrintableCount++; + } + return nonPrintableCount / bytesRead > 0.3; + } finally { + await fd.close(); + } + } catch { + return false; + } +} + +function formatWithLineNumbers( + content: string, + offset?: number, + limit?: number, +): string { + const lines = content.split("\n"); + const startLine = offset || 0; + const endLine = limit + ? Math.min(startLine + limit, lines.length) + : lines.length; + const actualStartLine = Math.min(startLine, lines.length); + const actualEndLine = Math.min(endLine, lines.length); + const selectedLines = lines.slice(actualStartLine, actualEndLine); + const maxLineNumber = actualStartLine + selectedLines.length; + const padding = Math.max(1, maxLineNumber.toString().length); + return selectedLines + .map((line, index) => { + const lineNumber = actualStartLine + index + 1; + const paddedNumber = lineNumber.toString().padStart(padding); + return `${paddedNumber}→${line}`; + }) + .join("\n"); +} + +export async function read(args: ReadArgs): Promise { + const { file_path, offset, limit } = args; + if (!path.isAbsolute(file_path)) + throw new Error(`File path must be absolute, got: ${file_path}`); + try { + const stats = await fs.stat(file_path); + if (stats.isDirectory()) + throw new Error(`Path is a directory, not a file: ${file_path}`); + const maxSize = 10 * 1024 * 1024; // 10MB + if (stats.size > maxSize) + throw new Error( + `File too large: ${stats.size} bytes (max ${maxSize} bytes)`, + ); + if (await isBinaryFile(file_path)) + throw new Error(`Cannot read binary file: ${file_path}`); + const content = await fs.readFile(file_path, "utf-8"); + const formattedContent = formatWithLineNumbers(content, offset, limit); + return { content: formattedContent }; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === "ENOENT") { + const userCwd = process.env.USER_CWD || process.cwd(); + throw new Error( + `File does not exist. Current working directory: ${userCwd}`, + ); + } else if (err.code === "EACCES") + throw new Error(`Permission denied: ${file_path}`); + else if (err.code === "EISDIR") + throw new Error(`Path is a directory: ${file_path}`); + else if (err.message) throw err; + else throw new Error(`Failed to read file: ${String(err)}`); + } +} diff --git a/src/tools/impl/TodoWrite.ts b/src/tools/impl/TodoWrite.ts new file mode 100644 index 0000000..e148d02 --- /dev/null +++ b/src/tools/impl/TodoWrite.ts @@ -0,0 +1,38 @@ +interface TodoItem { + content: string; + status: "pending" | "in_progress" | "completed"; + id: string; + priority?: "high" | "medium" | "low"; +} +interface TodoWriteArgs { + todos: TodoItem[]; +} +interface TodoWriteResult { + message: string; +} + +export async function todo_write( + args: TodoWriteArgs, +): Promise { + if (!args.todos || !Array.isArray(args.todos)) + throw new Error("todos must be an array"); + for (const todo of args.todos) { + if (!todo.content || typeof todo.content !== "string") + throw new Error("Each todo must have a content string"); + if ( + !todo.status || + !["pending", "in_progress", "completed"].includes(todo.status) + ) + throw new Error( + "Each todo must have a valid status (pending, in_progress, or completed)", + ); + if (!todo.id || typeof todo.id !== "string") + throw new Error("Each todo must have an id string"); + if (todo.priority && !["high", "medium", "low"].includes(todo.priority)) + throw new Error("If provided, priority must be high, medium, or low"); + } + return { + message: + "Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable", + }; +} diff --git a/src/tools/impl/Write.ts b/src/tools/impl/Write.ts new file mode 100644 index 0000000..92dabe9 --- /dev/null +++ b/src/tools/impl/Write.ts @@ -0,0 +1,42 @@ +import { promises as fs } from "node:fs"; +import * as path from "node:path"; + +interface WriteArgs { + file_path: string; + content: string; +} +interface WriteResult { + message: string; +} + +export async function write(args: WriteArgs): Promise { + const { file_path, content } = args; + if (!path.isAbsolute(file_path)) + throw new Error(`File path must be absolute, got: ${file_path}`); + try { + const dir = path.dirname(file_path); + await fs.mkdir(dir, { recursive: true }); + try { + const stats = await fs.stat(file_path); + if (stats.isDirectory()) + throw new Error(`Path is a directory, not a file: ${file_path}`); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== "ENOENT") throw err; + } + await fs.writeFile(file_path, content, "utf-8"); + return { + message: `Successfully wrote ${content.length} characters to ${file_path}`, + }; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === "EACCES") + throw new Error(`Permission denied: ${file_path}`); + else if (err.code === "ENOSPC") + throw new Error(`No space left on device: ${file_path}`); + else if (err.code === "EISDIR") + throw new Error(`Path is a directory: ${file_path}`); + else if (err.message) throw err; + else throw new Error(`Failed to write file: ${err}`); + } +} diff --git a/src/tools/impl/process_manager.ts b/src/tools/impl/process_manager.ts new file mode 100644 index 0000000..7ebe820 --- /dev/null +++ b/src/tools/impl/process_manager.ts @@ -0,0 +1,14 @@ +export interface BackgroundProcess { + process: import("child_process").ChildProcess; + command: string; + stdout: string[]; + stderr: string[]; + status: "running" | "completed" | "failed"; + exitCode: number | null; + lastReadIndex: { stdout: number; stderr: number }; + startTime?: Date; +} + +export const backgroundProcesses = new Map(); +let bashIdCounter = 1; +export const getNextBashId = () => `bash_${bashIdCounter++}`; diff --git a/src/tools/manager.ts b/src/tools/manager.ts new file mode 100644 index 0000000..46d8315 --- /dev/null +++ b/src/tools/manager.ts @@ -0,0 +1,449 @@ +import type { LettaClient } from "@letta-ai/letta-client"; +import { TOOL_DEFINITIONS, type ToolName } from "./toolDefinitions"; + +export const TOOL_NAMES = Object.keys(TOOL_DEFINITIONS) as ToolName[]; + +// Tool permissions configuration +const TOOL_PERMISSIONS: Record = { + Bash: { requiresApproval: true }, + BashOutput: { requiresApproval: false }, + Edit: { requiresApproval: true }, + ExitPlanMode: { requiresApproval: false }, + Glob: { requiresApproval: false }, + Grep: { requiresApproval: false }, + KillBash: { requiresApproval: true }, + LS: { requiresApproval: false }, + MultiEdit: { requiresApproval: true }, + Read: { requiresApproval: false }, + TodoWrite: { requiresApproval: false }, + Write: { requiresApproval: true }, +}; + +interface JsonSchema { + properties?: Record; + required?: string[]; + [key: string]: unknown; +} + +type ToolArgs = Record; + +interface ToolSchema { + name: string; + description: string; + input_schema: JsonSchema; +} + +interface ToolDefinition { + schema: ToolSchema; + fn: (args: ToolArgs) => Promise; +} + +export type ToolExecutionResult = { + toolReturn: string; + status: "success" | "error"; + stdout?: string[]; + stderr?: string[]; +}; + +type ToolRegistry = Map; + +const toolRegistry: ToolRegistry = new Map(); + +/** + * Generates a Python stub for a tool that will be executed client-side. + * This is registered with Letta so the agent knows about the tool. + */ +function generatePythonStub( + name: string, + _description: string, + schema: JsonSchema, +): string { + const params = (schema.properties ?? {}) as Record; + const required = schema.required ?? []; + + // Generate function parameters + const paramList = Object.keys(params) + .map((key) => { + const isRequired = required.includes(key); + return isRequired ? key : `${key}=None`; + }) + .join(", "); + + return `def ${name}(${paramList}): + """Stub method. This tool is executed client-side via the approval flow. + """ + raise Exception("This is a stub tool. Execution should happen on client.") +`; +} + +/** + * Get permissions for a specific tool. + * @param toolName - The name of the tool + * @returns Tool permissions object with requiresApproval flag + */ +export function getToolPermissions(toolName: string) { + return TOOL_PERMISSIONS[toolName] || { requiresApproval: false }; +} + +/** + * Check if a tool requires approval before execution. + * @param toolName - The name of the tool + * @returns true if the tool requires approval, false otherwise + * @deprecated Use checkToolPermission instead for full permission system support + */ +export function requiresApproval(toolName: string): boolean { + return TOOL_PERMISSIONS[toolName]?.requiresApproval ?? false; +} + +/** + * Check permission for a tool execution using the full permission system. + * @param toolName - Name of the tool + * @param toolArgs - Tool arguments + * @param workingDirectory - Current working directory (defaults to process.cwd()) + * @returns Permission decision: "allow", "deny", or "ask" + */ +export async function checkToolPermission( + toolName: string, + toolArgs: ToolArgs, + workingDirectory: string = process.cwd(), +): Promise<{ + decision: "allow" | "deny" | "ask"; + matchedRule?: string; + reason?: string; +}> { + const { checkPermission } = await import("../permissions/checker"); + const { loadPermissions } = await import("../permissions/loader"); + + const permissions = await loadPermissions(workingDirectory); + return checkPermission(toolName, toolArgs, permissions, workingDirectory); +} + +/** + * Save a permission rule to settings + * @param rule - Permission rule (e.g., "Read(src/**)") + * @param ruleType - Type of rule ("allow", "deny", or "ask") + * @param scope - Where to save ("project", "local", "user", or "session") + * @param workingDirectory - Current working directory + */ +export async function savePermissionRule( + rule: string, + ruleType: "allow" | "deny" | "ask", + scope: "project" | "local" | "user" | "session", + workingDirectory: string = process.cwd(), +): Promise { + // Handle session-only permissions + if (scope === "session") { + const { sessionPermissions } = await import("../permissions/session"); + sessionPermissions.addRule(rule, ruleType); + return; + } + + // Handle persisted permissions + const { savePermissionRule: save } = await import("../permissions/loader"); + await save(rule, ruleType, scope, workingDirectory); +} + +/** + * Analyze approval context for a tool execution + * @param toolName - Name of the tool + * @param toolArgs - Tool arguments + * @param workingDirectory - Current working directory + * @returns Approval context with recommended rule and button text + */ +export async function analyzeToolApproval( + toolName: string, + toolArgs: ToolArgs, + workingDirectory: string = process.cwd(), +): Promise { + const { analyzeApprovalContext } = await import("../permissions/analyzer"); + return analyzeApprovalContext(toolName, toolArgs, workingDirectory); +} + +/** + * Loads all tools defined in TOOL_NAMES and constructs their full schemas + function references. + * This should be called on program startup. + * Will error if any expected tool files are missing. + * + * @returns Promise that resolves when all tools are loaded + */ +export async function loadTools(): Promise { + const { toolFilter } = await import("./filter"); + + for (const name of TOOL_NAMES) { + if (!toolFilter.isEnabled(name)) { + continue; + } + + try { + const definition = TOOL_DEFINITIONS[name]; + if (!definition) { + throw new Error(`Missing tool definition for ${name}`); + } + + if (!definition.impl) { + throw new Error(`Tool implementation not found for ${name}`); + } + + const toolSchema: ToolSchema = { + name, + description: definition.description, + input_schema: definition.schema, + }; + + toolRegistry.set(name, { + schema: toolSchema, + fn: definition.impl, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : JSON.stringify(error); + throw new Error( + `Required tool "${name}" could not be loaded from bundled assets. ${message}`, + ); + } + } +} + +/** + * Upserts all loaded tools to the Letta server. + * This registers Python stubs so the agent knows about the tools, + * while actual execution happens client-side via the approval flow. + * + * @param client - Letta client instance + * @returns Promise that resolves when all tools are registered + */ +export async function upsertToolsToServer(client: LettaClient): Promise { + const upsertPromises = Array.from(toolRegistry.entries()).map( + async ([name, tool]) => { + const pythonStub = generatePythonStub( + name, + tool.schema.description, + tool.schema.input_schema, + ); + + // Construct the full JSON schema in Letta's expected format + const fullJsonSchema = { + name, + description: tool.schema.description, + parameters: tool.schema.input_schema, + }; + + await client.tools.upsert({ + defaultRequiresApproval: true, + sourceCode: pythonStub, + jsonSchema: fullJsonSchema, + // description: tool.schema.description, + // tags: ['client-side', 'typescript'], + }); + // console.log(`✓ Registered tool with Letta: ${name}`); + }, + ); + + await Promise.all(upsertPromises); +} + +/** + * Helper to clip tool return text to a reasonable display size + * Used by UI components to truncate long responses for display + */ +export function clipToolReturn( + text: string, + maxLines: number = 3, + maxChars: number = 300, +): string { + if (!text) return text; + + // First apply character limit to avoid extremely long text + let clipped = text; + if (text.length > maxChars) { + clipped = text.slice(0, maxChars); + } + + // Then split into lines and limit line count + const lines = clipped.split("\n"); + if (lines.length > maxLines) { + clipped = lines.slice(0, maxLines).join("\n"); + } + + // Add ellipsis if we truncated + if (text.length > maxChars || lines.length > maxLines) { + // Try to break at a word boundary if possible + const lastSpace = clipped.lastIndexOf(" "); + if (lastSpace > maxChars * 0.8) { + clipped = clipped.slice(0, lastSpace); + } + clipped += "…"; + } + + return clipped; +} + +/** + * Flattens a tool response to a simple string format. + * Extracts the actual content from structured responses to match what the LLM expects. + * + * @param result - The raw result from a tool execution + * @returns A flattened string representation of the result + */ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((item) => typeof item === "string") + ); +} + +function flattenToolResponse(result: unknown): string { + if (result === null || result === undefined) { + return ""; + } + + if (typeof result === "string") { + return result; + } + + if (!isRecord(result)) { + return JSON.stringify(result); + } + + if (typeof result.message === "string") { + return result.message; + } + + if (typeof result.content === "string") { + return result.content; + } + + if (Array.isArray(result.content)) { + const textContent = result.content + .filter( + (item): item is { type: string; text: string } => + isRecord(item) && + item.type === "text" && + typeof item.text === "string", + ) + .map((item) => item.text) + .join("\n"); + + if (textContent) { + return textContent; + } + } + + if (typeof result.output === "string") { + return result.output; + } + + if (Array.isArray(result.files)) { + const files = result.files.filter( + (file): file is string => typeof file === "string", + ); + if (files.length === 0) { + return "No files found"; + } + return `Found ${files.length} file${files.length === 1 ? "" : "s"}\n${files.join("\n")}`; + } + + if (typeof result.killed === "boolean") { + return result.killed + ? "Process killed successfully" + : "Failed to kill process (may have already exited)"; + } + + if (typeof result.error === "string") { + return result.error; + } + + if (Array.isArray(result.todos)) { + return `Updated ${result.todos.length} todo${result.todos.length !== 1 ? "s" : ""}`; + } + + return JSON.stringify(result); +} + +/** + * Executes a tool by name with the provided arguments. + * + * @param name - The name of the tool to execute + * @param args - Arguments object to pass to the tool + * @returns Promise with the tool's execution result including status and optional stdout/stderr + */ +export async function executeTool( + name: string, + args: ToolArgs, +): Promise { + const tool = toolRegistry.get(name); + + if (!tool) { + return { + toolReturn: `Tool not found: ${name}. Available tools: ${Array.from(toolRegistry.keys()).join(", ")}`, + status: "error", + }; + } + + try { + const result = await tool.fn(args); + + // Extract stdout/stderr if present (for bash tools) + const recordResult = isRecord(result) ? result : undefined; + const stdoutValue = recordResult?.stdout; + const stderrValue = recordResult?.stderr; + const stdout = isStringArray(stdoutValue) ? stdoutValue : undefined; + const stderr = isStringArray(stderrValue) ? stderrValue : undefined; + + // Flatten the response to plain text + const flattenedResponse = flattenToolResponse(result); + + // Return the full response (truncation happens in UI layer only) + return { + toolReturn: flattenedResponse, + status: "success", + ...(stdout && { stdout }), + ...(stderr && { stderr }), + }; + } catch (error) { + // Don't console.error here - it pollutes the TUI + // The error message is already returned in toolReturn + return { + toolReturn: error instanceof Error ? error.message : String(error), + status: "error", + }; + } +} + +/** + * Gets all loaded tool names (for passing to Letta agent creation). + * + * @returns Array of tool names + */ +export function getToolNames(): string[] { + return Array.from(toolRegistry.keys()); +} + +/** + * Gets all loaded tool schemas (for inspection/debugging). + * + * @returns Array of tool schemas + */ +export function getToolSchemas(): ToolSchema[] { + return Array.from(toolRegistry.values()).map((tool) => tool.schema); +} + +/** + * Gets a single tool's schema by name. + * + * @param name - The tool name + * @returns The tool schema or undefined if not found + */ +export function getToolSchema(name: string): ToolSchema | undefined { + return toolRegistry.get(name)?.schema; +} + +/** + * Clears the tool registry (useful for testing). + */ +export function clearTools(): void { + toolRegistry.clear(); +} diff --git a/src/tools/schemas/Bash.json b/src/tools/schemas/Bash.json new file mode 100644 index 0000000..7dd521d --- /dev/null +++ b/src/tools/schemas/Bash.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The command to execute" + }, + "timeout": { + "type": "number", + "description": "Optional timeout in milliseconds (max 600000)" + }, + "description": { + "type": "string", + "description": " Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'" + }, + "run_in_background": { + "type": "boolean", + "description": "Set to true to run this command in the background. Use BashOutput to read the output later." + } + }, + "required": ["command"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/src/tools/schemas/BashOutput.json b/src/tools/schemas/BashOutput.json new file mode 100644 index 0000000..56d7ab3 --- /dev/null +++ b/src/tools/schemas/BashOutput.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "bash_id": { + "type": "string", + "description": "The ID of the background shell to retrieve output from" + }, + "filter": { + "type": "string", + "description": "Optional regular expression to filter the output lines. Only lines matching this regex will be included in the result. Any lines that do not match will no longer be available to read." + } + }, + "required": ["bash_id"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/src/tools/schemas/Edit.json b/src/tools/schemas/Edit.json new file mode 100644 index 0000000..0ab1573 --- /dev/null +++ b/src/tools/schemas/Edit.json @@ -0,0 +1,25 @@ +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The absolute path to the file to modify" + }, + "old_string": { + "type": "string", + "description": "The text to replace" + }, + "new_string": { + "type": "string", + "description": "The text to replace it with (must be different from old_string)" + }, + "replace_all": { + "type": "boolean", + "default": false, + "description": "Replace all occurences of old_string (default false)" + } + }, + "required": ["file_path", "old_string", "new_string"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/src/tools/schemas/ExitPlanMode.json b/src/tools/schemas/ExitPlanMode.json new file mode 100644 index 0000000..15d8470 --- /dev/null +++ b/src/tools/schemas/ExitPlanMode.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "plan": { + "type": "string" + } + }, + "required": ["plan"], + "additionalProperties": false +} diff --git a/src/tools/schemas/Glob.json b/src/tools/schemas/Glob.json new file mode 100644 index 0000000..de86629 --- /dev/null +++ b/src/tools/schemas/Glob.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "The glob pattern to match files against" + }, + "path": { + "type": "string", + "description": "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - simply omit it for the default behavior. Must be a valid directory path if provided." + } + }, + "required": ["pattern"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/src/tools/schemas/Grep.json b/src/tools/schemas/Grep.json new file mode 100644 index 0000000..9e99445 --- /dev/null +++ b/src/tools/schemas/Grep.json @@ -0,0 +1,57 @@ +{ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "The regular expression pattern to search for in file contents" + }, + "path": { + "type": "string", + "description": "File or directory to search in (rg PATH). Defaults to current working directory." + }, + "glob": { + "type": "string", + "description": "Glob pattern to filter files (e.g. \"*.js\", \"*.{ts,tsx}\") - maps to rg --glob" + }, + "output_mode": { + "type": "string", + "enum": ["content", "files_with_matches", "count"], + "description": "Output mode: \"content\" shows matching lines (supports -A/-B/-C context, -n line numbers, head_limit), \"files_with_matches\" shows file paths (supports head_limit), \"count\" shows match counts (supports head_limit). Defaults to \"files_with_matches\"." + }, + "-B": { + "type": "number", + "description": "Number of lines to show before each match (rg -B). Requires output_mode: \"content\", ignored otherwise." + }, + "-A": { + "type": "number", + "description": "Number of lines to show after each match (rg -A). Requires output_mode: \"content\", ignored otherwise." + }, + "-C": { + "type": "number", + "description": "Number of lines to show before and after each match (rg -C). Requires output_mode: \"content\", ignored otherwise." + }, + "-n": { + "type": "boolean", + "description": "Show line numbers in output (rg -n). Requires output_mode: \"content\", ignored otherwise." + }, + "-i": { + "type": "boolean", + "description": "Case insensitive search (rg -i)" + }, + "type": { + "type": "string", + "description": "File type to search (rg --type). Common types: js, py, rust, go, java, etc. More efficient than include for standard file types." + }, + "head_limit": { + "type": "number", + "description": "Limit output to first N lines/entries, equivalent to \"| head -N\". Works across all output modes: content (limits output lines), files_with_matches (limits file paths), count (limits count entries). When unspecified, shows all results from ripgrep." + }, + "multiline": { + "type": "boolean", + "description": "Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false." + } + }, + "required": ["pattern"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/src/tools/schemas/KillBash.json b/src/tools/schemas/KillBash.json new file mode 100644 index 0000000..c88ba4c --- /dev/null +++ b/src/tools/schemas/KillBash.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "shell_id": { + "type": "string", + "description": "The ID of the shell to terminate" + } + }, + "required": ["shell_id"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/src/tools/schemas/LS.json b/src/tools/schemas/LS.json new file mode 100644 index 0000000..5f70cfa --- /dev/null +++ b/src/tools/schemas/LS.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The directory to list" + }, + "ignore": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional list of glob patterns to ignore (e.g. ['node_modules', '*.log'])" + } + }, + "required": ["path"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/src/tools/schemas/MultiEdit.json b/src/tools/schemas/MultiEdit.json new file mode 100644 index 0000000..e7c6151 --- /dev/null +++ b/src/tools/schemas/MultiEdit.json @@ -0,0 +1,37 @@ +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The absolute path to the file to modify" + }, + "edits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "old_string": { + "type": "string", + "description": "The text to replace" + }, + "new_string": { + "type": "string", + "description": "The text to replace it with" + }, + "replace_all": { + "type": "boolean", + "default": false, + "description": "Replace all occurences of old_string (default false)." + } + }, + "required": ["old_string", "new_string"], + "additionalProperties": false + }, + "minItems": 1, + "description": "Array of edit operations to perform sequentially on the file" + } + }, + "required": ["file_path", "edits"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/src/tools/schemas/Read.json b/src/tools/schemas/Read.json new file mode 100644 index 0000000..0462574 --- /dev/null +++ b/src/tools/schemas/Read.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The absolute path to the file to read" + }, + "offset": { + "type": "number", + "description": "The line number to start reading from. Only provide if the file is too large to read at once" + }, + "limit": { + "type": "number", + "description": "The number of lines to read. Only provide if the file is too large to read at once." + } + }, + "required": ["file_path"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/src/tools/schemas/TodoWrite.json b/src/tools/schemas/TodoWrite.json new file mode 100644 index 0000000..11111e9 --- /dev/null +++ b/src/tools/schemas/TodoWrite.json @@ -0,0 +1,34 @@ +{ + "type": "object", + "properties": { + "todos": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed"] + }, + "priority": { + "type": "string", + "enum": ["high", "medium", "low"] + }, + "id": { + "type": "string" + } + }, + "required": ["content", "status", "id"], + "additionalProperties": false + }, + "description": "The updated todo list" + } + }, + "required": ["todos"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/src/tools/schemas/Write.json b/src/tools/schemas/Write.json new file mode 100644 index 0000000..4777cd9 --- /dev/null +++ b/src/tools/schemas/Write.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The absolute path to the file to write (must be absolute, not relative)" + }, + "content": { + "type": "string", + "description": "The content to write to the file" + } + }, + "required": ["file_path", "content"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/src/tools/toolDefinitions.ts b/src/tools/toolDefinitions.ts new file mode 100644 index 0000000..fe6da41 --- /dev/null +++ b/src/tools/toolDefinitions.ts @@ -0,0 +1,111 @@ +import BashDescription from "./descriptions/Bash.md"; +import BashOutputDescription from "./descriptions/BashOutput.md"; +import EditDescription from "./descriptions/Edit.md"; +import ExitPlanModeDescription from "./descriptions/ExitPlanMode.md"; +import GlobDescription from "./descriptions/Glob.md"; +import GrepDescription from "./descriptions/Grep.md"; +import KillBashDescription from "./descriptions/KillBash.md"; +import LSDescription from "./descriptions/LS.md"; +import MultiEditDescription from "./descriptions/MultiEdit.md"; +import ReadDescription from "./descriptions/Read.md"; +import TodoWriteDescription from "./descriptions/TodoWrite.md"; +import WriteDescription from "./descriptions/Write.md"; +import { bash } from "./impl/Bash"; +import { bash_output } from "./impl/BashOutput"; +import { edit } from "./impl/Edit"; +import { exit_plan_mode } from "./impl/ExitPlanMode"; +import { glob } from "./impl/Glob"; +import { grep } from "./impl/Grep"; +import { kill_bash } from "./impl/KillBash"; +import { ls } from "./impl/LS"; +import { multi_edit } from "./impl/MultiEdit"; +import { read } from "./impl/Read"; +import { todo_write } from "./impl/TodoWrite"; +import { write } from "./impl/Write"; +import BashSchema from "./schemas/Bash.json"; +import BashOutputSchema from "./schemas/BashOutput.json"; +import EditSchema from "./schemas/Edit.json"; +import ExitPlanModeSchema from "./schemas/ExitPlanMode.json"; +import GlobSchema from "./schemas/Glob.json"; +import GrepSchema from "./schemas/Grep.json"; +import KillBashSchema from "./schemas/KillBash.json"; +import LSSchema from "./schemas/LS.json"; +import MultiEditSchema from "./schemas/MultiEdit.json"; +import ReadSchema from "./schemas/Read.json"; +import TodoWriteSchema from "./schemas/TodoWrite.json"; +import WriteSchema from "./schemas/Write.json"; + +type ToolImplementation = (args: Record) => Promise; + +interface ToolAssets { + schema: Record; + description: string; + impl: ToolImplementation; +} + +const toolDefinitions = { + Bash: { + schema: BashSchema, + description: BashDescription.trim(), + impl: bash, + }, + BashOutput: { + schema: BashOutputSchema, + description: BashOutputDescription.trim(), + impl: bash_output, + }, + Edit: { + schema: EditSchema, + description: EditDescription.trim(), + impl: edit, + }, + ExitPlanMode: { + schema: ExitPlanModeSchema, + description: ExitPlanModeDescription.trim(), + impl: exit_plan_mode, + }, + Glob: { + schema: GlobSchema, + description: GlobDescription.trim(), + impl: glob, + }, + Grep: { + schema: GrepSchema, + description: GrepDescription.trim(), + impl: grep, + }, + KillBash: { + schema: KillBashSchema, + description: KillBashDescription.trim(), + impl: kill_bash, + }, + LS: { + schema: LSSchema, + description: LSDescription.trim(), + impl: ls, + }, + MultiEdit: { + schema: MultiEditSchema, + description: MultiEditDescription.trim(), + impl: multi_edit, + }, + Read: { + schema: ReadSchema, + description: ReadDescription.trim(), + impl: read, + }, + TodoWrite: { + schema: TodoWriteSchema, + description: TodoWriteDescription.trim(), + impl: todo_write, + }, + Write: { + schema: WriteSchema, + description: WriteDescription.trim(), + impl: write, + }, +} as const satisfies Record; + +export type ToolName = keyof typeof toolDefinitions; + +export const TOOL_DEFINITIONS: Record = toolDefinitions; diff --git a/src/types/md.d.ts b/src/types/md.d.ts new file mode 100644 index 0000000..c94d67b --- /dev/null +++ b/src/types/md.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string; + export default content; +} diff --git a/src/types/mdx.d.ts b/src/types/mdx.d.ts new file mode 100644 index 0000000..7034c73 --- /dev/null +++ b/src/types/mdx.d.ts @@ -0,0 +1,4 @@ +declare module "*.mdx" { + const content: string; + export default content; +} diff --git a/src/types/txt.d.ts b/src/types/txt.d.ts new file mode 100644 index 0000000..d0cff49 --- /dev/null +++ b/src/types/txt.d.ts @@ -0,0 +1,4 @@ +declare module "*.txt" { + const content: string; + export default content; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4cad888 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/vendor/ink-text-input/build/index.js b/vendor/ink-text-input/build/index.js new file mode 100644 index 0000000..9023ef9 --- /dev/null +++ b/vendor/ink-text-input/build/index.js @@ -0,0 +1,115 @@ +import chalk from 'chalk'; +import { Text, useInput } from 'ink'; +import React, { useEffect, useState } from 'react'; + +function TextInput({ value: originalValue, placeholder = '', focus = true, mask, highlightPastedText = false, showCursor = true, onChange, onSubmit, externalCursorOffset, onCursorOffsetChange }) { + const [state, setState] = useState({ cursorOffset: (originalValue || '').length, cursorWidth: 0 }); + const { cursorOffset, cursorWidth } = state; + useEffect(() => { + setState(previousState => { + if (!focus || !showCursor) { + return previousState; + } + const newValue = originalValue || ''; + if (previousState.cursorOffset > newValue.length - 1) { + return { cursorOffset: newValue.length, cursorWidth: 0 }; + } + return previousState; + }); + }, [originalValue, focus, showCursor]); + useEffect(() => { + if (typeof externalCursorOffset === 'number') { + const newValue = originalValue || ''; + const clamped = Math.max(0, Math.min(externalCursorOffset, newValue.length)); + setState(prev => ({ cursorOffset: clamped, cursorWidth: 0 })); + if (typeof onCursorOffsetChange === 'function') onCursorOffsetChange(clamped); + } + }, [externalCursorOffset, originalValue, onCursorOffsetChange]); + const cursorActualWidth = highlightPastedText ? cursorWidth : 0; + const value = mask ? mask.repeat(originalValue.length) : originalValue; + let renderedValue = value; + let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined; + if (showCursor && focus) { + renderedPlaceholder = placeholder.length > 0 ? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1)) : chalk.inverse(' '); + renderedValue = value.length > 0 ? '' : chalk.inverse(' '); + let i = 0; + for (const char of value) { + renderedValue += i >= cursorOffset - cursorActualWidth && i <= cursorOffset ? chalk.inverse(char) : char; + i++; + } + if (value.length > 0 && cursorOffset === value.length) { + renderedValue += chalk.inverse(' '); + } + } + useInput((input, key) => { + if (key && key.isPasted) { + return; + } + // Treat Escape as a control key (don't insert into value) + if (key.escape || key.upArrow || key.downArrow || (key.ctrl && input === 'c') || key.tab || (key.shift && key.tab)) { + return; + } + if (key.return) { + if (onSubmit) { + onSubmit(originalValue); + } + return; + } + let nextCursorOffset = cursorOffset; + let nextValue = originalValue; + let nextCursorWidth = 0; + if (key.leftArrow) { + if (showCursor) { + nextCursorOffset--; + } + } + else if (key.rightArrow) { + if (showCursor) { + nextCursorOffset++; + } + } + else if (key.backspace || key.delete) { + if (cursorOffset > 0) { + nextValue = originalValue.slice(0, cursorOffset - 1) + originalValue.slice(cursorOffset, originalValue.length); + nextCursorOffset--; + } + } + else if (key.ctrl && input === 'a') { + // CTRL-A: jump to beginning of line + if (showCursor) { + nextCursorOffset = 0; + } + } + else if (key.ctrl && input === 'e') { + // CTRL-E: jump to end of line + if (showCursor) { + nextCursorOffset = originalValue.length; + } + } + else { + nextValue = originalValue.slice(0, cursorOffset) + input + originalValue.slice(cursorOffset, originalValue.length); + nextCursorOffset += input.length; + if (input.length > 1) { + nextCursorWidth = input.length; + } + } + if (cursorOffset < 0) { + nextCursorOffset = 0; + } + if (cursorOffset > originalValue.length) { + nextCursorOffset = originalValue.length; + } + setState({ cursorOffset: nextCursorOffset, cursorWidth: nextCursorWidth }); + if (typeof onCursorOffsetChange === 'function') onCursorOffsetChange(nextCursorOffset); + if (nextValue !== originalValue) { + onChange(nextValue); + } + }, { isActive: focus }); + return (React.createElement(Text, null, placeholder ? (value.length > 0 ? renderedValue : renderedPlaceholder) : renderedValue)); +} +export default TextInput; +export function UncontrolledTextInput({ initialValue = '', ...props }) { + const [value, setValue] = useState(initialValue); + return React.createElement(TextInput, { ...props, value: value, onChange: setValue }); +} + diff --git a/vendor/ink/build/components/App.js b/vendor/ink/build/components/App.js new file mode 100644 index 0000000..8243965 --- /dev/null +++ b/vendor/ink/build/components/App.js @@ -0,0 +1,348 @@ +import { EventEmitter } from 'node:events'; +import process from 'node:process'; +import cliCursor from 'cli-cursor'; +import React, { PureComponent } from 'react'; +import AppContext from './AppContext.js'; +import ErrorOverview from './ErrorOverview.js'; +import FocusContext from './FocusContext.js'; +import StderrContext from './StderrContext.js'; +import StdinContext from './StdinContext.js'; +import StdoutContext from './StdoutContext.js'; + +const tab = '\t'; +const shiftTab = '\u001B[Z'; +const escape = '\u001B'; +export default class App extends PureComponent { + static displayName = 'InternalApp'; + static getDerivedStateFromError(error) { + return { error }; + } + state = { + isFocusEnabled: true, + activeFocusId: undefined, + focusables: [], + error: undefined, + }; + rawModeEnabledCount = 0; + internal_eventEmitter = new EventEmitter(); + isRawModeSupported() { + return this.props.stdin.isTTY; + } + render() { + return (React.createElement(AppContext.Provider, { value: { exit: this.handleExit } }, + React.createElement(StdinContext.Provider, { value: { stdin: this.props.stdin, setRawMode: this.handleSetRawMode, isRawModeSupported: this.isRawModeSupported(), internal_exitOnCtrlC: this.props.exitOnCtrlC, internal_eventEmitter: this.internal_eventEmitter } }, + React.createElement(StdoutContext.Provider, { value: { stdout: this.props.stdout, write: this.props.writeToStdout } }, + React.createElement(StderrContext.Provider, { value: { stderr: this.props.stderr, write: this.props.writeToStderr } }, + React.createElement(FocusContext.Provider, { value: { activeId: this.state.activeFocusId, add: this.addFocusable, remove: this.removeFocusable, activate: this.activateFocusable, deactivate: this.deactivateFocusable, enableFocus: this.enableFocus, disableFocus: this.disableFocus, focusNext: this.focusNext, focusPrevious: this.focusPrevious, focus: this.focus } }, this.state.error ? (React.createElement(ErrorOverview, { error: this.state.error })) : (this.props.children))))))); + } + componentDidMount() { + cliCursor.hide(this.props.stdout); + } + componentWillUnmount() { + cliCursor.show(this.props.stdout); + if (this.isRawModeSupported()) { + this.handleSetRawMode(false); + } + } + componentDidCatch(error) { + this.handleExit(error); + } + handleSetRawMode = (isEnabled) => { + const { stdin } = this.props; + if (!this.isRawModeSupported()) { + if (stdin === process.stdin) { + throw new Error('Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported'); + } + else { + throw new Error('Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported'); + } + } + stdin.setEncoding('utf8'); + if (isEnabled) { + if (this.rawModeEnabledCount === 0) { + stdin.ref(); + stdin.setRawMode(true); + stdin.addListener('readable', this.handleReadable); + // Enable bracketed paste on this TTY + this.props.stdout?.write('\x1B[?2004h'); + } + this.rawModeEnabledCount++; + return; + } + if (--this.rawModeEnabledCount === 0) { + this.props.stdout?.write('\x1B[?2004l'); + stdin.setRawMode(false); + stdin.removeListener('readable', this.handleReadable); + stdin.unref(); + } + }; + keyParseState = { mode: 'NORMAL', incomplete: '', pasteBuffer: '' }; + fallbackPaste = { aggregating: false, buffer: '', timer: null, lastAt: 0, chunks: 0, bytes: 0, escalated: false, recentTime: 0, recentLen: 0 }; + FALLBACK_NORMAL_MS = 16; + FALLBACK_PASTE_MS = 150; + PLACEHOLDER_LINE_THRESHOLD = 5; + PLACEHOLDER_CHAR_THRESHOLD = 500; + FALLBACK_START_LEN_THRESHOLD = 200; + parseChunk = (state, chunk) => { + const START = '\x1B[200~'; + const END = '\x1B[201~'; + const events = []; + let next = { ...state }; + let buf = (next.incomplete || '') + (chunk || ''); + next.incomplete = ''; + const pushText = (text) => { + if (text && text.length > 0) { + events.push({ type: 'text', value: text }); + } + }; + if (next.mode === 'NORMAL') { + let offset = 0; + while (offset < buf.length) { + const startIdx = buf.indexOf(START, offset); + if (startIdx === -1) { + const remainder = buf.slice(offset); + let keep = 0; + const max = Math.min(remainder.length, START.length - 1); + // Only keep potential START prefixes of length >= 2 (e.g., "\x1B[") to avoid swallowing a bare ESC + for (let i = max; i > 1; i--) { + if (START.startsWith(remainder.slice(-i))) { + keep = i; + break; + } + } + if (remainder.length > keep) { + pushText(remainder.slice(0, remainder.length - keep)); + } + next.incomplete = remainder.slice(remainder.length - keep); + break; + } + if (startIdx > offset) { + pushText(buf.slice(offset, startIdx)); + } + offset = startIdx + START.length; + const endIdx = buf.indexOf(END, offset); + if (endIdx !== -1) { + const content = buf.slice(offset, endIdx); + events.push({ type: 'paste', value: content }); + offset = endIdx + END.length; + continue; + } + next.mode = 'IN_PASTE'; + next.pasteBuffer = buf.slice(offset); + break; + } + return [events, next]; + } + if (next.mode === 'IN_PASTE') { + next.pasteBuffer += buf; + const endIdx = next.pasteBuffer.indexOf(END); + if (endIdx === -1) { + return [events, next]; + } + const content = next.pasteBuffer.slice(0, endIdx); + events.push({ type: 'paste', value: content }); + const after = next.pasteBuffer.slice(endIdx + END.length); + next.mode = 'NORMAL'; + next.pasteBuffer = ''; + const [moreEvents, finalState] = this.parseChunk(next, after); + return [events.concat(moreEvents), finalState]; + } + return [events, next]; + }; + countLines = (text) => { + if (!text) + return 0; + const m = text.match(/\r\n|\r|\n/g); + return (m ? m.length : 0); + }; + fallbackStart = () => { + this.fallbackStop(); + this.fallbackPaste.aggregating = true; + this.fallbackPaste.buffer = ''; + this.fallbackPaste.chunks = 0; + this.fallbackPaste.bytes = 0; + this.fallbackPaste.escalated = false; + this.fallbackPaste.lastAt = Date.now(); + this.fallbackPaste.timer = setTimeout(this.fallbackFlush, this.FALLBACK_NORMAL_MS); + }; + fallbackSchedule = (ms) => { + if (this.fallbackPaste.timer) + clearTimeout(this.fallbackPaste.timer); + this.fallbackPaste.timer = setTimeout(this.fallbackFlush, ms); + this.fallbackPaste.lastAt = Date.now(); + }; + fallbackStop = () => { + if (this.fallbackPaste.timer) + clearTimeout(this.fallbackPaste.timer); + this.fallbackPaste.timer = null; + this.fallbackPaste.aggregating = false; + }; + fallbackFlush = () => { + const txt = this.fallbackPaste.buffer; + this.fallbackStop(); + if (!txt) + return; + const lines = this.countLines(txt); + const isPaste = this.fallbackPaste.escalated || (lines > this.PLACEHOLDER_LINE_THRESHOLD) || (txt.length > this.PLACEHOLDER_CHAR_THRESHOLD); + if (isPaste) { + const pasteEvent = { sequence: txt, raw: txt, isPasted: true, name: '', ctrl: false, meta: false, shift: false }; + this.internal_eventEmitter.emit('input', pasteEvent); + } + else { + this.handleInput(txt); + this.internal_eventEmitter.emit('input', txt); + } + this.fallbackPaste.buffer = ''; + this.fallbackPaste.chunks = 0; + this.fallbackPaste.bytes = 0; + this.fallbackPaste.escalated = false; + }; + handleReadable = () => { + let chunk; + while ((chunk = this.props.stdin.read()) !== null) { + const [events, nextState] = this.parseChunk(this.keyParseState, chunk); + this.keyParseState = nextState; + for (const evt of events) { + if (evt.type === 'paste') { + if (this.fallbackPaste.aggregating) { + this.fallbackFlush(); + } + const content = evt.value; + const pasteEvent = { sequence: content, raw: content, isPasted: true, name: '', ctrl: false, meta: false, shift: false }; + this.internal_eventEmitter.emit('input', pasteEvent); + } + else if (evt.type === 'text') { + const text = evt.value; + if (!text) + continue; + const hasNewline = /\r|\n/.test(text); + if (this.fallbackPaste.aggregating) { + this.fallbackPaste.buffer += text; + this.fallbackPaste.chunks += 1; + this.fallbackPaste.bytes += text.length; + if (!this.fallbackPaste.escalated) { + if (this.fallbackPaste.buffer.length >= 128) { + this.fallbackPaste.escalated = true; + } + } + this.fallbackSchedule(this.fallbackPaste.escalated ? this.FALLBACK_PASTE_MS : this.FALLBACK_NORMAL_MS); + continue; + } + const now = Date.now(); + const quickCombo = (now - this.fallbackPaste.recentTime) <= 16 && (this.fallbackPaste.recentLen + text.length) >= 128; + if (text.length >= 128 || quickCombo) { + this.fallbackStart(); + this.fallbackPaste.buffer += text; + this.fallbackPaste.chunks = 1; + this.fallbackPaste.bytes = text.length; + this.fallbackPaste.escalated = text.length >= 128; + this.fallbackSchedule(this.FALLBACK_PASTE_MS); + continue; + } + this.handleInput(text); + this.internal_eventEmitter.emit('input', text); + this.fallbackPaste.recentTime = Date.now(); + this.fallbackPaste.recentLen = text.length; + continue; + } + } + } + }; + handleInput = (input) => { + if (input === '\x03' && this.props.exitOnCtrlC) { + this.handleExit(); + } + // Disable ESC-based focus clearing to avoid consuming the first Escape + // if (input === escape && this.state.activeFocusId) { + // this.setState({ activeFocusId: undefined }); + // } + if (this.state.isFocusEnabled && this.state.focusables.length > 0) { + if (input === tab) { + this.focusNext(); + } + if (input === shiftTab) { + this.focusPrevious(); + } + } + }; + handleExit = (error) => { + if (this.isRawModeSupported()) { + this.handleSetRawMode(false); + } + this.props.onExit(error); + }; + enableFocus = () => { + this.setState({ isFocusEnabled: true }); + }; + disableFocus = () => { + this.setState({ isFocusEnabled: false }); + }; + focus = (id) => { + this.setState(previousState => { + const hasFocusableId = previousState.focusables.some(focusable => focusable?.id === id); + if (!hasFocusableId) { + return previousState; + } + return { activeFocusId: id }; + }); + }; + focusNext = () => { + this.setState(previousState => { + const firstFocusableId = previousState.focusables.find(focusable => focusable.isActive)?.id; + const nextFocusableId = this.findNextFocusable(previousState); + return { activeFocusId: nextFocusableId ?? firstFocusableId }; + }); + }; + focusPrevious = () => { + this.setState(previousState => { + const lastFocusableId = previousState.focusables.findLast(focusable => focusable.isActive)?.id; + const previousFocusableId = this.findPreviousFocusable(previousState); + return { activeFocusId: previousFocusableId ?? lastFocusableId }; + }); + }; + addFocusable = (id, { autoFocus }) => { + this.setState(previousState => { + let nextFocusId = previousState.activeFocusId; + if (!nextFocusId && autoFocus) { + nextFocusId = id; + } + return { activeFocusId: nextFocusId, focusables: [...previousState.focusables, { id, isActive: true }] }; + }); + }; + removeFocusable = (id) => { + this.setState(previousState => ({ activeFocusId: previousState.activeFocusId === id ? undefined : previousState.activeFocusId, focusables: previousState.focusables.filter(focusable => focusable.id !== id) })); + }; + activateFocusable = (id) => { + this.setState(previousState => ({ focusables: previousState.focusables.map(focusable => (focusable.id !== id ? focusable : { id, isActive: true })) })); + }; + deactivateFocusable = (id) => { + this.setState(previousState => ({ activeFocusId: previousState.activeFocusId === id ? undefined : previousState.activeFocusId, focusables: previousState.focusables.map(focusable => (focusable.id !== id ? focusable : { id, isActive: false })) })); + }; + findNextFocusable = (state) => { + const activeIndex = state.focusables.findIndex(focusable => { + return focusable.id === state.activeFocusId; + }); + for (let index = activeIndex + 1; index < state.focusables.length; index++) { + const focusable = state.focusables[index]; + if (focusable?.isActive) { + return focusable.id; + } + } + return undefined; + }; + findPreviousFocusable = (state) => { + const activeIndex = state.focusables.findIndex(focusable => { + return focusable.id === state.activeFocusId; + }); + for (let index = activeIndex - 1; index >= 0; index--) { + const focusable = state.focusables[index]; + if (focusable?.isActive) { + return focusable.id; + } + } + return undefined; + }; +} +//# sourceMappingURL=App.js.map + + diff --git a/vendor/ink/build/devtools.js b/vendor/ink/build/devtools.js new file mode 100644 index 0000000..248cf38 --- /dev/null +++ b/vendor/ink/build/devtools.js @@ -0,0 +1,6 @@ +/* Patched by Letta Code: disable React DevTools hook in production builds. */ +export default { + connectToDevTools() { + // no-op + }, +}; diff --git a/vendor/ink/build/hooks/use-input.js b/vendor/ink/build/hooks/use-input.js new file mode 100644 index 0000000..344b4d5 --- /dev/null +++ b/vendor/ink/build/hooks/use-input.js @@ -0,0 +1,101 @@ +import { useEffect } from 'react'; +import parseKeypress, { nonAlphanumericKeys } from '../parse-keypress.js'; +import reconciler from '../reconciler.js'; +import useStdin from './use-stdin.js'; + +// Patched for bracketed paste: propagate "isPasted" and avoid leaking ESC sequences +const useInput = (inputHandler, options = {}) => { + const { stdin, setRawMode, internal_exitOnCtrlC, internal_eventEmitter } = useStdin(); + + useEffect(() => { + if (options.isActive === false) { + return; + } + setRawMode(true); + return () => { + setRawMode(false); + }; + }, [options.isActive, setRawMode]); + + useEffect(() => { + if (options.isActive === false) { + return; + } + + const handleData = (data) => { + // Handle bracketed paste events emitted by Ink stdin manager + if (data && typeof data === 'object' && data.isPasted) { + const key = { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + isPasted: true + }; + reconciler.batchedUpdates(() => { + inputHandler(data.sequence || data.raw || '', key); + }); + return; + } + + const keypress = parseKeypress(data); + const key = { + upArrow: keypress.name === 'up', + downArrow: keypress.name === 'down', + leftArrow: keypress.name === 'left', + rightArrow: keypress.name === 'right', + pageDown: keypress.name === 'pagedown', + pageUp: keypress.name === 'pageup', + return: keypress.name === 'return', + escape: keypress.name === 'escape', + ctrl: keypress.ctrl, + shift: keypress.shift, + tab: keypress.name === 'tab', + backspace: keypress.name === 'backspace', + delete: keypress.name === 'delete', + meta: keypress.meta || keypress.name === 'escape' || keypress.option, + isPasted: false + }; + + let input = keypress.ctrl ? keypress.name : keypress.sequence; + const seq = typeof keypress.sequence === 'string' ? keypress.sequence : ''; + // Filter xterm focus in/out sequences (ESC[I / ESC[O) + if (seq === '\u001B[I' || seq === '\u001B[O' || input === '[I' || input === '[O' || /^(?:\[I|\[O)+$/.test(input || '')) { + return; + } + + if (nonAlphanumericKeys.includes(keypress.name)) { + input = ''; + } + + if (input.length === 1 && typeof input[0] === 'string' && /[A-Z]/.test(input[0])) { + key.shift = true; + } + + if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) { + reconciler.batchedUpdates(() => { + inputHandler(input, key); + }); + } + }; + + internal_eventEmitter?.on('input', handleData); + return () => { + internal_eventEmitter?.removeListener('input', handleData); + }; + }, [options.isActive, stdin, internal_exitOnCtrlC, inputHandler]); +}; + +export default useInput; + +