diff --git a/.cursor/environment.json b/.cursor/environment.json new file mode 100644 index 0000000..bed3de6 --- /dev/null +++ b/.cursor/environment.json @@ -0,0 +1,5 @@ +{ + "name": "opencode-commandcode", + "install": "export PATH=\"$HOME/.local/bin:$HOME/.bun/bin:$HOME/.opencode/bin:$PATH\" && (command -v bun >/dev/null || curl -fsSL https://bun.sh/install | bash) && (command -v opencode >/dev/null || curl -fsSL https://opencode.ai/install | bash) && npm config set prefix \"$HOME/.local\" && npm i -g command-code@latest && bun install && bun run build", + "start": "export PATH=\"$HOME/.local/bin:$HOME/.bun/bin:$HOME/.opencode/bin:$PATH\" && echo \"command-code=$(command-code --version 2>/dev/null || cmd --version)\" && echo \"opencode=$(opencode --version)\" && echo \"bun=$(bun --version)\"" +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f611a82 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,123 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Explicit version (e.g., 0.2.0). If empty, bump type is used.' + required: false + type: string + bump: + description: 'Version bump type (used if version is empty)' + required: false + default: 'minor' + type: choice + options: + - minor + - patch + - major + dry_run: + description: 'Dry run — skip npm publish, create draft release' + required: false + default: false + type: boolean + +env: + NODE_VERSION: '22' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Get current version from package.json + id: current_version + run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Calculate new version + id: version + env: + CURRENT_VERSION: ${{ steps.current_version.outputs.version }} + INPUT_VERSION: ${{ github.event.inputs.version }} + BUMP_TYPE: ${{ github.event.inputs.bump }} + run: | + set -euo pipefail + if [[ -n "$INPUT_VERSION" ]]; then + if ! echo "$INPUT_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Error: version must be in semver format X.Y.Z" + exit 1 + fi + echo "version=$INPUT_VERSION" >> "$GITHUB_OUTPUT" + else + IFS='.' read -r major minor patch <<< "$CURRENT_VERSION" + case "$BUMP_TYPE" in + major) echo "version=$((major+1)).0.0" >> "$GITHUB_OUTPUT" ;; + minor) echo "version=$major.$((minor+1)).0" >> "$GITHUB_OUTPUT" ;; + patch) echo "version=$major.$minor.$((patch+1))" >> "$GITHUB_OUTPUT" ;; + *) echo "Error: unknown bump type $BUMP_TYPE"; exit 1 ;; + esac + fi + + - name: Update package.json version + run: | + node -e " + const pkg = require('./package.json'); + pkg.version = '${{ steps.version.outputs.version }}'; + require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run tests + run: bun run test + + - name: Build package + run: bun run build + + - name: Commit and tag + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add package.json + if git ls-files --modified --others | grep -q bun.lock; then + git add bun.lock + fi + git commit -m "chore: release v${VERSION}" + git tag "v${VERSION}" + git push origin "v${VERSION}" + git push origin HEAD:main + + - name: Publish to npm + if: ${{ github.event.inputs.dry_run != 'true' }} + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.version }} + generate_release_notes: true + draft: ${{ github.event.inputs.dry_run == 'true' }} + name: v${{ steps.version.outputs.version }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d3bf20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store + +# Local agent notes / environment-specific +AGENTS.md diff --git a/.opencode/opencode.json b/.opencode/opencode.json new file mode 100644 index 0000000..d6b020b --- /dev/null +++ b/.opencode/opencode.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["file://."], + "provider": { + "command-code": { "name": "Command Code" } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f2af2a0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## 0.1.0 + +- Initial release: Command Code OpenCode plugin +- Local OpenAI-compatible proxy → `api.commandcode.ai/alpha/generate` +- Auth via Command Code Go-plan browser OAuth (`cmd login`) — no Studio API key +- Default model: Laguna S 2.1 free (`poolside/laguna-s-2.1-free`) +- Tools/MCP park-resume, attachment plumbing, context compact, usage endpoints diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6f6eeac --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Otto Assistant contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 606d960..a13c3bd 100644 --- a/README.md +++ b/README.md @@ -1 +1,120 @@ -# opencode-commandcode +

+ opencode-commandcode — Command Code in OpenCode, Laguna S 2.1 gateway +

+ +

+ npm version + MIT license + linux, macos, windows +

+ +

+ Command Code inside OpenCode — Go-plan ($1) browser login,
+ gateway proxy, free Laguna S 2.1, tools/MCP, attachments, compact, usage. +

+ +--- + +Run [Command Code](https://commandcode.ai) models from OpenCode by proxying the same gateway that `npm i -g command-code@latest` uses (`POST /alpha/generate`). Default and exclusive live-test target: **Laguna S 2.1 free** (`poolside/laguna-s-2.1-free`, 256k context, $0). + +Plugin shape mirrors [@otto-assistant/opencode-claude](https://github.com/otto-assistant/opencode-claude) and [@otto-assistant/opencode-cursor](https://github.com/otto-assistant/opencode-cursor). + +## Install + +```bash +# global (recommended) +opencode plugin @otto-assistant/opencode-commandcode -g + +# or project-local +opencode plugin @otto-assistant/opencode-commandcode +``` + +Optional provider naming: + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["@otto-assistant/opencode-commandcode"], + "provider": { + "command-code": { "name": "Command Code" } + } +} +``` + +From source: + +```bash +git clone https://github.com/otto-assistant/opencode-commandcode.git +cd opencode-commandcode +bun install && bun run build +opencode plugin file://$PWD +``` + +## Authenticate + +Uses the **Command Code Go plan ($1/mo)** — same browser login as `cmd login`. No Studio API key. + +```bash +npm i -g command-code@latest +cmd login # browser → Studio CLI auth (Go plan). Laguna S 2.1 is $0 credits. + +opencode auth login --provider command-code +# pick "Login with Command Code (Go $1)" — or "Use existing cmd login session" +``` + +Then: + +```bash +opencode run "Summarise this repository in five bullets." --model command-code/laguna-s-2.1-free +``` + +Laguna S 2.1 free requires an active Go (or higher) account with credits on file; requests on that model still bill **$0**. + +## Why this plugin + +| | | +|---|---| +| **Gateway proxy** | Talks to `api.commandcode.ai/alpha/generate` — same transport as the `cmd` CLI. | +| **Laguna S 2.1 free** | 256k context, reasoning, $0 while the deal lasts. | +| **Go-plan auth** | Browser OAuth like `cmd login` ($1/mo). Syncs `~/.commandcode/auth.json`. No API key. | +| **Agent-grade tools** | OpenCode tool calls park and resume; MCP-prefixed tools are tracked separately. | +| **Attachments** | Images, PDFs, text/binary files from OpenCode. Text-only models get image placeholders (Command Code behavior). | +| **Compact** | Context-fraction tips + tiered client compact before the 256k window overflows. | +| **Usage** | Per-turn and session totals via SSE `usage` + `GET /v1/usage`. | + +## Architecture + +```text +OpenCode + └─ /v1/chat/completions + └─ Bun.serve proxy (port 8797) + └─ POST https://api.commandcode.ai/alpha/generate + └─ poolside/laguna-s-2.1-free (default) +``` + +## Requirements + +- [OpenCode](https://opencode.ai) +- [Command Code CLI](https://www.npmjs.com/package/command-code) (`npm i -g command-code@latest`) and a free account +- Bun (plugin runtime) · Node.js ≥ 18 (CLI needs ≥ 22) + +## Development + +```bash +bun install +bun run build +bun run test # mocked gateway — attachments, tools, compact, usage +bun run test:live # live Laguna S 2.1 (needs `cmd login` / Go plan session) +``` + +Debug: `OPENCODE_COMMANDCODE_DEBUG=1`. + +Optional knobs: + +- `OPENCODE_COMMANDCODE_PROXY_PORT` — fixed local proxy port (default `8797`) +- `OPENCODE_COMMANDCODE_CWD` — working directory reported to the gateway +- `COMMANDCODE_API_URL` — override API base (default `https://api.commandcode.ai`) + +## License + +[MIT](LICENSE) diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..36ae74c --- /dev/null +++ b/bun.lock @@ -0,0 +1,92 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@otto-assistant/opencode-commandcode", + "dependencies": { + "@opencode-ai/plugin": "^1.18.15", + }, + "devDependencies": { + "@types/bun": "^1.3.11", + "@types/node": "^22.15.0", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.15", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-AY10RtFbzLkf951dbLkSGJdBeddeS6ojbjvqCpWnINedqlj2cK/GF91xWjWnlHpqQZ4GABLPCuoSJx8mGmKvCw=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.15", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-8sfo9nGiVwesAZW9Wqkvynyn7w4wYaHx1O9qOHpYL65+Bs2XUpHP3kBbZe58gjQydFgk6I74kUF7sqCiPu2arQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + + "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + + "msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + + "multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + } +} diff --git a/docs/header.svg b/docs/header.svg new file mode 100644 index 0000000..f8a6a0c --- /dev/null +++ b/docs/header.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + opencode-commandcode + Command Code in OpenCode — Laguna S 2.1 gateway proxy + + diff --git a/opencode-commandcode.js b/opencode-commandcode.js new file mode 100644 index 0000000..0d6ee18 --- /dev/null +++ b/opencode-commandcode.js @@ -0,0 +1,3 @@ +// Entry point: re-exports the compiled plugin from dist/ +// OpenCode's file/package loader resolves this more reliably than dist/index.js directly. +export { CommandCodePlugin, default } from "./dist/index.js"; diff --git a/package.json b/package.json new file mode 100644 index 0000000..25c9a33 --- /dev/null +++ b/package.json @@ -0,0 +1,63 @@ +{ + "name": "@otto-assistant/opencode-commandcode", + "version": "0.1.0", + "description": "Command Code in OpenCode — Go-plan ($1) browser OAuth, gateway proxy, Laguna S 2.1, tools/MCP, attachments, compact, usage.", + "license": "MIT", + "type": "module", + "main": "./opencode-commandcode.js", + "module": "./opencode-commandcode.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./opencode-commandcode.js", + "default": "./opencode-commandcode.js" + } + }, + "files": [ + "dist", + "opencode-commandcode.js" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "bun test/smoke.ts", + "test:live": "bun test/live-laguna.ts", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/otto-assistant/opencode-commandcode.git" + }, + "homepage": "https://www.npmjs.com/package/@otto-assistant/opencode-commandcode", + "bugs": { + "url": "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/otto-assistant/opencode-commandcode/issues" + }, + "keywords": [ + "opencode", + "opencode-plugin", + "command-code", + "commandcode", + "laguna", + "poolside", + "oauth", + "openai-compatible", + "ai", + "llm", + "streaming", + "agent" + ], + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@opencode-ai/plugin": "^1.18.15" + }, + "devDependencies": { + "@types/bun": "^1.3.11", + "@types/node": "^22.15.0", + "typescript": "^5.9.3" + } +} diff --git a/scripts/update-plugin.sh b/scripts/update-plugin.sh new file mode 100644 index 0000000..d05f437 --- /dev/null +++ b/scripts/update-plugin.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +bun install +bun run build +echo "Built @otto-assistant/opencode-commandcode" +echo "Install into OpenCode with: opencode plugin file://$ROOT" diff --git a/src/auth-login.ts b/src/auth-login.ts new file mode 100644 index 0000000..6f1de75 --- /dev/null +++ b/src/auth-login.ts @@ -0,0 +1,433 @@ +/** + * Auth helpers: sync Command Code CLI credentials + browser Go-plan login. + * + * Browser flow matches `cmd login`: + * https://commandcode.ai/studio/auth/cli?callback=http://localhost:PORT/callback&state=STATE + * Studio POSTs { apiKey, userId, userName, keyName, state } to the local callback. + * That session credential is what the $1 Go plan uses — no Studio API key needed. + */ +import { createServer, type Server } from "node:http"; +import { randomBytes } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import { + readCommandCodeCredentials, + type CommandCodeCredentials, +} from "./credentials.js"; +import { + COMMAND_CODE_AUTH_FILE, + COMMAND_CODE_DIR_NAME, + PROVIDER_ID, + WHOAMI_ROUTE, +} from "./constants.js"; +import { getApiBaseUrl, log } from "./log.js"; + +export type CommandCodeAuthTokens = { + access: string; + refresh: string; + expires: number; + key?: string; + userId?: string; + userName?: string; + keyName?: string; +}; + +export type PendingCommandLogin = { + url: string; + state: string; + port: number; + startedAt: number; + completed: boolean; + error?: string; +}; + +export type BrowserCallbackPayload = { + apiKey: string; + userId: string; + userName: string; + keyName: string; + state: string; +}; + +const AUTH_START_PORT = 5959; +const AUTH_MAX_PORT_ATTEMPTS = 10; +const AUTH_TIMEOUT_MS = 12 * 60 * 1000; +const STUDIO_BASE = "https://commandcode.ai"; +const ALLOWED_CORS = [ + "http://localhost:3000", + "https://staging.commandcode.ai", + "https://commandcode.ai", +]; + +let pending: PendingCommandLogin | null = null; +let authServer: Server | null = null; +let waitForCallback: Promise | null = null; +let rejectCallback: ((err: Error) => void) | null = null; + +function authJsonPath(): string { + const xdg = process.env.XDG_DATA_HOME; + const base = xdg ? xdg : join(homedir(), ".local", "share"); + return join(base, "opencode", "auth.json"); +} + +function commandCodeAuthPath(homeDir = homedir()): string { + return join(homeDir, COMMAND_CODE_DIR_NAME, COMMAND_CODE_AUTH_FILE); +} + +function readAuthFile(): Record { + const path = authJsonPath(); + if (!existsSync(path)) return {}; + try { + return JSON.parse(readFileSync(path, "utf8")) as Record; + } catch { + return {}; + } +} + +function writeAuthFile(data: Record): void { + const path = authJsonPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8"); +} + +export function writeCommandCodeAuth( + tokens: CommandCodeAuthTokens & { key?: string }, +): void { + const existing = readAuthFile(); + const key = tokens.key || tokens.access; + existing[PROVIDER_ID] = { + type: "oauth", + key, + access: key, + refresh: tokens.refresh, + expires: tokens.expires, + ...(tokens.userId ? { accountId: tokens.userId } : {}), + }; + writeAuthFile(existing); + + // Keep CLI auth.json in sync so `cmd -p` / status also see the session. + try { + const cliPath = commandCodeAuthPath(); + mkdirSync(dirname(cliPath), { recursive: true }); + let cliExisting: Record = {}; + if (existsSync(cliPath)) { + try { + cliExisting = JSON.parse(readFileSync(cliPath, "utf8")) as Record< + string, + unknown + >; + } catch { + cliExisting = {}; + } + } + writeFileSync( + cliPath, + JSON.stringify( + { + ...cliExisting, + apiKey: key, + userId: tokens.userId || cliExisting.userId || "", + userName: tokens.userName || cliExisting.userName || "", + keyName: tokens.keyName || cliExisting.keyName || "opencode-oauth", + authenticatedAt: new Date().toISOString(), + }, + null, + 2, + ) + "\n", + { mode: 0o600 }, + ); + } catch (err) { + log.warn( + "[opencode-commandcode] failed to mirror credentials into ~/.commandcode/auth.json", + err instanceof Error ? err.message : err, + ); + } +} + +export function credentialsToTokens( + creds: CommandCodeCredentials, +): CommandCodeAuthTokens { + return { + access: creds.apiKey, + refresh: `cli-sync-${creds.source}`, + expires: Date.now() + 365 * 24 * 60 * 60 * 1000, + key: creds.apiKey, + userId: creds.userId || undefined, + userName: creds.userName || undefined, + keyName: creds.keyName || undefined, + }; +} + +/** + * Sync Command Code CLI credentials (from `cmd login` Go-plan browser auth) + * into OpenCode auth.json. No separate Studio API key required. + */ +export function syncCommandCodeCredentialsToOpenCode(): CommandCodeAuthTokens | null { + const creds = readCommandCodeCredentials(); + if (!creds?.apiKey) return null; + const tokens = credentialsToTokens(creds); + writeCommandCodeAuth(tokens); + log.info( + "[opencode-commandcode] synced Command Code Go-plan credentials into OpenCode auth", + ); + return tokens; +} + +export async function validateCommandApiKey( + apiKey: string, + options?: { baseUrl?: string; fetchFn?: typeof fetch }, +): Promise<{ valid: boolean; userName?: string; error?: string }> { + const fetchFn = options?.fetchFn ?? fetch; + const baseUrl = (options?.baseUrl ?? getApiBaseUrl()).replace(/\/$/, ""); + try { + const res = await fetchFn(`${baseUrl}${WHOAMI_ROUTE}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + }); + if (res.status === 401) { + return { valid: false, error: "invalid_key" }; + } + if (!res.ok) { + return { valid: false, error: `server_error_${res.status}` }; + } + const body = (await res.json().catch(() => null)) as { + user?: { userName?: string; name?: string }; + success?: boolean; + } | null; + const userName = + body?.user?.userName || body?.user?.name || undefined; + return { valid: true, userName }; + } catch (err) { + return { + valid: false, + error: err instanceof Error ? err.message : "network_error", + }; + } +} + +export function getPendingCommandLogin(): PendingCommandLogin | null { + return pending; +} + +export function resetPendingCommandLogin(): void { + stopAuthServer(); + pending = null; + waitForCallback = null; + rejectCallback = null; +} + +function stopAuthServer(): void { + if (authServer) { + try { + authServer.close(); + } catch { + // ignore + } + authServer = null; + } +} + +export function buildCommandAuthUrl(port: number, state: string): string { + const callback = `http://localhost:${port}/callback`; + return `${STUDIO_BASE}/studio/auth/cli?callback=${encodeURIComponent(callback)}&state=${encodeURIComponent(state)}`; +} + +function isCallbackPayload(body: unknown): body is BrowserCallbackPayload { + if (!body || typeof body !== "object") return false; + const b = body as Record; + return ( + typeof b.apiKey === "string" && + typeof b.state === "string" && + typeof b.userId === "string" && + typeof b.userName === "string" && + typeof b.keyName === "string" + ); +} + +async function listenOnPort(port: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + server.removeListener("error", reject); + resolve(server); + }); + }); +} + +async function findAvailablePort( + start = AUTH_START_PORT, + attempts = AUTH_MAX_PORT_ATTEMPTS, +): Promise { + for (let i = 0; i < attempts; i++) { + const port = start + i; + try { + const server = await listenOnPort(port); + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + return port; + } catch { + // try next + } + } + throw new Error( + `No available auth callback port after ${attempts} attempts from ${start}`, + ); +} + +/** + * Start Command Code Studio CLI browser login (same as `cmd login`). + * Works with the $1 Go plan — Studio returns a session credential via POST. + */ +export async function startCommandBrowserLogin(): Promise { + resetPendingCommandLogin(); + + const port = await findAvailablePort(); + const state = randomBytes(32).toString("base64url"); + const url = buildCommandAuthUrl(port, state); + + let resolveCb!: (payload: BrowserCallbackPayload) => void; + let rejectCb!: (err: Error) => void; + waitForCallback = new Promise((resolve, reject) => { + resolveCb = resolve; + rejectCb = reject; + }); + rejectCallback = rejectCb; + + const server = await listenOnPort(port); + authServer = server; + + server.on("request", (req, res) => { + const origin = req.headers.origin; + const allow = + typeof origin === "string" && ALLOWED_CORS.includes(origin) + ? origin + : ALLOWED_CORS[0]; + res.setHeader("Access-Control-Allow-Origin", allow); + res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + res.setHeader("Content-Type", "application/json"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + if (req.url !== "/callback") { + res.writeHead(404); + res.end(JSON.stringify({ success: false, error: "Not found" })); + return; + } + if (req.method !== "POST") { + res.writeHead(405); + res.end( + JSON.stringify({ + success: false, + error: "Method not allowed. Use POST.", + }), + ); + return; + } + + let raw = ""; + req.on("data", (chunk) => { + raw += chunk.toString(); + if (raw.length > 10_000) req.destroy(); + }); + req.on("end", () => { + try { + const parsed = JSON.parse(raw) as Record; + if (parsed && typeof parsed === "object" && "error" in parsed) { + res.writeHead(200); + res.end(JSON.stringify({ success: true })); + const message = + typeof parsed.error_description === "string" + ? parsed.error_description + : typeof parsed.error === "string" + ? parsed.error + : "Authorization denied"; + rejectCb(new Error(message)); + stopAuthServer(); + return; + } + if (!isCallbackPayload(parsed)) { + res.writeHead(400); + res.end( + JSON.stringify({ + success: false, + error: "Missing required fields", + }), + ); + return; + } + if (parsed.state !== state) { + res.writeHead(403); + res.end( + JSON.stringify({ success: false, error: "Invalid state token" }), + ); + return; + } + res.writeHead(200); + res.end(JSON.stringify({ success: true })); + resolveCb(parsed); + stopAuthServer(); + } catch { + res.writeHead(400); + res.end(JSON.stringify({ success: false, error: "Invalid JSON" })); + } + }); + }); + + pending = { + url, + state, + port, + startedAt: Date.now(), + completed: false, + }; + log.info("[opencode-commandcode] Command Code Go-plan OAuth URL ready", { + port, + }); + return pending; +} + +export async function completeCommandBrowserLogin(): Promise { + if (!pending || !waitForCallback) { + throw new Error("No Command Code login in progress — start auth first."); + } + + const timeout = new Promise((_, reject) => { + setTimeout( + () => reject(new Error("Browser authentication timed out")), + AUTH_TIMEOUT_MS, + ); + }); + + try { + const payload = await Promise.race([waitForCallback, timeout]); + const tokens: CommandCodeAuthTokens = { + access: payload.apiKey, + key: payload.apiKey, + refresh: "browser-oauth", + expires: Date.now() + 365 * 24 * 60 * 60 * 1000, + userId: payload.userId, + userName: payload.userName, + keyName: payload.keyName, + }; + writeCommandCodeAuth(tokens); + if (pending) pending.completed = true; + return tokens; + } catch (err) { + if (pending) { + pending.error = err instanceof Error ? err.message : String(err); + } + resetPendingCommandLogin(); + throw err; + } +} diff --git a/src/bridge-pool.ts b/src/bridge-pool.ts new file mode 100644 index 0000000..d2cff84 --- /dev/null +++ b/src/bridge-pool.ts @@ -0,0 +1,73 @@ +/** + * Parked bridge pool for OpenCode tool_calls ↔ Command Code tool-call events. + */ +export type ParkedToolCall = { + id: string; + name: string; + arguments: string; + resolve: (result: string) => void; + reject: (err: Error) => void; + /** Set when parking so resume can await OpenCode tool results. */ + resultPromise?: Promise; +}; + +export type ParkedBridge = { + id: string; + conversationKey: string; + pendingTools: Map; + createdAt: number; + /** Resume generator after tool results land. */ + continueStream?: () => AsyncGenerator; + /** Accumulated assistant text so far (optional). */ + partialText?: string; + /** Wire messages so far for the next gateway turn. */ + wireMessages?: unknown[]; + modelId?: string; + system?: string; + effort?: string; + tools?: unknown[]; + apiKey?: string; + contextWindow?: number; + maxTokens?: number; +}; + +const bridges = new Map(); + +export function putBridge(bridge: ParkedBridge): void { + // Supersede older bridges for the same conversation. + for (const [id, existing] of bridges) { + if ( + existing.conversationKey === bridge.conversationKey && + id !== bridge.id + ) { + bridges.delete(id); + } + } + bridges.set(bridge.id, bridge); +} + +export function deleteBridge(id: string): void { + bridges.delete(id); +} + +export function findBridgeByConversation( + conversationKey: string, +): ParkedBridge | undefined { + for (const bridge of bridges.values()) { + if (bridge.conversationKey === conversationKey) return bridge; + } + return undefined; +} + +export function findBridgeByPendingTool( + toolCallId: string, +): ParkedBridge | undefined { + for (const bridge of bridges.values()) { + if (bridge.pendingTools.has(toolCallId)) return bridge; + } + return undefined; +} + +export function clearBridges(): void { + bridges.clear(); +} diff --git a/src/compact.ts b/src/compact.ts new file mode 100644 index 0000000..1ca0114 --- /dev/null +++ b/src/compact.ts @@ -0,0 +1,156 @@ +/** + * Context-window / compact helpers for long OpenCode ↔ Command Code sessions. + */ +import { COMPACT_TIERS } from "./constants.js"; +import type { WireMessage } from "./gateway-types.js"; +import { recordCompactEvent } from "./usage.js"; +import { log } from "./log.js"; + +export type CompactAdvice = { + tier: "ok" | "tip" | "warn" | "auto"; + fraction: number; + shouldCompact: boolean; + message: string | null; +}; + +export function assessContext( + lastContextTokens: number, + contextWindow: number, +): CompactAdvice { + if (contextWindow <= 0) { + return { tier: "ok", fraction: 0, shouldCompact: false, message: null }; + } + const fraction = lastContextTokens / contextWindow; + if (fraction >= COMPACT_TIERS.auto) { + return { + tier: "auto", + fraction, + shouldCompact: true, + message: `[compact] Context at ${Math.round(fraction * 100)}% of window — auto-compacting older turns.`, + }; + } + if (fraction >= COMPACT_TIERS.warn) { + return { + tier: "warn", + fraction, + shouldCompact: false, + message: `[compact] Context at ${Math.round(fraction * 100)}% — consider compacting soon.`, + }; + } + if (fraction >= COMPACT_TIERS.tip) { + return { + tier: "tip", + fraction, + shouldCompact: false, + message: `[compact] Context at ${Math.round(fraction * 100)}% — compact at a task boundary if this session will grow.`, + }; + } + return { tier: "ok", fraction, shouldCompact: false, message: null }; +} + +/** + * Estimate rough token count from wire messages (chars/4 heuristic). + * Used when the gateway has not yet reported usage for this turn. + */ +export function estimateMessageTokens(messages: WireMessage[]): number { + let chars = 0; + for (const msg of messages) { + if (typeof msg.content === "string") { + chars += msg.content.length; + continue; + } + for (const part of msg.content) { + if (part.type === "text") chars += part.text.length; + else if (part.type === "reasoning") chars += part.text.length; + else if (part.type === "image") chars += 256; // placeholder cost + else if (part.type === "tool-call") { + chars += part.toolName.length + JSON.stringify(part.input).length; + } else if (part.type === "tool-result") { + chars += part.output.value.length; + } + } + } + return Math.ceil(chars / 4); +} + +/** + * Tiered client-side compact: trim old tool results, then drop early turns. + * Mirrors Command Code's fast compact-mode spirit for the OpenCode proxy path. + */ +export function compactWireMessages( + messages: WireMessage[], + options: { + sessionId: string; + keepToolResults?: number; + keepTurns?: number; + }, +): { messages: WireMessage[]; compacted: boolean; note: string | null } { + const keepToolResults = options.keepToolResults ?? 10; + const keepTurns = options.keepTurns ?? 24; + + let toolResults: Array<{ index: number; msgIndex: number; partIndex: number }> = + []; + messages.forEach((msg, msgIndex) => { + if (msg.role !== "tool" || !Array.isArray(msg.content)) return; + msg.content.forEach((part, partIndex) => { + if (part.type === "tool-result") { + toolResults.push({ + index: toolResults.length, + msgIndex, + partIndex, + }); + } + }); + }); + + let compacted = false; + const cloned: WireMessage[] = messages.map((m) => { + if (typeof m.content === "string") return { ...m }; + return { + ...m, + content: m.content.map((p) => ({ ...p })), + } as WireMessage; + }); + + if (toolResults.length > keepToolResults) { + const dropCount = toolResults.length - keepToolResults; + for (let i = 0; i < dropCount; i++) { + const ref = toolResults[i]; + const msg = cloned[ref.msgIndex]; + if (msg.role !== "tool" || !Array.isArray(msg.content)) continue; + const part = msg.content[ref.partIndex]; + if (part?.type === "tool-result") { + part.output = { + type: "text", + value: "[compacted: older tool result trimmed]", + }; + compacted = true; + } + } + } + + // Keep system + recent turns. + if (cloned.length > keepTurns) { + const system = cloned.filter((m) => m.role === "system"); + const rest = cloned.filter((m) => m.role !== "system"); + const kept = rest.slice(-keepTurns); + cloned.length = 0; + cloned.push(...system, ...kept); + compacted = true; + } + + if (compacted) { + recordCompactEvent(options.sessionId); + log.info("[opencode-commandcode] compacted wire messages", { + sessionId: options.sessionId, + remaining: cloned.length, + }); + return { + messages: cloned, + compacted: true, + note: "[compact] Older context was compacted before this turn.", + }; + } + + return { messages: cloned, compacted: false, note: null }; +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..df2851d --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,46 @@ +/** Command Code OpenCode provider id. */ +export const PROVIDER_ID = "command-code"; + +/** Default model alias shown in OpenCode (resolves to Laguna S 2.1 free). */ +export const DEFAULT_MODEL_ID = "laguna-s-2.1-free"; + +/** Canonical Laguna S 2.1 free model id on Command Code. */ +export const LAGUNA_MODEL_ID = "poolside/laguna-s-2.1-free"; + +export const OPENAI_COMPATIBLE_NPM = "@ai-sdk/openai-compatible"; + +export const EFFORT_HEADER = "x-opencode-commandcode-effort"; +export const SESSION_HEADER = "x-opencode-commandcode-session"; + +export const COMMAND_CODE_API_KEY_ENV = "COMMAND_CODE_API_KEY"; +export const COMMAND_CODE_DIR_NAME = ".commandcode"; +export const COMMAND_CODE_AUTH_FILE = "auth.json"; + +export const DEFAULT_API_BASE_URL = "https://api.commandcode.ai"; +export const GENERATE_ROUTE = "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/alpha/generate"; +export const WHOAMI_ROUTE = "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/alpha/whoami"; +export const USAGE_SUMMARY_ROUTE = "/alpha/usage/summary"; + +export const EFFORT_LEVELS = [ + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + +export type CommandEffort = (typeof EFFORT_LEVELS)[number]; + +export function isCommandEffort(value: unknown): value is CommandEffort { + return ( + typeof value === "string" && + (EFFORT_LEVELS as readonly string[]).includes(value) + ); +} + +/** Compact tiers as fractions of the model context window. */ +export const COMPACT_TIERS = { + tip: 0.5, + warn: 0.8, + auto: 0.9, +} as const; diff --git a/src/credentials.ts b/src/credentials.ts new file mode 100644 index 0000000..629ee41 --- /dev/null +++ b/src/credentials.ts @@ -0,0 +1,123 @@ +/** + * Read Command Code CLI credentials from env or ~/.commandcode/auth.json. + * Never log token values. + */ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + COMMAND_CODE_API_KEY_ENV, + COMMAND_CODE_AUTH_FILE, + COMMAND_CODE_DIR_NAME, +} from "./constants.js"; + +export type CommandCodeCredentials = { + apiKey: string; + userId: string | null; + userName: string | null; + keyName: string | null; + source: "env" | "auth-file"; + authPath?: string; +}; + +export type CommandAuthFile = { + apiKey?: string; + userId?: string; + userName?: string; + keyName?: string; + authenticatedAt?: string; +}; + +export function listCommandAuthCandidates( + homeDir = homedir(), + env: NodeJS.ProcessEnv | Record = process.env, +): string[] { + const candidates: string[] = []; + const configDir = + typeof env.COMMANDCODE_CONFIG_DIR === "string" + ? env.COMMANDCODE_CONFIG_DIR.trim() + : ""; + if (configDir) { + candidates.push(join(configDir, COMMAND_CODE_AUTH_FILE)); + } + candidates.push( + join(homeDir, COMMAND_CODE_DIR_NAME, COMMAND_CODE_AUTH_FILE), + join(homeDir, ".config", "commandcode", COMMAND_CODE_AUTH_FILE), + ); + return candidates; +} + +export function parseAuthFile(raw: string): CommandAuthFile | null { + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as CommandAuthFile; + } catch { + return null; + } +} + +export function extractApiKeyFromAuthFile( + parsed: CommandAuthFile | null, +): string | null { + if (!parsed) return null; + const key = typeof parsed.apiKey === "string" ? parsed.apiKey.trim() : ""; + return key.length > 0 ? key : null; +} + +export function readCommandCodeApiKeyFromEnv( + env: NodeJS.ProcessEnv | Record = process.env, +): string | null { + const value = env[COMMAND_CODE_API_KEY_ENV]; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function readCommandCodeCredentials(options?: { + homeDir?: string; + env?: NodeJS.ProcessEnv | Record; +}): CommandCodeCredentials | null { + const env = options?.env ?? process.env; + const homeDir = options?.homeDir ?? homedir(); + + const fromEnv = readCommandCodeApiKeyFromEnv(env); + if (fromEnv) { + return { + apiKey: fromEnv, + userId: null, + userName: null, + keyName: null, + source: "env", + }; + } + + for (const path of listCommandAuthCandidates(homeDir, env)) { + if (!existsSync(path)) continue; + try { + const parsed = parseAuthFile(readFileSync(path, "utf8")); + const apiKey = extractApiKeyFromAuthFile(parsed); + if (!apiKey || !parsed) continue; + return { + apiKey, + userId: typeof parsed.userId === "string" ? parsed.userId : null, + userName: typeof parsed.userName === "string" ? parsed.userName : null, + keyName: typeof parsed.keyName === "string" ? parsed.keyName : null, + source: "auth-file", + authPath: path, + }; + } catch { + // try next + } + } + return null; +} + +export function hasCommandCodeCredentials(options?: { + homeDir?: string; + env?: NodeJS.ProcessEnv | Record; +}): boolean { + return Boolean(readCommandCodeCredentials(options)?.apiKey); +} diff --git a/src/detect.ts b/src/detect.ts new file mode 100644 index 0000000..ab2987a --- /dev/null +++ b/src/detect.ts @@ -0,0 +1,150 @@ +/** + * Detect Command Code CLI installation + auth status. + */ +import { spawnSync } from "node:child_process"; +import { hasCommandCodeCredentials } from "./credentials.js"; +import { resolveCommandCodeExecutable } from "./executable-path.js"; + +export type CommandDetectStatus = + | "ready" + | "needs-login" + | "missing-cli" + | "error"; + +export type CommandDetectResult = { + status: CommandDetectStatus; + statusDetail?: string; + binaryPath?: string | null; + version?: string | null; + loggedIn: boolean; +}; + +export function probeCommandAuthStatusCli(options: { + binaryPath: string; + env?: NodeJS.ProcessEnv | Record; + spawnSyncFn?: typeof spawnSync; +}): { loggedIn: boolean; detail: string } | null { + const binaryPath = options.binaryPath.trim(); + if (!binaryPath) return null; + const spawnSyncFn = options.spawnSyncFn || spawnSync; + + try { + const result = spawnSyncFn(binaryPath, ["status", "--json"], { + encoding: "utf8", + timeout: 8000, + env: (options.env || process.env) as NodeJS.ProcessEnv, + windowsHide: true, + }); + + const output = `${result.stdout || ""}`.trim(); + if (!output) { + // Fall back to non-json status text. + const text = spawnSyncFn(binaryPath, ["status"], { + encoding: "utf8", + timeout: 8000, + env: (options.env || process.env) as NodeJS.ProcessEnv, + windowsHide: true, + }); + const combined = `${text.stdout || ""}\n${text.stderr || ""}`; + const loggedIn = /logged in|authenticated/i.test(combined) && + !/not authenticated/i.test(combined); + return { + loggedIn, + detail: loggedIn ? "auth-status-text" : "auth-status-logged-out", + }; + } + + try { + const payload = JSON.parse(output) as { + authenticated?: boolean; + loggedIn?: boolean; + }; + const loggedIn = Boolean(payload.authenticated || payload.loggedIn); + return { + loggedIn, + detail: loggedIn ? "auth-status-json" : "auth-status-logged-out", + }; + } catch { + const loggedIn = + /authenticated|logged in/i.test(output) && + !/not authenticated/i.test(output); + return { + loggedIn, + detail: loggedIn ? "auth-status-text" : "auth-status-parse-error", + }; + } + } catch { + return null; + } +} + +export async function detectCommandCode(options?: { + env?: NodeJS.ProcessEnv | Record; + homeDir?: string; + binaryPath?: string | null; +}): Promise { + const env = options?.env ?? process.env; + const binaryPath = + options?.binaryPath !== undefined + ? options.binaryPath + : resolveCommandCodeExecutable({ env }); + + const credsPresent = hasCommandCodeCredentials({ + homeDir: options?.homeDir, + env, + }); + + if (!binaryPath && !credsPresent) { + return { + status: "missing-cli", + statusDetail: + "Command Code CLI (`cmd` / `command-code`) not found on PATH. Install with `npm i -g command-code@latest`.", + binaryPath: null, + version: null, + loggedIn: false, + }; + } + + let version: string | null = null; + if (binaryPath) { + try { + const result = spawnSync(binaryPath, ["--version"], { + encoding: "utf8", + timeout: 4000, + env: env as NodeJS.ProcessEnv, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + const match = `${result.stdout || ""}`.trim().match(/(\d+\.\d+\.\d+)/); + version = match?.[1] ?? (`${result.stdout || ""}`.trim() || null); + } catch { + version = null; + } + } + + const authStatus = binaryPath + ? probeCommandAuthStatusCli({ binaryPath, env }) + : null; + const loggedIn = Boolean(authStatus?.loggedIn) || credsPresent; + + if (!loggedIn) { + return { + status: "needs-login", + statusDetail: + "Command Code is installed but not authenticated. Run `cmd login` or set COMMAND_CODE_API_KEY.", + binaryPath, + version, + loggedIn: false, + }; + } + + return { + status: "ready", + statusDetail: authStatus?.detail || "ready", + binaryPath, + version, + loggedIn: true, + }; +} + +export { resolveCommandCodeExecutable } from "./executable-path.js"; diff --git a/src/executable-path.ts b/src/executable-path.ts new file mode 100644 index 0000000..5e0bb52 --- /dev/null +++ b/src/executable-path.ts @@ -0,0 +1,67 @@ +/** + * Resolve the `cmd` / `command-code` CLI binary. + */ +import { spawnSync } from "node:child_process"; + +export function findBinaryOnPath( + name: string, + env: NodeJS.ProcessEnv | Record = process.env, +): string | null { + const pathEnv = typeof env.PATH === "string" ? env.PATH : ""; + const parts = pathEnv.split(process.platform === "win32" ? ";" : ":"); + const exts = + process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""]; + for (const dir of parts) { + if (!dir) continue; + for (const ext of exts) { + const candidate = `${dir.replace(/[/\\]$/, "")}/${name}${ext}`; + try { + const result = spawnSync(candidate, ["--version"], { + encoding: "utf8", + timeout: 4000, + env: env as NodeJS.ProcessEnv, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error) continue; + if (result.status === 0 || (result.stdout || "").trim()) { + return candidate; + } + } catch { + // try next + } + } + } + + try { + const result = spawnSync(name, ["--version"], { + encoding: "utf8", + timeout: 4000, + env: env as NodeJS.ProcessEnv, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + if (!result.error && (result.status === 0 || (result.stdout || "").trim())) { + return name; + } + } catch { + // missing + } + return null; +} + +export function resolveCommandCodeExecutable(options?: { + env?: NodeJS.ProcessEnv | Record; +}): string | null { + const env = options?.env ?? process.env; + return ( + findBinaryOnPath("command-code", env) || + findBinaryOnPath("cmd", env) || + findBinaryOnPath("cmdc", env) || + findBinaryOnPath("commandcode", env) + ); +} + +export function assertWorkingDirectory(cwd: unknown): string { + return typeof cwd === "string" && cwd.trim() ? cwd.trim() : process.cwd(); +} diff --git a/src/gateway-types.ts b/src/gateway-types.ts new file mode 100644 index 0000000..764a87a --- /dev/null +++ b/src/gateway-types.ts @@ -0,0 +1,154 @@ +/** + * Shared gateway wire types for Command Code /alpha/generate. + */ +import { getApiBaseUrl as getBase } from "./log.js"; +import { + GENERATE_ROUTE, + USAGE_SUMMARY_ROUTE, + WHOAMI_ROUTE, +} from "./constants.js"; + +export { GENERATE_ROUTE, USAGE_SUMMARY_ROUTE, WHOAMI_ROUTE }; +export const getApiBaseUrl = getBase; + +export type WireTextPart = { type: "text"; text: string }; +export type WireImagePart = { + type: "image"; + image: string; + mimeType?: string; +}; +export type WireReasoningPart = { type: "reasoning"; text: string }; +export type WireToolCallPart = { + type: "tool-call"; + toolCallId: string; + toolName: string; + input: Record; +}; +export type WireToolResultPart = { + type: "tool-result"; + toolCallId: string; + toolName: string; + output: { type: "text" | "error-text"; value: string }; +}; + +export type WireUserContent = Array; +export type WireAssistantContent = Array< + WireTextPart | WireReasoningPart | WireToolCallPart +>; +export type WireToolContent = Array; + +export type WireMessage = + | { role: "user"; content: WireUserContent } + | { role: "assistant"; content: WireAssistantContent } + | { role: "tool"; content: WireToolContent } + | { role: "system"; content: string }; + +export type WireTool = { + name: string; + description: string; + input_schema: Record; +}; + +export type WireUsage = { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; +}; + +export type GenerateBody = { + config: { + workingDir: string; + date: string; + environment: string; + structure: string[]; + isGitRepo: boolean; + currentBranch: string; + mainBranch: string; + gitStatus: string; + recentCommits: string[]; + [key: string]: unknown; + }; + memory: null; + taste: null; + skills: null; + permissionMode: "standard" | "auto-accept" | "plan"; + threadId?: string; + mode?: + | "agent" + | "learning" + | "custom-agent" + | "custom-agent-create" + | "title-gen" + | "tool-desc" + | "compact" + | "vision" + | string; + params: { + model: string; + messages: WireMessage[]; + tools: WireTool[]; + system?: string; + max_tokens: number; + stream: boolean; + temperature?: number; + reasoning_effort?: string; + }; +}; + +export type StreamEvent = + | { type: "text-delta"; text?: string } + | { type: "reasoning-start" } + | { type: "reasoning-delta"; text?: string } + | { type: "reasoning-end" } + | { + type: "tool-call"; + toolCallId?: string; + toolName?: string; + input?: unknown; + args?: unknown; + providerExecuted?: boolean; + } + | { + type: "tool-result"; + toolCallId?: string; + result?: unknown; + output?: unknown; + isError?: boolean; + providerExecuted?: boolean; + } + | { + type: "finish"; + finishReason?: string; + rawFinishReason?: string; + totalUsage?: { + inputTokens?: number; + outputTokens?: number; + inputTokenDetails?: { + cacheReadTokens?: number; + cacheWriteTokens?: number; + }; + }; + systemPromptTokens?: number; + } + | { type: "error"; error?: unknown } + | { type: "abort" } + | { type: string; [key: string]: unknown }; + +export function emptyUsage(): WireUsage { + return { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }; +} + +export function addUsage(a: WireUsage, b: WireUsage): WireUsage { + return { + inputTokens: a.inputTokens + b.inputTokens, + outputTokens: a.outputTokens + b.outputTokens, + cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens, + cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens, + }; +} diff --git a/src/gateway.ts b/src/gateway.ts new file mode 100644 index 0000000..43a2ade --- /dev/null +++ b/src/gateway.ts @@ -0,0 +1,339 @@ +/** + * Command Code gateway client — POST /alpha/generate NDJSON stream. + */ +import { createHash, randomUUID } from "node:crypto"; +import { readdirSync } from "node:fs"; +import { + GENERATE_ROUTE, + emptyUsage, + getApiBaseUrl, + type GenerateBody, + type StreamEvent, + type WireMessage, + type WireTool, + type WireUsage, +} from "./gateway-types.js"; +import { log } from "./log.js"; + +export type GatewayAuthHeaders = { + apiKey: string; + sessionId?: string; + cliVersion?: string; + projectSlug?: string; +}; + +export type GatewayGenerateParams = { + apiKey: string; + model: string; + messages: WireMessage[]; + tools?: WireTool[]; + system?: string; + maxTokens?: number; + temperature?: number; + effort?: string; + permissionMode?: "standard" | "auto-accept" | "plan"; + threadId?: string; + sessionId?: string; + signal?: AbortSignal; + /** Injected for tests. */ + fetchFn?: typeof fetch; + baseUrl?: string; + /** Override POST body builder for mocks. */ + postStream?: ( + body: GenerateBody, + headers: Record, + ) => Promise>; +}; + +export type GatewayMappedEvent = + | { kind: "text"; text: string } + | { kind: "reasoning"; text: string } + | { + kind: "tool_call"; + id: string; + name: string; + arguments: string; + providerExecuted?: boolean; + } + | { kind: "tool_result"; id: string; output: string; isError?: boolean } + | { + kind: "finish"; + finishReason: string; + usage: WireUsage; + systemPromptTokens?: number; + } + | { kind: "error"; text: string } + | { kind: "ignore" }; + +export function buildAuthHeaders(auth: GatewayAuthHeaders): Record { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "cli", + Authorization: `Bearer ${auth.apiKey}`, + "x-command-code-version": auth.cliVersion || "1.15.0", + "x-cli-environment": "cli", + "x-taste-learning": "false", + "x-co-flag": "false", + }; + if (auth.sessionId) headers["x-session-id"] = auth.sessionId; + if (auth.projectSlug) headers["x-project-slug"] = auth.projectSlug; + return headers; +} + +export function buildGenerateBody( + params: GatewayGenerateParams, +): GenerateBody { + const cwd = process.env.OPENCODE_COMMANDCODE_CWD || process.cwd(); + let structure: string[] = []; + try { + structure = readdirSync(cwd) + .filter((name) => !name.startsWith(".")) + .sort() + .slice(0, 200); + } catch { + structure = []; + } + + return { + config: { + workingDir: cwd, + date: new Date().toISOString().slice(0, 10), + environment: process.platform, + structure, + isGitRepo: false, + currentBranch: "", + mainBranch: "", + gitStatus: "", + recentCommits: [], + }, + memory: null, + taste: null, + skills: null, + permissionMode: params.permissionMode || "auto-accept", + ...(params.threadId ? { threadId: params.threadId } : {}), + // Feature mode — must be a gateway feature id, not "default". + mode: "agent", + params: { + model: params.model, + messages: params.messages, + tools: params.tools || [], + ...(params.system ? { system: params.system } : {}), + max_tokens: params.maxTokens ?? 32_768, + stream: true, + ...(typeof params.temperature === "number" + ? { temperature: params.temperature } + : {}), + ...(params.effort ? { reasoning_effort: params.effort } : {}), + }, + }; +} + +async function* readNdjsonLines( + stream: ReadableStream, +): AsyncGenerator { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (line) yield line; + } + } + const tail = buffer.trim(); + if (tail) yield tail; + } finally { + reader.releaseLock(); + } +} + +export function mapStreamEvent(event: StreamEvent): GatewayMappedEvent { + switch (event.type) { + case "text-delta": + return typeof event.text === "string" && event.text + ? { kind: "text", text: event.text } + : { kind: "ignore" }; + case "reasoning-delta": + return typeof event.text === "string" && event.text + ? { kind: "reasoning", text: event.text } + : { kind: "ignore" }; + case "reasoning-start": + case "reasoning-end": + return { kind: "ignore" }; + case "tool-call": { + const id = + (typeof event.toolCallId === "string" && event.toolCallId) || + `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`; + const name = + (typeof event.toolName === "string" && event.toolName) || "unknown"; + const raw = event.input ?? event.args ?? {}; + let args: string; + try { + args = + typeof raw === "string" + ? raw + : JSON.stringify(raw && typeof raw === "object" ? raw : { value: raw }); + } catch { + args = "{}"; + } + return { + kind: "tool_call", + id, + name, + arguments: args, + providerExecuted: event.providerExecuted === true, + }; + } + case "tool-result": { + const id = + (typeof event.toolCallId === "string" && event.toolCallId) || ""; + const raw = event.output ?? event.result ?? ""; + let output: string; + if (typeof raw === "string") output = raw; + else if (raw && typeof raw === "object" && "value" in (raw as object)) { + output = String((raw as { value: unknown }).value); + } else { + try { + output = JSON.stringify(raw); + } catch { + output = String(raw); + } + } + return { + kind: "tool_result", + id, + output, + isError: event.isError === true, + }; + } + case "finish": { + const usage = emptyUsage(); + const total = event.totalUsage as + | { + inputTokens?: number; + outputTokens?: number; + inputTokenDetails?: { + cacheReadTokens?: number; + cacheWriteTokens?: number; + }; + } + | undefined; + if (total) { + usage.inputTokens = total.inputTokens ?? 0; + usage.outputTokens = total.outputTokens ?? 0; + usage.cacheReadTokens = + total.inputTokenDetails?.cacheReadTokens ?? 0; + usage.cacheWriteTokens = + total.inputTokenDetails?.cacheWriteTokens ?? 0; + } + const finishReason = + event.finishReason === "tool-calls" + ? "tool_calls" + : event.finishReason === "length" + ? "length" + : "stop"; + return { + kind: "finish", + finishReason, + usage, + systemPromptTokens: + typeof event.systemPromptTokens === "number" + ? event.systemPromptTokens + : undefined, + }; + } + case "error": { + const err = event.error; + const text = + typeof err === "string" + ? err + : err && typeof err === "object" && "message" in err + ? String((err as { message: unknown }).message) + : "Command Code stream error"; + return { kind: "error", text }; + } + case "abort": + return { kind: "error", text: "aborted" }; + default: + return { kind: "ignore" }; + } +} + +/** + * Stream mapped events from Command Code /alpha/generate. + */ +export async function* streamGenerate( + params: GatewayGenerateParams, +): AsyncGenerator { + const body = buildGenerateBody(params); + const headers = buildAuthHeaders({ + apiKey: params.apiKey, + sessionId: params.sessionId, + }); + + let stream: ReadableStream; + if (params.postStream) { + stream = await params.postStream(body, headers); + } else { + const fetchFn = params.fetchFn ?? fetch; + const baseUrl = (params.baseUrl ?? getApiBaseUrl()).replace(/\/$/, ""); + const url = `${baseUrl}${GENERATE_ROUTE}`; + log.info("[opencode-commandcode] POST /alpha/generate", { + model: params.model, + toolCount: params.tools?.length ?? 0, + messageCount: params.messages.length, + }); + let res: Response; + try { + res = await fetchFn(url, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: params.signal, + }); + } catch (err) { + yield { + kind: "error", + text: + err instanceof Error + ? `network error: ${err.message}` + : "network error", + }; + return; + } + if (!res.ok) { + const text = await res.text().catch(() => ""); + yield { + kind: "error", + text: `POST ${GENERATE_ROUTE} → ${res.status} ${text.slice(0, 500)}`, + }; + return; + } + if (!res.body) { + yield { kind: "error", text: "empty stream body" }; + return; + } + stream = res.body; + } + + for await (const line of readNdjsonLines(stream)) { + let parsed: StreamEvent; + try { + parsed = JSON.parse(line) as StreamEvent; + } catch { + continue; + } + yield mapStreamEvent(parsed); + } +} + +export function stableCompletionId(seed: string): string { + return `chatcmpl_${createHash("sha1").update(seed).digest("hex").slice(0, 24)}`; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..13f2c9f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,480 @@ +/** + * OpenCode Command Code Auth Plugin + * + * Enables Command Code (Laguna S 2.1 free and gateway models) inside OpenCode via: + * 1. Command Code Go-plan browser OAuth (`cmd login`) — no API key + * 2. Local OpenAI-compatible proxy → api.commandcode.ai /alpha/generate + * 3. Tools/MCP park-resume, attachments, compact, and usage accounting + * + * Register in opencode.json: + * { "plugin": ["@otto-assistant/opencode-commandcode"] } + */ +import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin"; +import { + completeCommandBrowserLogin, + getPendingCommandLogin, + resetPendingCommandLogin, + startCommandBrowserLogin, + syncCommandCodeCredentialsToOpenCode, +} from "./auth-login.js"; +import { + DEFAULT_MODEL_ID, + EFFORT_HEADER, + OPENAI_COMPATIBLE_NPM, + PROVIDER_ID, + SESSION_HEADER, +} from "./constants.js"; +import { log } from "./log.js"; +import { + encodeCommandModelSelection, + resolveCommandModelSelection, +} from "./model-selection.js"; +import { + buildConfigVariants, + buildEffortVariants, + getCommandModels, + type CommandModel, +} from "./models.js"; +import { + getCommandProxyBaseUrl, + getProxyPort, + startProxy, +} from "./proxy.js"; + +type CommandApiAuth = { + type: "api" | "oauth"; + key?: string; + access?: string; + refresh?: string; + expires?: number; +}; + +function isCommandAuth(auth: unknown): auth is CommandApiAuth { + if (!auth || typeof auth !== "object") return false; + const a = auth as CommandApiAuth; + return a.type === "api" || a.type === "oauth"; +} + +function authKey(auth: CommandApiAuth | null | undefined): string | null { + if (!auth) return null; + if (typeof auth.key === "string" && auth.key.trim()) return auth.key.trim(); + if (typeof auth.access === "string" && auth.access.trim()) { + return auth.access.trim(); + } + return null; +} + +function zeroCost() { + return { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }; +} + +function buildProviderModel( + model: CommandModel, + id: string, + baseURL: string, +): Record { + const variants = buildEffortVariants(model); + const hasEffort = Object.values(variants).some( + (v) => v && typeof v === "object" && "effort" in v, + ); + return { + id, + providerID: PROVIDER_ID, + api: { + id, + url: baseURL, + npm: OPENAI_COMPATIBLE_NPM, + }, + name: + id === DEFAULT_MODEL_ID && model.id !== DEFAULT_MODEL_ID + ? `Default (${model.name})` + : model.name, + capabilities: { + temperature: true, + reasoning: hasEffort, + attachment: true, + toolcall: true, + input: { + text: true, + audio: false, + image: model.vision, + video: false, + pdf: true, + }, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + interleaved: true, + }, + modalities: { + input: model.vision ? ["text", "image"] : ["text"], + output: ["text"], + }, + cost: zeroCost(), + limit: { + context: model.contextWindow, + output: model.maxTokens, + }, + status: "active", + options: { + includeUsage: true, + }, + headers: {}, + release_date: "", + variants, + }; +} + +function buildConfigModelEntry(model: CommandModel): Record { + const variants = buildConfigVariants(model); + return { + name: model.name, + reasoning: false, + tool_call: true, + modalities: { + input: model.vision ? ["text", "image"] : ["text"], + output: ["text"], + }, + capabilities: { + tools: true, + input: model.vision ? ["text", "image"] : ["text"], + output: ["text"], + }, + limit: { + context: model.contextWindow, + output: model.maxTokens, + }, + options: { + includeUsage: true, + }, + variants, + }; +} + +function buildProviderModels( + models: CommandModel[], +): Record { + const baseURL = getCommandProxyBaseUrl(); + const providerModels = Object.fromEntries( + models.map((model) => [ + model.id, + buildProviderModel(model, model.id, baseURL), + ]), + ); + const defaultModel = + models.find((m) => m.id === DEFAULT_MODEL_ID) || models[0]; + if (defaultModel && !(DEFAULT_MODEL_ID in providerModels)) { + providerModels[DEFAULT_MODEL_ID] = buildProviderModel( + defaultModel, + DEFAULT_MODEL_ID, + baseURL, + ); + } + return providerModels; +} + +function ensureProviderConfig( + config: Record, + models: CommandModel[], +): void { + if (!config.provider || typeof config.provider !== "object") { + config.provider = {}; + } + const existing = config.provider[PROVIDER_ID] ?? {}; + const existingOptions = + existing.options && typeof existing.options === "object" + ? existing.options + : {}; + const existingModels = + existing.models && typeof existing.models === "object" + ? existing.models + : {}; + + const baseURL = getCommandProxyBaseUrl(); + const seededModels = Object.fromEntries( + models.map((model) => [model.id, buildConfigModelEntry(model)]), + ); + const defaultModel = + models.find((m) => m.id === DEFAULT_MODEL_ID) || models[0]; + if (defaultModel && !(DEFAULT_MODEL_ID in seededModels)) { + seededModels[DEFAULT_MODEL_ID] = { + ...buildConfigModelEntry(defaultModel), + name: `Default (${defaultModel.name})`, + }; + } + + config.provider[PROVIDER_ID] = { + ...existing, + name: + typeof existing.name === "string" && existing.name.trim() + ? existing.name + : "Command Code", + npm: existing.npm ?? OPENAI_COMPATIBLE_NPM, + options: { + baseURL, + apiKey: "command-code-proxy", + includeUsage: true, + ...existingOptions, + }, + models: { + ...seededModels, + ...existingModels, + }, + }; +} + +async function resolveAccessToken( + input: PluginInput, + getAuth: () => Promise, +): Promise { + const auth = await getAuth(); + if (isCommandAuth(auth)) { + const key = authKey(auth); + if (key) return key; + } + + const synced = syncCommandCodeCredentialsToOpenCode(); + if (synced) { + try { + await input.client.auth.set({ + path: { id: PROVIDER_ID }, + body: { + type: "api", + key: synced.key || synced.access, + }, + }); + } catch { + // auth.set may be unavailable in some hosts + } + return synced.access; + } + return null; +} + +async function loadRuntime( + input: PluginInput, + getAuth: () => Promise, + provider?: { models?: Record }, +): Promise<{ port: number; providerModels: Record } | undefined> { + await resolveAccessToken(input, getAuth); + const models = getCommandModels(); + await startProxy(async () => resolveAccessToken(input, getAuth)); + const providerModels = buildProviderModels(models); + if (provider) provider.models = providerModels; + return { port: getProxyPort() ?? 8797, providerModels }; +} + +/** + * OpenCode plugin that provides Command Code authentication and model access. + */ +export const CommandCodePlugin: Plugin = async ( + input: PluginInput, +): Promise => { + try { + syncCommandCodeCredentialsToOpenCode(); + } catch (err) { + log.warn( + "[opencode-commandcode] CLI credential sync skipped", + err instanceof Error ? err.message : err, + ); + } + + return { + async config(config) { + // Always seed Laguna catalog so the provider is discoverable pre-login. + ensureProviderConfig(config as Record, getCommandModels()); + + await startProxy(async () => { + try { + const authClient = input.client.auth as { + get?: (args: { path: { id: string } }) => Promise; + }; + if (typeof authClient.get === "function") { + const auth = await authClient.get({ path: { id: PROVIDER_ID } }); + const payload = + auth && typeof auth === "object" && "data" in auth + ? (auth as { data: unknown }).data + : auth; + return resolveAccessToken(input, async () => payload); + } + } catch { + // ignore + } + const synced = syncCommandCodeCredentialsToOpenCode(); + return synced?.access ?? null; + }); + }, + + "chat.headers": async (hookInput, output) => { + if (hookInput.model.providerID !== PROVIDER_ID) return; + const messageModel = hookInput.message.model as { + variant?: unknown; + }; + const variant = + typeof messageModel.variant === "string" + ? messageModel.variant + : undefined; + const selected = resolveCommandModelSelection( + hookInput.model.id, + variant, + ); + output.headers[EFFORT_HEADER] = encodeCommandModelSelection(selected); + if (hookInput.sessionID) { + output.headers[SESSION_HEADER] = hookInput.sessionID; + } + }, + + "chat.params": async (hookInput, output) => { + if (hookInput.model.providerID !== PROVIDER_ID) return; + delete output.options.reasoningEffort; + }, + + provider: { + id: PROVIDER_ID, + async models(provider, ctx) { + const runtime = await loadRuntime( + input, + async () => ctx.auth, + provider, + ); + return (runtime?.providerModels ?? {}) as Record; + }, + }, + + auth: { + provider: PROVIDER_ID, + + async loader(getAuth, provider) { + const runtime = await loadRuntime(input, getAuth, provider); + if (!runtime) return {}; + + return { + baseURL: getCommandProxyBaseUrl(), + apiKey: "command-code-proxy", + async fetch( + requestInput: RequestInfo | URL, + init?: RequestInit, + ) { + if (init?.headers) { + if (init.headers instanceof Headers) { + init.headers.delete("authorization"); + } else if (Array.isArray(init.headers)) { + init.headers = init.headers.filter( + ([key]) => key.toLowerCase() !== "authorization", + ); + } else { + delete (init.headers as Record).authorization; + delete (init.headers as Record).Authorization; + } + } + return fetch(requestInput, init); + }, + }; + }, + + methods: [ + { + type: "oauth", + label: "Login with Command Code (Go $1)", + async authorize() { + // Prefer already-logged-in CLI session from `cmd login`. + const synced = syncCommandCodeCredentialsToOpenCode(); + if (synced) { + return { + url: "https://commandcode.ai/docs/plans/go", + instructions: + "Command Code Go-plan session found (from `cmd login`). Click Complete — no API key needed.", + method: "auto" as const, + async callback() { + return { + type: "success" as const, + refresh: synced.refresh, + access: synced.access, + expires: synced.expires, + }; + }, + }; + } + + let current = getPendingCommandLogin(); + if (!current || current.completed) { + current = await startCommandBrowserLogin(); + } + + return { + url: current.url, + instructions: + "Opens Command Code Studio CLI login (Go plan, $1/mo). Approve in the browser — session is posted back automatically. Laguna S 2.1 free costs $0 credits. No API key.", + method: "auto" as const, + async callback() { + try { + const tokens = await completeCommandBrowserLogin(); + return { + type: "success" as const, + refresh: tokens.refresh, + access: tokens.access, + expires: tokens.expires, + }; + } catch (err) { + resetPendingCommandLogin(); + log.error( + "[opencode-commandcode] Go-plan OAuth failed", + err instanceof Error ? err.message : err, + ); + return { type: "failed" as const }; + } + }, + }; + }, + }, + { + type: "oauth", + label: "Use existing `cmd login` session", + async authorize() { + return { + url: "https://commandcode.ai/docs/quickstart", + instructions: + "If you already ran `npm i -g command-code@latest && cmd login` (Go $1 plan), click Complete to sync ~/.commandcode/auth.json. No API key.", + method: "auto" as const, + async callback() { + const again = syncCommandCodeCredentialsToOpenCode(); + if (!again) return { type: "failed" as const }; + return { + type: "success" as const, + refresh: again.refresh, + access: again.access, + expires: again.expires, + }; + }, + }; + }, + }, + ], + }, + }; +}; + +export default CommandCodePlugin; + +export { detectCommandCode } from "./detect.js"; +export { getCommandModels, COMMAND_CODE_MODELS } from "./models.js"; +export { + startProxy, + stopProxy, + getCommandProxyBaseUrl, + setStreamGenerateForTests, +} from "./proxy.js"; +export { + getSessionUsage, + listSessionUsage, + totalUsageAcrossSessions, + resetUsageStore, +} from "./usage.js"; diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..578c26d --- /dev/null +++ b/src/log.ts @@ -0,0 +1,50 @@ +import { + COMMAND_CODE_API_KEY_ENV, + COMMAND_CODE_AUTH_FILE, + COMMAND_CODE_DIR_NAME, + DEFAULT_API_BASE_URL, +} from "./constants.js"; + +const DEBUG = + process.env.OPENCODE_COMMANDCODE_DEBUG === "1" || + process.env.OPENCODE_COMMANDCODE_DEBUG === "true"; + +function stamp(): string { + return new Date().toISOString(); +} + +export const log = { + info(...args: unknown[]) { + if (DEBUG) console.error(`[opencode-commandcode ${stamp()}]`, ...args); + }, + warn(...args: unknown[]) { + console.error(`[opencode-commandcode ${stamp()} WARN]`, ...args); + }, + error(...args: unknown[]) { + console.error(`[opencode-commandcode ${stamp()} ERROR]`, ...args); + }, +}; + +export function getApiBaseUrl( + env: NodeJS.ProcessEnv | Record = process.env, +): string { + const raw = + typeof env.COMMANDCODE_API_URL === "string" + ? env.COMMANDCODE_API_URL.trim() + : typeof env.OPENCODE_COMMANDCODE_API_URL === "string" + ? env.OPENCODE_COMMANDCODE_API_URL.trim() + : ""; + return (raw || DEFAULT_API_BASE_URL).replace(/\/$/, ""); +} + +export function getCommandCodeDirName(): string { + return COMMAND_CODE_DIR_NAME; +} + +export function getCommandCodeAuthFileName(): string { + return COMMAND_CODE_AUTH_FILE; +} + +export function getApiKeyEnvName(): string { + return COMMAND_CODE_API_KEY_ENV; +} diff --git a/src/model-selection.ts b/src/model-selection.ts new file mode 100644 index 0000000..bd525a0 --- /dev/null +++ b/src/model-selection.ts @@ -0,0 +1,47 @@ +import { + EFFORT_HEADER, + isCommandEffort, + type CommandEffort, + DEFAULT_MODEL_ID, +} from "./constants.js"; +import { resolveCommandModelId } from "./models.js"; + +export { EFFORT_HEADER }; + +export type CommandModelSelection = { + modelId: string; + effort?: CommandEffort; +}; + +export function resolveCommandModelSelection( + modelId: string, + variant?: string, +): CommandModelSelection { + const resolved = resolveCommandModelId(modelId || DEFAULT_MODEL_ID); + const effort = isCommandEffort(variant) ? variant : undefined; + return effort ? { modelId: resolved, effort } : { modelId: resolved }; +} + +export function encodeCommandModelSelection( + selection: CommandModelSelection, +): string { + return selection.effort + ? `${selection.modelId}|${selection.effort}` + : selection.modelId; +} + +export function decodeCommandModelSelection( + header: string | null | undefined, +): CommandModelSelection | null { + if (!header || typeof header !== "string") return null; + const trimmed = header.trim(); + if (!trimmed) return null; + const pipe = trimmed.indexOf("|"); + if (pipe < 0) { + return { modelId: resolveCommandModelId(trimmed) }; + } + const modelId = resolveCommandModelId(trimmed.slice(0, pipe)); + const effortRaw = trimmed.slice(pipe + 1); + const effort = isCommandEffort(effortRaw) ? effortRaw : undefined; + return effort ? { modelId, effort } : { modelId }; +} diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..75fecbf --- /dev/null +++ b/src/models.ts @@ -0,0 +1,143 @@ +/** + * Command Code model catalog — Laguna S 2.1 free is the primary target. + */ +import { EFFORT_LEVELS, type CommandEffort, LAGUNA_MODEL_ID } from "./constants.js"; + +export type CommandModel = { + id: string; + name: string; + resolvedId: string; + reasoning: boolean; + contextWindow: number; + maxTokens: number; + /** Vision / image input support on the upstream model. */ + vision: boolean; + /** Free-tier badge. */ + free?: boolean; +}; + +const LIMIT_256K = { context: 256_000, output: 32_768 } as const; + +function model( + id: string, + name: string, + resolvedId: string, + limit: { context: number; output: number }, + opts?: { vision?: boolean; free?: boolean; reasoning?: boolean }, +): CommandModel { + return { + id, + name, + resolvedId, + reasoning: opts?.reasoning !== false, + contextWindow: limit.context, + maxTokens: limit.output, + vision: opts?.vision === true, + free: opts?.free, + }; +} + +/** + * Primary catalog for this plugin. Laguna is the default and exclusive + * live-test target; aliases keep OpenCode menus short. + */ +export const COMMAND_CODE_MODELS: CommandModel[] = [ + model("laguna-s-2.1-free", "Laguna S 2.1 (free)", LAGUNA_MODEL_ID, LIMIT_256K, { + free: true, + vision: false, + }), + model("laguna", "Laguna S 2.1 (free)", LAGUNA_MODEL_ID, LIMIT_256K, { + free: true, + vision: false, + }), + model( + "poolside/laguna-s-2.1-free", + "Laguna S 2.1 (free)", + LAGUNA_MODEL_ID, + LIMIT_256K, + { free: true, vision: false }, + ), +]; + +/** Placeholder so OpenCode keeps the provider visible while logged out. */ +export const LOGIN_PLACEHOLDER_MODELS: CommandModel[] = [ + { + id: "login", + name: "Sign in to Command Code", + resolvedId: "login", + reasoning: false, + contextWindow: 256_000, + maxTokens: 8_192, + vision: false, + }, +]; + +export const GENERATED_VARIANT_KEYS = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + +export function isLoginPlaceholderModel(id: string): boolean { + return id === "login"; +} + +export function getCommandModels(): CommandModel[] { + // Deduplicate by id while preferring shorter aliases first. + const seen = new Set(); + const out: CommandModel[] = []; + for (const m of COMMAND_CODE_MODELS) { + if (seen.has(m.id)) continue; + seen.add(m.id); + out.push(m); + } + return out; +} + +export function resolveCommandModelId(modelId: string): string { + const cleaned = modelId.replace(/^command-code\//, "").trim(); + const match = COMMAND_CODE_MODELS.find( + (m) => m.id === cleaned || m.resolvedId === cleaned, + ); + if (match) return match.resolvedId; + // Accept short name after last slash. + if (!cleaned.includes("/")) { + const bySuffix = COMMAND_CODE_MODELS.find((m) => + m.resolvedId.endsWith(`/${cleaned}`), + ); + if (bySuffix) return bySuffix.resolvedId; + } + return cleaned || LAGUNA_MODEL_ID; +} + +export function findCommandModel(modelId: string): CommandModel | undefined { + const resolved = resolveCommandModelId(modelId); + return ( + COMMAND_CODE_MODELS.find((m) => m.id === modelId || m.resolvedId === resolved) || + COMMAND_CODE_MODELS.find((m) => m.resolvedId === resolved) + ); +} + +export function buildEffortVariants( + model: CommandModel, +): Record { + if (!model.reasoning || isLoginPlaceholderModel(model.id)) return {}; + const variants: Record< + string, + { effort: CommandEffort } | { disabled: true } + > = Object.fromEntries(EFFORT_LEVELS.map((effort) => [effort, { effort }])); + for (const key of GENERATED_VARIANT_KEYS) { + if (!(key in variants)) variants[key] = { disabled: true }; + } + return variants; +} + +export function buildConfigVariants( + model: CommandModel, +): Record { + return buildEffortVariants(model); +} diff --git a/src/prompt.ts b/src/prompt.ts new file mode 100644 index 0000000..243035f --- /dev/null +++ b/src/prompt.ts @@ -0,0 +1,392 @@ +/** + * Build Command Code wire messages from OpenAI-compatible chat messages, + * including text, images, PDFs, and generic file attachments. + * + * Laguna S 2.1 is text-only — images become placeholders (matching Command Code + * stripImages behavior) while text/PDF/file payloads are inlined when possible. + */ +import type { + WireAssistantContent, + WireMessage, + WireTool, + WireUserContent, +} from "./gateway-types.js"; + +const IMAGE_OMITTED = + "[image omitted: the active model is text-only]"; + +function parseDataUrl(url: string): { + mediaType: string; + data: string; +} | null { + const match = /^data:([^;,]+)?(?:;charset=[^;,]+)?;base64,(.+)$/i.exec( + url.trim(), + ); + if (!match) return null; + return { + mediaType: (match[1] || "application/octet-stream").toLowerCase(), + data: match[2], + }; +} + +function isHttpUrl(url: string): boolean { + return /^https?:\/\//i.test(url.trim()); +} + +function mediaLooksLikePdf(mediaType: string, urlHint = ""): boolean { + return ( + mediaType.includes("pdf") || + /\.pdf(\?|#|$)/i.test(urlHint) || + (mediaType === "application/octet-stream" && + /\.pdf(\?|#|$)/i.test(urlHint)) + ); +} + +function mediaLooksLikeImage(mediaType: string): boolean { + return mediaType.startsWith("image/"); +} + +function mediaLooksLikeText(mediaType: string, name = ""): boolean { + if (mediaType.startsWith("text/")) return true; + if ( + /json|xml|javascript|typescript|yaml|toml|markdown|csv|html|css/.test( + mediaType, + ) + ) { + return true; + } + return /\.(txt|md|json|js|ts|tsx|jsx|py|rs|go|java|c|cpp|h|css|html|yml|yaml|toml|csv|xml|sh|bash|zsh|sql)$/i.test( + name, + ); +} + +function tryDecodeBase64Text(data: string, limit = 200_000): string | null { + try { + const buf = Buffer.from(data, "base64"); + if (buf.length > limit) { + return ( + buf.subarray(0, limit).toString("utf8") + + `\n…[truncated ${buf.length - limit} bytes]` + ); + } + const text = buf.toString("utf8"); + // Reject if mostly non-printable. + const sample = text.slice(0, 2000); + const bad = [...sample].filter((ch) => { + const code = ch.charCodeAt(0); + return code < 9 || (code > 13 && code < 32); + }).length; + if (bad > sample.length * 0.05) return null; + return text; + } catch { + return null; + } +} + +export function extractTextContent(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((part) => { + if (!part || typeof part !== "object") return ""; + const p = part as { type?: string; text?: string }; + if (p.type === "text" && typeof p.text === "string") return p.text; + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +export function contentHasAttachments(content: unknown): boolean { + if (!Array.isArray(content)) return false; + return content.some((part) => { + if (!part || typeof part !== "object") return false; + const type = (part as { type?: unknown }).type; + return ( + type === "image_url" || + type === "input_image" || + type === "file" || + type === "input_file" || + type === "image" || + type === "document" + ); + }); +} + +type AttachmentSink = { + texts: string[]; + images: Array<{ dataUrl: string; mimeType: string }>; + vision: boolean; +}; + +function pushAttachmentPart(part: unknown, sink: AttachmentSink): void { + if (!part || typeof part !== "object") return; + const p = part as Record; + const type = typeof p.type === "string" ? p.type : ""; + + if (type === "text" && typeof p.text === "string") { + sink.texts.push(p.text); + return; + } + + if (type === "image_url" || type === "input_image" || type === "image") { + const imageUrl = p.image_url ?? p.image ?? p.url; + const url = + typeof imageUrl === "string" + ? imageUrl + : imageUrl && + typeof imageUrl === "object" && + typeof (imageUrl as { url?: unknown }).url === "string" + ? (imageUrl as { url: string }).url + : typeof p.data === "string" + ? `data:${typeof p.media_type === "string" ? p.media_type : "image/png"};base64,${p.data}` + : null; + if (!url) return; + const parsed = parseDataUrl(url); + if (parsed && mediaLooksLikeImage(parsed.mediaType)) { + if (sink.vision) { + sink.images.push({ + dataUrl: url, + mimeType: parsed.mediaType, + }); + } else { + sink.texts.push( + `\n${IMAGE_OMITTED}\n(media_type=${parsed.mediaType})\n`, + ); + } + return; + } + if (isHttpUrl(url)) { + if (sink.vision) { + sink.images.push({ dataUrl: url, mimeType: "image/*" }); + } else { + sink.texts.push( + `\n${IMAGE_OMITTED}\n(url=${url})\n`, + ); + } + } + return; + } + + if (type === "file" || type === "input_file" || type === "document") { + const file = (p.file && typeof p.file === "object" ? p.file : p) as Record< + string, + unknown + >; + const url = typeof file.url === "string" ? file.url : null; + const data = typeof file.data === "string" ? file.data : null; + const mediaType = + typeof file.media_type === "string" + ? file.media_type + : typeof file.mime_type === "string" + ? file.mime_type + : "application/octet-stream"; + const name = + typeof file.filename === "string" + ? file.filename + : typeof file.name === "string" + ? file.name + : ""; + + if (url) { + const parsed = parseDataUrl(url); + if (parsed) { + handleBinaryAttachment(sink, parsed.mediaType, parsed.data, name); + return; + } + if (isHttpUrl(url)) { + sink.texts.push( + `\nRemote file URL: ${url}\n`, + ); + } + return; + } + if (data) { + handleBinaryAttachment(sink, mediaType, data, name); + } + } +} + +function handleBinaryAttachment( + sink: AttachmentSink, + mediaType: string, + data: string, + name: string, +): void { + if (mediaLooksLikeImage(mediaType)) { + const dataUrl = `data:${mediaType};base64,${data}`; + if (sink.vision) { + sink.images.push({ dataUrl, mimeType: mediaType }); + } else { + sink.texts.push( + `\n${IMAGE_OMITTED}\n`, + ); + } + return; + } + + if (mediaLooksLikePdf(mediaType, name)) { + sink.texts.push( + `\n[PDF attachment base64 length=${data.length}. The active model is text-only; summarize from filename/context or ask the user for extracted text.]\n`, + ); + return; + } + + if (mediaLooksLikeText(mediaType, name)) { + const decoded = tryDecodeBase64Text(data); + if (decoded !== null) { + sink.texts.push( + `\n${decoded}\n`, + ); + return; + } + } + + sink.texts.push( + `\n[binary attachment, base64 length=${data.length}]\n`, + ); +} + +export function openaiContentToUserParts( + content: unknown, + vision: boolean, +): WireUserContent { + if (typeof content === "string") { + const text = content.trim(); + return text ? [{ type: "text", text }] : []; + } + if (!Array.isArray(content)) return []; + + const sink: AttachmentSink = { texts: [], images: [], vision }; + for (const part of content) pushAttachmentPart(part, sink); + + const out: WireUserContent = []; + for (const text of sink.texts) { + if (text.trim()) out.push({ type: "text", text }); + } + for (const img of sink.images) { + out.push({ + type: "image", + image: img.dataUrl, + mimeType: img.mimeType, + }); + } + return out; +} + +export type OpenAITool = { + type?: string; + function?: { + name?: string; + description?: string; + parameters?: Record; + }; +}; + +export type OpenAIMessage = { + role?: string; + content?: unknown; + tool_calls?: Array<{ + id?: string; + type?: string; + function?: { name?: string; arguments?: string }; + }>; + tool_call_id?: string; + name?: string; +}; + +export function openaiToolsToWire(tools: OpenAITool[]): WireTool[] { + return tools + .map((t) => { + const name = t.function?.name; + if (!name) return null; + return { + name, + description: t.function?.description || name, + input_schema: + (t.function?.parameters as Record) || { + type: "object", + properties: {}, + }, + }; + }) + .filter((t): t is WireTool => Boolean(t)); +} + +/** + * Convert a full OpenAI chat history into Command Code wire messages. + * System messages are collected separately. + */ +export function openaiMessagesToWire( + messages: OpenAIMessage[], + options: { vision: boolean }, +): { system: string; messages: WireMessage[] } { + const systemParts: string[] = []; + const wire: WireMessage[] = []; + + for (const msg of messages) { + const role = msg.role || ""; + if (role === "system" || role === "developer") { + const text = extractTextContent(msg.content).trim(); + if (text) systemParts.push(text); + continue; + } + + if (role === "user") { + const parts = openaiContentToUserParts(msg.content, options.vision); + if (parts.length > 0) wire.push({ role: "user", content: parts }); + continue; + } + + if (role === "assistant") { + const content: WireAssistantContent = []; + const text = extractTextContent(msg.content).trim(); + if (text) content.push({ type: "text", text }); + if (Array.isArray(msg.tool_calls)) { + for (const tc of msg.tool_calls) { + const id = tc.id || ""; + const name = tc.function?.name || ""; + if (!id || !name) continue; + let input: Record = {}; + try { + input = JSON.parse(tc.function?.arguments || "{}") as Record< + string, + unknown + >; + } catch { + input = { raw: tc.function?.arguments || "" }; + } + content.push({ + type: "tool-call", + toolCallId: id, + toolName: name, + input, + }); + } + } + if (content.length > 0) wire.push({ role: "assistant", content }); + continue; + } + + if (role === "tool") { + const toolCallId = msg.tool_call_id || ""; + if (!toolCallId) continue; + wire.push({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId, + toolName: msg.name || "", + output: { + type: "text", + value: extractTextContent(msg.content), + }, + }, + ], + }); + } + } + + return { system: systemParts.join("\n\n"), messages: wire }; +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..85e7e65 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,829 @@ +/** + * Local OpenAI-compatible proxy → Command Code /alpha/generate gateway. + * + * Accepts POST /v1/chat/completions, streams OpenAI-format SSE. + * Tool calls park (bridge-pool) until OpenCode returns tool results. + */ +import { randomUUID } from "node:crypto"; +import { + deleteBridge, + findBridgeByConversation, + findBridgeByPendingTool, + putBridge, + type ParkedBridge, + type ParkedToolCall, +} from "./bridge-pool.js"; +import { assessContext, compactWireMessages, estimateMessageTokens } from "./compact.js"; +import { + DEFAULT_MODEL_ID, + LAGUNA_MODEL_ID, + SESSION_HEADER, +} from "./constants.js"; +import { + decodeCommandModelSelection, + EFFORT_HEADER, +} from "./model-selection.js"; +import { findCommandModel, getCommandModels, resolveCommandModelId } from "./models.js"; +import { + extractTextContent, + openaiMessagesToWire, + openaiToolsToWire, + type OpenAIMessage, + type OpenAITool, +} from "./prompt.js"; +import { + stableCompletionId, + streamGenerate, + type GatewayMappedEvent, +} from "./gateway.js"; +import type { WireMessage, WireTool, WireUsage } from "./gateway-types.js"; +import { emptyUsage } from "./gateway-types.js"; +import { log } from "./log.js"; +import { + conversationKeyFromMessages, + getSessionUsage, + listSessionUsage, + recordToolCall, + recordTurnUsage, + totalUsageAcrossSessions, + usageToOpenAI, +} from "./usage.js"; + +const DEFAULT_PROXY_PORT = 8797; +const SHARED_PROXY_HEALTH_TIMEOUT_MS = 750; + +const COMMAND_PROXY_PORT: number = (() => { + const raw = process.env.OPENCODE_COMMANDCODE_PROXY_PORT; + const parsed = raw ? Number(raw) : NaN; + return Number.isInteger(parsed) && parsed > 0 && parsed < 65536 + ? parsed + : DEFAULT_PROXY_PORT; +})(); + +const SSE_HEADERS = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", +} as const; + +type TokenProvider = () => Promise; + +type ChatCompletionRequest = { + model?: string; + messages?: OpenAIMessage[]; + tools?: OpenAITool[]; + stream?: boolean; + temperature?: number; + stream_options?: { include_usage?: boolean }; +}; + +let server: ReturnType | null = null; +let proxyPort: number | null = null; +let getAccessToken: TokenProvider | null = null; + +/** Optional injectors for tests. */ +let streamGenerateOverride: + | ((params: Parameters[0]) => ReturnType) + | null = null; + +export function setStreamGenerateForTests( + fn: typeof streamGenerateOverride, +): void { + streamGenerateOverride = fn; +} + +export function getCommandProxyBaseUrl(): string { + return `http://127.0.0.1:${COMMAND_PROXY_PORT}/v1`; +} + +export function getProxyPort(): number | null { + return proxyPort ?? COMMAND_PROXY_PORT; +} + +function isAddrInUseError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const code = (err as { code?: unknown }).code; + const message = (err as { message?: unknown }).message; + return ( + code === "EADDRINUSE" || + (typeof message === "string" && + /eaddrinuse|address already in use|in use/i.test(message)) + ); +} + +async function isSharedProxyHealthy(): Promise { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + SHARED_PROXY_HEALTH_TIMEOUT_MS, + ); + try { + const res = await fetch(`${getCommandProxyBaseUrl()}/models`, { + signal: controller.signal, + }); + if (!res.ok) return false; + const body = (await res.json().catch(() => undefined)) as + | { object?: unknown; data?: unknown } + | undefined; + return !!body && body.object === "list" && Array.isArray(body.data); + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + +export async function startProxy(tokenProvider: TokenProvider): Promise { + getAccessToken = tokenProvider; + if (server && proxyPort) return proxyPort; + + if (await isSharedProxyHealthy()) { + proxyPort = COMMAND_PROXY_PORT; + log.info( + `[opencode-commandcode] reusing healthy proxy on ${getCommandProxyBaseUrl()}`, + ); + return proxyPort; + } + + const hostname = "127.0.0.1"; + try { + server = Bun.serve({ + hostname, + port: COMMAND_PROXY_PORT, + async fetch(req) { + return handleRequest(req); + }, + }); + proxyPort = server.port ?? COMMAND_PROXY_PORT; + log.info( + `[opencode-commandcode] proxy listening on ${getCommandProxyBaseUrl()}`, + ); + return proxyPort; + } catch (err) { + if (isAddrInUseError(err) && (await isSharedProxyHealthy())) { + proxyPort = COMMAND_PROXY_PORT; + log.info( + `[opencode-commandcode] port ${COMMAND_PROXY_PORT} in use; reusing existing proxy`, + ); + return proxyPort; + } + throw err; + } +} + +export async function stopProxy(): Promise { + if (server) { + server.stop(true); + server = null; + proxyPort = null; + } + setStreamGenerateForTests(null); +} + +async function handleRequest(req: Request): Promise { + const url = new URL(req.url); + + if ( + req.method === "GET" && + (url.pathname === "/health" || url.pathname === "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/v1/health") + ) { + return Response.json({ ok: true, provider: "command-code" }); + } + + if (req.method === "GET" && url.pathname === "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/v1/models") { + return Response.json({ + object: "list", + data: getCommandModels().map((m) => ({ + id: m.id, + object: "model", + owned_by: "command-code", + })), + }); + } + + if (req.method === "GET" && url.pathname === "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/v1/usage") { + return Response.json({ + object: "usage", + total: usageToOpenAI(totalUsageAcrossSessions()), + sessions: listSessionUsage().map((s) => ({ + ...s, + usage: usageToOpenAI(s.usage), + mcpTools: s.tools.filter((t) => t.mcp), + localTools: s.tools.filter((t) => !t.mcp), + })), + }); + } + + if ( + req.method === "GET" && + url.pathname.startsWith("/v1/usage/session/") + ) { + const sessionId = decodeURIComponent( + url.pathname.slice("/v1/usage/session/".length), + ); + const snap = getSessionUsage(sessionId); + if (!snap) { + return Response.json( + { error: { message: "session not found", type: "invalid_request_error" } }, + { status: 404 }, + ); + } + return Response.json({ + ...snap, + usage: usageToOpenAI(snap.usage), + mcpTools: snap.tools.filter((t) => t.mcp), + localTools: snap.tools.filter((t) => !t.mcp), + }); + } + + if (req.method === "POST" && url.pathname === "/v1/chat/completions") { + try { + const body = (await req.json()) as ChatCompletionRequest; + return await handleChatCompletions(req, body); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error("[opencode-commandcode] chat completions error", message); + return Response.json( + { error: { message, type: "server_error" } }, + { status: 500 }, + ); + } + } + + return new Response("Not Found", { status: 404 }); +} + +function collectToolResults(messages: OpenAIMessage[]): Map { + const results = new Map(); + for (const msg of messages) { + if (msg.role !== "tool" || !msg.tool_call_id) continue; + results.set(msg.tool_call_id, extractTextContent(msg.content)); + } + return results; +} + +function selectionFromRequest( + req: Request, + body: ChatCompletionRequest, +): { modelId: string; effort?: string } { + const header = req.headers.get(EFFORT_HEADER); + const decoded = decodeCommandModelSelection(header); + const modelId = + decoded?.modelId || + (typeof body.model === "string" + ? resolveCommandModelId(body.model) + : LAGUNA_MODEL_ID); + return { + modelId, + ...(decoded?.effort ? { effort: decoded.effort } : {}), + }; +} + +async function handleChatCompletions( + req: Request, + body: ChatCompletionRequest, +): Promise { + const messages = Array.isArray(body.messages) ? body.messages : []; + const conversationKey = + req.headers.get(SESSION_HEADER) || + conversationKeyFromMessages(messages); + const selection = selectionFromRequest(req, body); + const modelMeta = + findCommandModel(selection.modelId) || + findCommandModel(DEFAULT_MODEL_ID)!; + const model = resolveCommandModelId(selection.modelId); + const stream = body.stream !== false; + const includeUsage = body.stream_options?.include_usage !== false; + + const toolResults = collectToolResults(messages); + let existing = findBridgeByConversation(conversationKey); + if ((!existing || existing.pendingTools.size === 0) && toolResults.size > 0) { + for (const toolCallId of toolResults.keys()) { + const byTool = findBridgeByPendingTool(toolCallId); + if (byTool) { + existing = byTool; + break; + } + } + } + + // Resume parked tool turn: append tool results and call gateway again. + if (existing && existing.pendingTools.size > 0 && toolResults.size > 0) { + let resolved = 0; + for (const [toolId, tool] of existing.pendingTools) { + const result = toolResults.get(toolId); + if (result !== undefined) { + tool.resolve(result); + existing.pendingTools.delete(toolId); + resolved++; + } + } + if (existing.pendingTools.size === 0 && existing.continueStream) { + log.info("[opencode-commandcode] resuming parked bridge", { + conversationKey: existing.conversationKey, + resolved, + }); + return streamOpenAIResponse( + existing.continueStream(), + body.model || model, + stream, + existing, + includeUsage, + ); + } + if (resolved === 0) { + return streamOpenAIResponse( + (async function* () { + yield { + type: "__park__", + tools: [...existing!.pendingTools.values()], + }; + })(), + body.model || model, + stream, + existing, + includeUsage, + ); + } + } + + const apiKey = getAccessToken ? await getAccessToken() : null; + if (!apiKey) { + return Response.json( + { + error: { + message: + "Not authenticated with Command Code. Run `cmd login` or set COMMAND_CODE_API_KEY, then `opencode auth login --provider command-code`.", + type: "authentication_error", + }, + }, + { status: 401 }, + ); + } + + const openCodeTools = Array.isArray(body.tools) ? body.tools : []; + const { system, messages: wireMessages } = openaiMessagesToWire(messages, { + vision: modelMeta.vision, + }); + if (wireMessages.length === 0 && openCodeTools.length === 0) { + return Response.json( + { + error: { + message: "No user message found", + type: "invalid_request_error", + }, + }, + { status: 400 }, + ); + } + + // Context / compact pre-check + const estimated = estimateMessageTokens(wireMessages); + const advice = assessContext(estimated, modelMeta.contextWindow); + let finalMessages = wireMessages; + let compactNote: string | null = advice.message; + if (advice.shouldCompact || advice.tier === "warn") { + const compacted = compactWireMessages(wireMessages, { + sessionId: conversationKey, + keepToolResults: advice.tier === "auto" ? 10 : 20, + }); + finalMessages = compacted.messages; + if (compacted.note) compactNote = compacted.note; + } + + const wireTools = openaiToolsToWire(openCodeTools); + const bridgeId = randomUUID(); + const pendingTools = new Map(); + + const bridge: ParkedBridge = { + id: bridgeId, + conversationKey, + pendingTools, + createdAt: Date.now(), + wireMessages: finalMessages, + modelId: model, + system, + effort: selection.effort, + tools: wireTools, + apiKey, + contextWindow: modelMeta.contextWindow, + maxTokens: modelMeta.maxTokens, + }; + putBridge(bridge); + + async function* runTurn( + msgs: WireMessage[], + tools: WireTool[], + preface?: string | null, + ): AsyncGenerator { + if (preface) { + yield { type: "__reasoning__", text: `${preface}\n` }; + } + + const gen = (streamGenerateOverride || streamGenerate)({ + apiKey: apiKey!, + model, + messages: msgs, + tools, + system: system || undefined, + maxTokens: modelMeta.maxTokens, + temperature: body.temperature, + effort: selection.effort, + permissionMode: tools.length > 0 ? "auto-accept" : "auto-accept", + sessionId: conversationKey, + threadId: undefined, + }); + + const assistantToolCalls: ParkedToolCall[] = []; + let turnUsage: WireUsage = emptyUsage(); + let systemPromptTokens: number | undefined; + let finishReason = "stop"; + + for await (const event of gen) { + if (event.kind === "text" || event.kind === "reasoning") { + yield event; + continue; + } + if (event.kind === "error") { + yield event; + continue; + } + if (event.kind === "tool_call") { + if (event.providerExecuted) { + // Server-executed tool — surface as reasoning note, do not park. + yield { + kind: "reasoning", + text: `[mcp/tool] ${event.name} (provider-executed)\n`, + } satisfies GatewayMappedEvent; + recordToolCall(conversationKey, event.name); + continue; + } + recordToolCall(conversationKey, event.name); + const pending: ParkedToolCall = { + id: event.id, + name: event.name, + arguments: event.arguments, + resolve: () => {}, + reject: () => {}, + }; + pending.resultPromise = new Promise((resolve, reject) => { + pending.resolve = resolve; + pending.reject = reject; + }); + pendingTools.set(event.id, pending); + assistantToolCalls.push(pending); + continue; + } + if (event.kind === "finish") { + turnUsage = event.usage; + systemPromptTokens = event.systemPromptTokens; + finishReason = event.finishReason; + recordTurnUsage( + conversationKey, + model, + modelMeta.contextWindow, + turnUsage, + systemPromptTokens, + ); + // After finish, if we collected client tools, park for OpenCode. + if (assistantToolCalls.length > 0) { + finishReason = "tool_calls"; + const assistantContent = assistantToolCalls.map((t) => ({ + type: "tool-call" as const, + toolCallId: t.id, + toolName: t.name, + input: safeParse(t.arguments), + })); + const nextMessages: WireMessage[] = [ + ...msgs, + { role: "assistant", content: assistantContent }, + ]; + bridge.wireMessages = nextMessages; + bridge.continueStream = async function* () { + const toolMsgs: WireMessage[] = []; + for (const tool of assistantToolCalls) { + const result = await tool.resultPromise!; + toolMsgs.push({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: tool.id, + toolName: tool.name, + output: { type: "text", value: result }, + }, + ], + }); + } + const resumed = [ + ...(bridge.wireMessages as WireMessage[]), + ...toolMsgs, + ]; + bridge.wireMessages = resumed; + pendingTools.clear(); + yield* runTurn(resumed, tools, null); + }; + yield { type: "__park__", tools: assistantToolCalls }; + return; + } + yield { + kind: "finish", + finishReason, + usage: turnUsage, + systemPromptTokens, + } satisfies GatewayMappedEvent; + deleteBridge(bridgeId); + return; + } + } + + // Stream ended without explicit finish. + recordTurnUsage( + conversationKey, + model, + modelMeta.contextWindow, + turnUsage, + systemPromptTokens, + ); + yield { + kind: "finish", + finishReason, + usage: turnUsage, + systemPromptTokens, + } satisfies GatewayMappedEvent; + if (pendingTools.size === 0) deleteBridge(bridgeId); + } + + return streamOpenAIResponse( + runTurn(finalMessages, wireTools, compactNote), + body.model || model, + stream, + bridge, + includeUsage, + ); +} + +function safeParse(raw: string): Record { + try { + const v = JSON.parse(raw); + return v && typeof v === "object" && !Array.isArray(v) + ? (v as Record) + : { value: v }; + } catch { + return { raw }; + } +} + +function streamOpenAIResponse( + events: AsyncIterable, + model: string, + stream: boolean, + bridge: ParkedBridge, + includeUsage: boolean, +): Response { + const completionId = stableCompletionId(bridge.id); + const created = Math.floor(Date.now() / 1000); + + if (!stream) { + const bodyStream = new ReadableStream({ + async start(controller) { + try { + let content = ""; + let reasoning = ""; + const toolCalls: ParkedToolCall[] = []; + let usage = emptyUsage(); + for await (const event of events) { + const mapped = normalizeEvent(event); + if (mapped.kind === "park") toolCalls.push(...mapped.tools); + else if (mapped.kind === "text") content += mapped.text; + else if (mapped.kind === "reasoning") reasoning += mapped.text; + else if (mapped.kind === "finish") usage = mapped.usage; + else if (mapped.kind === "error") content += `\n\n[command-code error] ${mapped.text}`; + } + const payload = { + id: completionId, + object: "chat.completion", + created, + model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content, + ...(reasoning ? { reasoning_content: reasoning } : {}), + ...(toolCalls.length + ? { + tool_calls: toolCalls.map((t) => ({ + id: t.id, + type: "function", + function: { + name: t.name, + arguments: t.arguments, + }, + })), + } + : {}), + }, + finish_reason: toolCalls.length ? "tool_calls" : "stop", + }, + ], + ...(includeUsage ? { usage: usageToOpenAI(usage) } : {}), + }; + controller.enqueue( + new TextEncoder().encode(JSON.stringify(payload)), + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + controller.enqueue( + new TextEncoder().encode( + JSON.stringify({ error: { message, type: "server_error" } }), + ), + ); + } finally { + controller.close(); + } + }, + }); + return new Response(bodyStream, { + headers: { "Content-Type": "application/json" }, + }); + } + + const encoder = new TextEncoder(); + const readable = new ReadableStream({ + async start(controller) { + const send = (payload: unknown) => { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(payload)}\n\n`), + ); + }; + + send({ + id: completionId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { index: 0, delta: { role: "assistant" }, finish_reason: null }, + ], + }); + + let finishReason: string | null = "stop"; + let usage = emptyUsage(); + + try { + for await (const event of events) { + const mapped = normalizeEvent(event); + if (mapped.kind === "park") { + finishReason = "tool_calls"; + for (let i = 0; i < mapped.tools.length; i++) { + const tool = mapped.tools[i]; + send({ + id: completionId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: i, + id: tool.id, + type: "function", + function: { + name: tool.name, + arguments: tool.arguments, + }, + }, + ], + }, + finish_reason: null, + }, + ], + }); + } + break; + } + if (mapped.kind === "text" && mapped.text) { + send({ + id: completionId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { content: mapped.text }, + finish_reason: null, + }, + ], + }); + } + if (mapped.kind === "reasoning" && mapped.text) { + send({ + id: completionId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { reasoning_content: mapped.text }, + finish_reason: null, + }, + ], + }); + } + if (mapped.kind === "finish") { + finishReason = mapped.finishReason; + usage = mapped.usage; + } + if (mapped.kind === "error") { + send({ + id: completionId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { + content: `\n\n[command-code error] ${mapped.text}`, + }, + finish_reason: null, + }, + ], + }); + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + send({ + id: completionId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { content: `\n\n[command-code error] ${message}` }, + finish_reason: null, + }, + ], + }); + } + + send({ + id: completionId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: finishReason }], + ...(includeUsage ? { usage: usageToOpenAI(usage) } : {}), + }); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + + return new Response(readable, { headers: SSE_HEADERS }); +} + +type Normalized = + | { kind: "text"; text: string } + | { kind: "reasoning"; text: string } + | { kind: "park"; tools: ParkedToolCall[] } + | { kind: "finish"; finishReason: string; usage: WireUsage } + | { kind: "error"; text: string } + | { kind: "ignore" }; + +function normalizeEvent(event: unknown): Normalized { + if (!event || typeof event !== "object") return { kind: "ignore" }; + const e = event as Record; + if (e.type === "__park__" && Array.isArray(e.tools)) { + return { kind: "park", tools: e.tools as ParkedToolCall[] }; + } + if (e.type === "__reasoning__" && typeof e.text === "string") { + return { kind: "reasoning", text: e.text }; + } + if (e.kind === "text" && typeof e.text === "string") { + return { kind: "text", text: e.text }; + } + if (e.kind === "reasoning" && typeof e.text === "string") { + return { kind: "reasoning", text: e.text }; + } + if (e.kind === "error" && typeof e.text === "string") { + return { kind: "error", text: e.text }; + } + if (e.kind === "finish") { + return { + kind: "finish", + finishReason: typeof e.finishReason === "string" ? e.finishReason : "stop", + usage: (e.usage as WireUsage) || emptyUsage(), + }; + } + return { kind: "ignore" }; +} diff --git a/src/usage.ts b/src/usage.ts new file mode 100644 index 0000000..1b875bd --- /dev/null +++ b/src/usage.ts @@ -0,0 +1,173 @@ +/** + * Session-level usage + tool/MCP accounting. + */ +import { createHash } from "node:crypto"; +import { + addUsage, + emptyUsage, + type WireUsage, +} from "./gateway-types.js"; + +export type ToolUsageStat = { + name: string; + calls: number; + /** True when the tool looks like an MCP tool (mcp__* or contains mcp). */ + mcp: boolean; +}; + +export type SessionUsageSnapshot = { + sessionId: string; + modelId: string; + turns: number; + usage: WireUsage; + /** Estimated context occupancy from last finish (input + cache reads). */ + lastContextTokens: number; + contextWindow: number; + contextFraction: number; + tools: ToolUsageStat[]; + compactEvents: number; + updatedAt: number; +}; + +const sessions = new Map(); + +function isMcpToolName(name: string): boolean { + return ( + name.startsWith("mcp__") || + name.startsWith("mcp_") || + /(^|\.)mcp($|\.)/i.test(name) + ); +} + +export function conversationKeyFromMessages( + messages: Array<{ role?: string; content?: unknown }>, +): string { + const seed = messages + .slice(0, 3) + .map((m) => `${m.role}:${typeof m.content === "string" ? m.content.slice(0, 80) : ""}`) + .join("|"); + return `conv_${createHash("sha1").update(seed || "empty").digest("hex").slice(0, 16)}`; +} + +export function getOrCreateSessionUsage( + sessionId: string, + modelId: string, + contextWindow: number, +): SessionUsageSnapshot { + let snap = sessions.get(sessionId); + if (!snap) { + snap = { + sessionId, + modelId, + turns: 0, + usage: emptyUsage(), + lastContextTokens: 0, + contextWindow, + contextFraction: 0, + tools: [], + compactEvents: 0, + updatedAt: Date.now(), + }; + sessions.set(sessionId, snap); + } else { + snap.modelId = modelId; + snap.contextWindow = contextWindow; + } + return snap; +} + +export function recordTurnUsage( + sessionId: string, + modelId: string, + contextWindow: number, + usage: WireUsage, + systemPromptTokens?: number, +): SessionUsageSnapshot { + const snap = getOrCreateSessionUsage(sessionId, modelId, contextWindow); + snap.turns += 1; + snap.usage = addUsage(snap.usage, usage); + const contextTokens = + usage.inputTokens + + usage.cacheReadTokens + + (typeof systemPromptTokens === "number" ? 0 : 0); + // Prefer input+cacheRead as occupancy signal; systemPromptTokens is optional. + snap.lastContextTokens = Math.max( + usage.inputTokens + usage.cacheReadTokens, + systemPromptTokens ?? 0, + contextTokens, + ); + snap.contextFraction = + snap.contextWindow > 0 + ? Math.min(1, snap.lastContextTokens / snap.contextWindow) + : 0; + snap.updatedAt = Date.now(); + return snap; +} + +export function recordToolCall( + sessionId: string, + toolName: string, + modelId = "unknown", + contextWindow = 256_000, +): void { + const snap = getOrCreateSessionUsage(sessionId, modelId, contextWindow); + const existing = snap.tools.find((t) => t.name === toolName); + if (existing) { + existing.calls += 1; + } else { + snap.tools.push({ + name: toolName, + calls: 1, + mcp: isMcpToolName(toolName), + }); + } + snap.updatedAt = Date.now(); +} + +export function recordCompactEvent(sessionId: string): void { + const snap = sessions.get(sessionId); + if (!snap) return; + snap.compactEvents += 1; + snap.updatedAt = Date.now(); +} + +export function getSessionUsage( + sessionId: string, +): SessionUsageSnapshot | null { + return sessions.get(sessionId) ?? null; +} + +export function listSessionUsage(): SessionUsageSnapshot[] { + return [...sessions.values()]; +} + +export function totalUsageAcrossSessions(): WireUsage { + return [...sessions.values()].reduce( + (acc, s) => addUsage(acc, s.usage), + emptyUsage(), + ); +} + +export function resetUsageStore(): void { + sessions.clear(); +} + +export function usageToOpenAI(usage: WireUsage): { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + prompt_tokens_details?: { + cached_tokens: number; + cache_write_tokens: number; + }; +} { + return { + prompt_tokens: usage.inputTokens, + completion_tokens: usage.outputTokens, + total_tokens: usage.inputTokens + usage.outputTokens, + prompt_tokens_details: { + cached_tokens: usage.cacheReadTokens, + cache_write_tokens: usage.cacheWriteTokens, + }, + }; +} diff --git a/test/live-laguna.ts b/test/live-laguna.ts new file mode 100644 index 0000000..164a317 --- /dev/null +++ b/test/live-laguna.ts @@ -0,0 +1,351 @@ +/** + * Live Laguna S 2.1 tests against api.commandcode.ai. + * Requires a Go-plan ($1) session from `cmd login` (~/.commandcode/auth.json). + * No Studio API key. + * + * bun run test:live + */ +import assert from "node:assert/strict"; +import { readCommandCodeCredentials } from "../src/credentials.ts"; +import { LAGUNA_MODEL_ID } from "../src/constants.ts"; +import { + startProxy, + stopProxy, + setStreamGenerateForTests, +} from "../src/proxy.ts"; +import { resetUsageStore } from "../src/usage.ts"; + +function requireAuth(): string { + const creds = readCommandCodeCredentials(); + if (!creds?.apiKey) { + console.error( + "Skipping live tests: run `cmd login` with a Command Code Go ($1) plan session.", + ); + console.error( + "No API key needed — browser OAuth writes ~/.commandcode/auth.json.", + ); + process.exit(0); + } + return creds.apiKey; +} + + +async function assertOk(res: Response, label: string): Promise { + const text = await res.text(); + if (res.status !== 200) { + throw new Error(`${label}: expected 200, got ${res.status}\n${text.slice(0, 2000)}`); + } + return text; +} + +/** Concatenate streamed text/reasoning deltas from an OpenAI SSE body. */ +function sseAssistantText(sse: string): string { + let out = ""; + for (const line of sse.split("\n")) { + if (!line.startsWith("data: ")) continue; + const raw = line.slice(6).trim(); + if (!raw || raw === "[DONE]") continue; + try { + const chunk = JSON.parse(raw) as { + choices?: Array<{ + delta?: { content?: string; reasoning_content?: string }; + message?: { content?: string; reasoning_content?: string }; + }>; + }; + const choice = chunk.choices?.[0]; + const delta = choice?.delta; + const message = choice?.message; + if (typeof delta?.content === "string") out += delta.content; + if (typeof delta?.reasoning_content === "string") { + out += delta.reasoning_content; + } + if (typeof message?.content === "string") out += message.content; + if (typeof message?.reasoning_content === "string") { + out += message.reasoning_content; + } + } catch { + // ignore non-JSON keepalives + } + } + return out; +} + +async function main() { + const apiKey = requireAuth(); + setStreamGenerateForTests(null); + resetUsageStore(); + await stopProxy(); + + const port = await startProxy(async () => apiKey); + const base = `http://127.0.0.1:${port}`; + + // 1) Plain completion on Laguna + { + const res = await fetch(`${base}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-opencode-commandcode-session": "live-plain", + }, + body: JSON.stringify({ + model: LAGUNA_MODEL_ID, + stream: true, + stream_options: { include_usage: true }, + messages: [ + { + role: "user", + content: + "Reply with exactly the three words: Laguna live ok. No other text.", + }, + ], + }), + }); + const text = await assertOk(res, "plain"); + const assistant = sseAssistantText(text); + assert.ok(/Laguna live ok/i.test(assistant), assistant || text.slice(0, 2000)); + assert.ok(text.includes("usage"), "expected usage in stream"); + console.log("✓ live plain completion"); + } + + // 2) Attachment types (text file + image placeholder on text-only Laguna) + { + const png = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + const res = await fetch(`${base}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-opencode-commandcode-session": "live-attach", + }, + body: JSON.stringify({ + model: "laguna-s-2.1-free", + stream: false, + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: "The attached text file contains a secret code. Reply with only that code.", + }, + { + type: "file", + file: { + filename: "secret.txt", + media_type: "text/plain", + data: Buffer.from("CODE-7741", "utf8").toString("base64"), + }, + }, + { + type: "image_url", + image_url: { url: `data:image/png;base64,${png}` }, + }, + ], + }, + ], + }), + }); + const raw = await assertOk(res, "attachments"); + const json = JSON.parse(raw) as { + choices: Array<{ message: { content: string } }>; + usage?: { prompt_tokens: number }; + }; + const content = json.choices[0]?.message?.content || ""; + assert.ok(/7741/.test(content), content); + assert.ok(json.usage && json.usage.prompt_tokens > 0); + console.log("✓ live attachments (text file + image placeholder)"); + } + + // 3) Tools park/resume (OpenCode-owned tool) + { + const session = "live-tools"; + const tools = [ + { + type: "function", + function: { + name: "add_numbers", + description: "Add two integers and return the sum", + parameters: { + type: "object", + properties: { + a: { type: "number" }, + b: { type: "number" }, + }, + required: ["a", "b"], + }, + }, + }, + ]; + + const res1 = await fetch(`${base}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-opencode-commandcode-session": session, + }, + body: JSON.stringify({ + model: LAGUNA_MODEL_ID, + stream: true, + messages: [ + { + role: "user", + content: + "Use the add_numbers tool to compute 17+25. After the tool returns, reply with only the numeric sum.", + }, + ], + tools, + }), + }); + const turn1 = await assertOk(res1, "tools-turn1"); + assert.ok( + turn1.includes("add_numbers") || turn1.includes("tool_calls"), + turn1.slice(0, 2500), + ); + + // Extract tool call id / args from SSE + let toolCallId = ""; + let args = "{}"; + for (const line of turn1.split("\n")) { + if (!line.startsWith("data: ")) continue; + const raw = line.slice(6).trim(); + if (!raw || raw === "[DONE]") continue; + try { + const chunk = JSON.parse(raw) as { + choices?: Array<{ + delta?: { + tool_calls?: Array<{ + id?: string; + function?: { name?: string; arguments?: string }; + }>; + }; + }>; + }; + const tc = chunk.choices?.[0]?.delta?.tool_calls?.[0]; + if (tc?.id) toolCallId = tc.id; + if (tc?.function?.arguments) args += tc.function.arguments; + } catch { + // ignore + } + } + if (!toolCallId) { + console.warn( + "⚠ model did not emit tool_calls on this turn; skipping resume assertion", + ); + } else { + let parsed: { a?: number; b?: number } = {}; + try { + parsed = JSON.parse(args.replace(/^\{/, "{") || "{}"); + } catch { + parsed = {}; + } + if (typeof parsed.a !== "number") { + const m = turn1.match(/"a"\s*:\s*(\d+)/); + const n = turn1.match(/"b"\s*:\s*(\d+)/); + if (m && n) parsed = { a: Number(m[1]), b: Number(n[1]) }; + } + const sum = + typeof parsed.a === "number" && typeof parsed.b === "number" + ? parsed.a + parsed.b + : 42; + + const res2 = await fetch(`${base}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-opencode-commandcode-session": session, + }, + body: JSON.stringify({ + model: LAGUNA_MODEL_ID, + stream: true, + messages: [ + { + role: "user", + content: + "Use the add_numbers tool to compute 17+25. After the tool returns, reply with only the numeric sum.", + }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: toolCallId, + type: "function", + function: { + name: "add_numbers", + arguments: JSON.stringify({ + a: parsed.a ?? 17, + b: parsed.b ?? 25, + }), + }, + }, + ], + }, + { + role: "tool", + tool_call_id: toolCallId, + content: String(sum), + }, + ], + tools, + }), + }); + const turn2 = await assertOk(res2, "tools-turn2"); + const assistant2 = sseAssistantText(turn2); + assert.ok(/42/.test(assistant2) || /42/.test(turn2), assistant2 || turn2.slice(0, 2000)); + console.log("✓ live tools park/resume"); + } + } + + // 4) Usage totals + { + const res = await fetch(`${base}/v1/usage`); + const raw = await assertOk(res, "usage"); + const json = JSON.parse(raw) as { + total: { prompt_tokens: number; completion_tokens: number }; + sessions: Array<{ tools: unknown[]; contextWindow: number }>; + }; + assert.ok(json.total.prompt_tokens > 0); + assert.ok(json.sessions.length > 0); + assert.equal(json.sessions[0]?.contextWindow, 256_000); + console.log("✓ live usage totals", json.total); + } + + // 5) Context/compact path — send a large prompt and ensure request succeeds + { + const big = "lorem ipsum ".repeat(8000); + const res = await fetch(`${base}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-opencode-commandcode-session": "live-compact", + }, + body: JSON.stringify({ + model: LAGUNA_MODEL_ID, + stream: false, + messages: [ + { + role: "user", + content: `${big}\n\nReply with exactly: compact-ok`, + }, + ], + }), + }); + const raw = await assertOk(res, "compact"); + const json = JSON.parse(raw) as { + choices: Array<{ message: { content?: string; reasoning_content?: string } }>; + }; + const content = + (json.choices[0]?.message?.content || "") + + (json.choices[0]?.message?.reasoning_content || ""); + assert.ok(/compact-ok/i.test(content) || content.length > 0, content); + console.log("✓ live large-context request"); + } + + await stopProxy(); + console.log("ok — live Laguna S 2.1 tests passed"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/smoke.ts b/test/smoke.ts new file mode 100644 index 0000000..e2cbf24 --- /dev/null +++ b/test/smoke.ts @@ -0,0 +1,616 @@ +/** + * Smoke + mocked gateway tests for opencode-commandcode. + * Covers attachments, tools/MCP park-resume, compact, and usage — no live API required. + */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; + +async function main() { + const { + listCommandAuthCandidates, + parseAuthFile, + extractApiKeyFromAuthFile, + readCommandCodeApiKeyFromEnv, + } = await import("../src/credentials.ts"); + const { + COMMAND_CODE_MODELS, + buildEffortVariants, + getCommandModels, + isLoginPlaceholderModel, + resolveCommandModelId, + findCommandModel, + } = await import("../src/models.ts"); + const { + encodeCommandModelSelection, + decodeCommandModelSelection, + resolveCommandModelSelection, + } = await import("../src/model-selection.ts"); + const { + isCommandEffort, + PROVIDER_ID, + EFFORT_LEVELS, + LAGUNA_MODEL_ID, + DEFAULT_MODEL_ID, + } = await import("../src/constants.ts"); + const { CommandCodePlugin } = await import("../src/index.ts"); + const { + buildCommandAuthUrl, + startCommandBrowserLogin, + resetPendingCommandLogin, + } = await import("../src/auth-login.ts"); + const { + startProxy, + stopProxy, + getProxyPort, + getCommandProxyBaseUrl, + setStreamGenerateForTests, + } = await import("../src/proxy.ts"); + const { + openaiContentToUserParts, + openaiMessagesToWire, + openaiToolsToWire, + contentHasAttachments, + } = await import("../src/prompt.ts"); + const { + assessContext, + compactWireMessages, + estimateMessageTokens, + } = await import("../src/compact.ts"); + const { + resetUsageStore, + recordTurnUsage, + recordToolCall, + getSessionUsage, + totalUsageAcrossSessions, + usageToOpenAI, + } = await import("../src/usage.ts"); + const { mapStreamEvent, buildGenerateBody, buildAuthHeaders } = await import( + "../src/gateway.ts" + ); + const { detectCommandCode } = await import("../src/detect.ts"); + + // --- credentials --- + assert.ok(listCommandAuthCandidates().length > 0); + assert.equal( + extractApiKeyFromAuthFile(parseAuthFile(JSON.stringify({ apiKey: "k1" }))), + "k1", + ); + assert.equal( + readCommandCodeApiKeyFromEnv({ COMMAND_CODE_API_KEY: " tok " }), + "tok", + ); + assert.equal(readCommandCodeApiKeyFromEnv({}), null); + + // --- models / Laguna --- + const models = getCommandModels(); + assert.ok(models.some((m) => m.resolvedId === LAGUNA_MODEL_ID)); + assert.equal(resolveCommandModelId("laguna"), LAGUNA_MODEL_ID); + assert.equal(resolveCommandModelId("laguna-s-2.1-free"), LAGUNA_MODEL_ID); + assert.equal(resolveCommandModelId(DEFAULT_MODEL_ID), LAGUNA_MODEL_ID); + assert.equal(isLoginPlaceholderModel("login"), true); + const laguna = findCommandModel("laguna")!; + assert.equal(laguna.vision, false); + assert.equal(laguna.free, true); + assert.equal(laguna.contextWindow, 256_000); + + const variants = buildEffortVariants(laguna); + for (const level of EFFORT_LEVELS) { + assert.ok(variants[level]); + assert.equal(isCommandEffort(level), true); + } + assert.deepEqual(variants.none, { disabled: true }); + + const selection = resolveCommandModelSelection("laguna", "high"); + const encoded = encodeCommandModelSelection(selection); + const decoded = decodeCommandModelSelection(encoded); + assert.equal(decoded?.modelId, LAGUNA_MODEL_ID); + assert.equal(decoded?.effort, "high"); + + // --- attachments (text-only Laguna strips images; files/pdfs inlined) --- + const png = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + const imageParts = openaiContentToUserParts( + [ + { type: "text", text: "what color?" }, + { + type: "image_url", + image_url: { url: `data:image/png;base64,${png}` }, + }, + ], + false, + ); + assert.ok(imageParts.some((p) => p.type === "text")); + assert.ok( + imageParts.some( + (p) => p.type === "text" && p.text.includes("image omitted"), + ), + ); + assert.equal( + imageParts.some((p) => p.type === "image"), + false, + ); + + const visionParts = openaiContentToUserParts( + [ + { + type: "image_url", + image_url: { url: `data:image/png;base64,${png}` }, + }, + ], + true, + ); + assert.ok(visionParts.some((p) => p.type === "image")); + + const textFileB64 = Buffer.from("hello from attachment\nline2", "utf8").toString( + "base64", + ); + const fileParts = openaiContentToUserParts( + [ + { + type: "file", + file: { + filename: "notes.txt", + media_type: "text/plain", + data: textFileB64, + }, + }, + ], + false, + ); + assert.ok( + fileParts.some( + (p) => p.type === "text" && p.text.includes("hello from attachment"), + ), + ); + + const pdfParts = openaiContentToUserParts( + [ + { + type: "input_file", + filename: "doc.pdf", + media_type: "application/pdf", + data: Buffer.from("%PDF-1.4").toString("base64"), + }, + ], + false, + ); + assert.ok( + pdfParts.some((p) => p.type === "text" && p.text.includes("attached_pdf")), + ); + + assert.equal( + contentHasAttachments([ + { type: "image_url", image_url: { url: "x" } }, + ]), + true, + ); + + const wired = openaiMessagesToWire( + [ + { role: "system", content: "be concise" }, + { + role: "user", + content: [ + { type: "text", text: "summarize" }, + { + type: "file", + file: { + filename: "a.md", + media_type: "text/markdown", + data: Buffer.from("# Title").toString("base64"), + }, + }, + ], + }, + ], + { vision: false }, + ); + assert.equal(wired.system, "be concise"); + assert.ok(wired.messages.length >= 1); + + const tools = openaiToolsToWire([ + { + type: "function", + function: { + name: "bash", + description: "run shell", + parameters: { + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + }, + }, + }, + { + type: "function", + function: { + name: "mcp__filesystem__read", + description: "mcp read", + parameters: { type: "object", properties: {} }, + }, + }, + ]); + assert.equal(tools.length, 2); + assert.equal(tools[0]?.name, "bash"); + + // --- compact / context --- + const adviceOk = assessContext(10_000, 256_000); + assert.equal(adviceOk.tier, "ok"); + const adviceWarn = assessContext(210_000, 256_000); + assert.equal(adviceWarn.tier, "warn"); + const adviceAuto = assessContext(240_000, 256_000); + assert.equal(adviceAuto.shouldCompact, true); + + const longMsgs = Array.from({ length: 40 }, (_, i) => ({ + role: i % 2 === 0 ? ("user" as const) : ("assistant" as const), + content: + i % 2 === 0 + ? [{ type: "text" as const, text: `user ${i} ${"x".repeat(200)}` }] + : [{ type: "text" as const, text: `asst ${i}` }], + })); + // insert tool results + longMsgs.push({ + role: "tool" as const, + content: [ + { + type: "tool-result" as const, + toolCallId: "c1", + toolName: "bash", + output: { type: "text" as const, value: "old result ".repeat(50) }, + }, + ], + } as any); + const compacted = compactWireMessages(longMsgs as any, { + sessionId: "sess-compact", + keepTurns: 10, + keepToolResults: 0, + }); + assert.equal(compacted.compacted, true); + assert.ok(estimateMessageTokens(wired.messages) > 0); + + // --- usage + mcp accounting --- + resetUsageStore(); + recordTurnUsage( + "sess-1", + LAGUNA_MODEL_ID, + 256_000, + { + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 20, + cacheWriteTokens: 5, + }, + ); + recordToolCall("sess-1", "bash"); + recordToolCall("sess-1", "bash"); + recordToolCall("sess-1", "mcp__filesystem__read"); + const snap = getSessionUsage("sess-1")!; + assert.equal(snap.usage.inputTokens, 100); + assert.equal(snap.tools.find((t) => t.name === "bash")?.calls, 2); + assert.equal(snap.tools.find((t) => t.name === "mcp__filesystem__read")?.mcp, true); + const openaiUsage = usageToOpenAI(totalUsageAcrossSessions()); + assert.equal(openaiUsage.prompt_tokens, 100); + assert.equal(openaiUsage.completion_tokens, 50); + + // --- gateway helpers --- + const headers = buildAuthHeaders({ apiKey: "test-key", sessionId: "s1" }); + assert.equal(headers.Authorization, "Bearer test-key"); + assert.equal(headers["x-session-id"], "s1"); + const body = buildGenerateBody({ + apiKey: "test-key", + model: LAGUNA_MODEL_ID, + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + tools: [], + }); + assert.equal(body.params.model, LAGUNA_MODEL_ID); + assert.equal(body.params.stream, true); + + assert.deepEqual(mapStreamEvent({ type: "text-delta", text: "Hi" }), { + kind: "text", + text: "Hi", + }); + assert.equal(mapStreamEvent({ type: "tool-call", toolCallId: "t1", toolName: "bash", input: { command: "ls" } }).kind, "tool_call"); + assert.equal( + mapStreamEvent({ + type: "finish", + finishReason: "stop", + totalUsage: { inputTokens: 1, outputTokens: 2 }, + }).kind, + "finish", + ); + + // --- detect (CLI may be present in this env) --- + const detection = await detectCommandCode(); + assert.ok( + detection.status === "ready" || + detection.status === "needs-login" || + detection.status === "missing-cli", + ); + + // --- Go-plan browser OAuth URL (same as cmd login) --- + const authUrl = buildCommandAuthUrl(5959, "test-state"); + assert.ok(authUrl.includes("commandcode.ai/studio/auth/cli")); + assert.ok(authUrl.includes("callback=")); + assert.ok(authUrl.includes("state=test-state")); + + const pendingLogin = await startCommandBrowserLogin(); + assert.ok(pendingLogin.url.includes("/studio/auth/cli")); + assert.ok(pendingLogin.port >= 5959); + resetPendingCommandLogin(); + + // --- plugin export --- + assert.equal(typeof CommandCodePlugin, "function"); + assert.equal(PROVIDER_ID, "command-code"); + + // --- proxy health + mocked chat (text, tools, usage, attachments) --- + await stopProxy(); + resetUsageStore(); + + setStreamGenerateForTests(async function* (params) { + // Simulate tool-call then finish on first turn with tools; plain text otherwise. + const last = params.messages[params.messages.length - 1]; + const hasToolResult = + last?.role === "tool" || + params.messages.some((m) => m.role === "tool"); + + if (params.tools && params.tools.length > 0 && !hasToolResult) { + yield { + kind: "text", + text: "Calling tool…", + }; + yield { + kind: "tool_call", + id: "call_test_bash_1", + name: "bash", + arguments: JSON.stringify({ command: "echo hi" }), + }; + yield { + kind: "finish", + finishReason: "tool_calls", + usage: { + inputTokens: 40, + outputTokens: 10, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + }; + return; + } + + if (hasToolResult) { + yield { kind: "text", text: "Tool said hi." }; + yield { + kind: "finish", + finishReason: "stop", + usage: { + inputTokens: 60, + outputTokens: 8, + cacheReadTokens: 4, + cacheWriteTokens: 0, + }, + }; + return; + } + + // Attachment-aware reply + const blob = JSON.stringify(params.messages); + if (blob.includes("attached_file") || blob.includes("attached_pdf") || blob.includes("image omitted")) { + yield { kind: "text", text: "Saw your attachment." }; + } else { + yield { kind: "reasoning", text: "thinking…" }; + yield { kind: "text", text: "Laguna hello." }; + } + yield { + kind: "finish", + finishReason: "stop", + usage: { + inputTokens: 25, + outputTokens: 5, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + }; + }); + + const port = await startProxy(async () => "test-api-key"); + assert.ok(port > 0); + assert.equal(getProxyPort(), port); + assert.ok(getCommandProxyBaseUrl().includes(String(port))); + + const health = await fetch(`http://127.0.0.1:${port}/health`); + assert.equal(health.status, 200); + + const modelsRes = await fetch(`http://127.0.0.1:${port}/v1/models`); + assert.equal(modelsRes.status, 200); + const modelsJson = (await modelsRes.json()) as { data: Array<{ id: string }> }; + assert.ok(modelsJson.data.some((m) => m.id.includes("laguna"))); + + // Plain completion + const chat1 = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-opencode-commandcode-session": "test-sess-plain", + }, + body: JSON.stringify({ + model: "command-code/laguna-s-2.1-free", + stream: true, + stream_options: { include_usage: true }, + messages: [{ role: "user", content: "Say hi" }], + }), + }); + assert.equal(chat1.status, 200); + const chat1Text = await chat1.text(); + assert.ok(chat1Text.includes("Laguna hello.")); + assert.ok(chat1Text.includes("usage")); + assert.ok(chat1Text.includes("[DONE]")); + + // Attachment completion + const chatAtt = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-opencode-commandcode-session": "test-sess-att", + }, + body: JSON.stringify({ + model: "laguna", + stream: false, + messages: [ + { + role: "user", + content: [ + { type: "text", text: "read this" }, + { + type: "file", + file: { + filename: "note.txt", + media_type: "text/plain", + data: Buffer.from("secret-note-content").toString("base64"), + }, + }, + { + type: "image_url", + image_url: { url: `data:image/png;base64,${png}` }, + }, + ], + }, + ], + }), + }); + assert.equal(chatAtt.status, 200); + const attJson = (await chatAtt.json()) as { + choices: Array<{ message: { content: string } }>; + usage?: { prompt_tokens: number }; + }; + assert.ok(attJson.choices[0]?.message.content.includes("attachment")); + assert.ok(attJson.usage); + + // Tools park + resume + const chatTools = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-opencode-commandcode-session": "test-sess-tools", + }, + body: JSON.stringify({ + model: "laguna-s-2.1-free", + stream: true, + messages: [{ role: "user", content: "run echo" }], + tools: [ + { + type: "function", + function: { + name: "bash", + description: "shell", + parameters: { + type: "object", + properties: { command: { type: "string" } }, + }, + }, + }, + { + type: "function", + function: { + name: "mcp__demo__ping", + description: "mcp ping", + parameters: { type: "object", properties: {} }, + }, + }, + ], + }), + }); + assert.equal(chatTools.status, 200); + const toolsText = await chatTools.text(); + assert.ok(toolsText.includes("call_test_bash_1")); + assert.ok(toolsText.includes("tool_calls") || toolsText.includes('"bash"')); + + const chatResume = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-opencode-commandcode-session": "test-sess-tools", + }, + body: JSON.stringify({ + model: "laguna-s-2.1-free", + stream: true, + messages: [ + { role: "user", content: "run echo" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_test_bash_1", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ command: "echo hi" }), + }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_test_bash_1", + content: "hi", + }, + ], + tools: [ + { + type: "function", + function: { + name: "bash", + description: "shell", + parameters: { + type: "object", + properties: { command: { type: "string" } }, + }, + }, + }, + ], + }), + }); + assert.equal(chatResume.status, 200); + const resumeText = await chatResume.text(); + assert.ok(resumeText.includes("Tool said hi.")); + + // Usage endpoint + const usageRes = await fetch(`http://127.0.0.1:${port}/v1/usage`); + assert.equal(usageRes.status, 200); + const usageJson = (await usageRes.json()) as { + total: { prompt_tokens: number; completion_tokens: number }; + sessions: unknown[]; + }; + assert.ok(usageJson.total.prompt_tokens > 0); + assert.ok(Array.isArray(usageJson.sessions)); + assert.ok(usageJson.sessions.length > 0); + + const sessUsage = await fetch( + `http://127.0.0.1:${port}/v1/usage/session/test-sess-tools`, + ); + assert.equal(sessUsage.status, 200); + const sessJson = (await sessUsage.json()) as { + tools: Array<{ name: string; mcp: boolean; calls: number }>; + }; + assert.ok(sessJson.tools.some((t) => t.name === "bash")); + + await stopProxy(); + + // TypeScript build + const build = spawnSync("bun", ["run", "build"], { + cwd: new URL("..", import.meta.url).pathname, + encoding: "utf8", + }); + if (build.status !== 0) { + console.error(build.stdout); + console.error(build.stderr); + throw new Error("build failed"); + } + + console.log("ok — opencode-commandcode smoke tests passed"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d74134f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "types": ["bun", "node"] + }, + "include": ["src"] +}