diff --git a/.github/scripts/commandcode_acp_smoke.mjs b/.github/scripts/commandcode_acp_smoke.mjs new file mode 100644 index 000000000..b7b6d18b1 --- /dev/null +++ b/.github/scripts/commandcode_acp_smoke.mjs @@ -0,0 +1,99 @@ +// Real CLI compatibility check; no account or model calls required. +// Run: node .github/scripts/commandcode_acp_smoke.mjs +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; +import { createInterface } from "node:readline"; + +const home = await mkdtemp(join(tmpdir(), "openab-commandcode-")); +// Match OpenAB's child environment; isolate HOME so CI cannot reuse credentials. +const env = { + HOME: home, + PATH: dirname(process.execPath) + delimiter + process.env.PATH, +}; +for (const key of process.platform === "win32" + ? ["USERNAME", "SystemRoot", "SystemDrive"] + : ["USER"]) { + if (process.env[key]) env[key] = process.env[key]; +} +if (process.platform === "win32") env.USERPROFILE = home; + +const child = spawn("command-code", ["acp"], { + cwd: home, + env, + stdio: ["pipe", "pipe", "inherit"], +}); +const lines = createInterface({ input: child.stdout }); +const pending = new Map(); +let failure; +let nextId = 0; + +function fail(error) { + failure = error; + for (const { reject } of pending.values()) reject(error); +} +child.on("error", fail); +child.stdin.on("error", fail); +child.on("exit", (code, signal) => fail(new Error(`CLI exited: ${code ?? signal}`))); +lines.on("line", (line) => { + try { + const message = JSON.parse(line); + assert.equal(message.jsonrpc, "2.0"); + pending.get(message.id)?.resolve(message); + } catch (error) { + fail(error); + } +}); + +async function request(method, params) { + if (failure) throw failure; + const id = ++nextId; + let timer; + try { + return await new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + timer = setTimeout(() => reject(new Error(`${method} timed out`)), 30_000); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + }); + } finally { + clearTimeout(timer); + pending.delete(id); + } +} + +try { + // Same handshake and capabilities as AcpConnection::initialize. + const init = await request("initialize", { + protocolVersion: 1, + clientCapabilities: {}, + clientInfo: { name: "openab", version: "0.1.0" }, + }); + assert.equal(init.error, undefined); + assert.equal(init.result.protocolVersion, 1); + assert.equal(init.result.agentInfo.name, "Command Code"); + assert.equal(init.result.agentCapabilities.loadSession, true); + console.log(`PASS initialize: Command Code ${init.result.agentInfo.version}`); + + const relative = await request("session/new", { cwd: ".", mcpServers: [] }); + assert.equal(relative.error?.code, -32602); + assert.match(relative.error.message, /absolute/i); + console.log("PASS session/new: relative project directories are rejected"); + + const session = await request("session/new", { cwd: home, mcpServers: [] }); + assert.equal(session.error?.code, -32000); + assert.match(session.error.message, /not authenticated/i); + console.log("PASS session/new: structured authentication error on clean HOME"); +} finally { + lines.close(); + child.stdin.end(); + if (child.exitCode === null && child.signalCode === null) { + await new Promise((resolve) => { + const timer = setTimeout(() => child.kill("SIGKILL"), 5_000); + child.once("close", () => { clearTimeout(timer); resolve(); }); + child.kill(); + }); + } + await rm(home, { recursive: true, force: true }); +} diff --git a/.github/scripts/test_node_base_arg.sh b/.github/scripts/test_node_base_arg.sh index ba7d825ca..621c55e78 100755 --- a/.github/scripts/test_node_base_arg.sh +++ b/.github/scripts/test_node_base_arg.sh @@ -83,10 +83,11 @@ repo_expect() { # repo_expect fi } for f in Dockerfile.unified Dockerfile.package; do - for t in claude codex copilot gemini mimocode opencode pi; do repo_expect "$f" "$t" NODE22_VERSION; done + for t in claude codex commandcode copilot gemini mimocode opencode pi; do repo_expect "$f" "$t" NODE22_VERSION; done repo_expect "$f" kimi NODE24_VERSION for t in kiro cursor grok devin antigravity native agentcore; do repo_expect "$f" "$t" ''; done done +repo_expect Dockerfile.commandcode '' NODE22_VERSION echo "ok repository targets" [ "$failures" -eq 0 ] || { echo "$failures test(s) failed"; exit 1; } diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index 3ecc5c507..e45423aa1 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -62,7 +62,7 @@ jobs: fi # Resolve variants - ALLOWED="kiro,claude,codex,copilot,cursor,devin,gemini,grok,kimi,mimocode,opencode,antigravity,pi,native,agentcore" + ALLOWED="kiro,claude,codex,commandcode,copilot,cursor,devin,gemini,grok,kimi,mimocode,opencode,antigravity,pi,native,agentcore" if [ "$VARIANTS_INPUT" = "all" ] || [ -z "$VARIANTS_INPUT" ]; then VARIANTS="$ALLOWED" else diff --git a/.github/workflows/build-operator.yml b/.github/workflows/build-operator.yml index b8285eb24..b9185f258 100644 --- a/.github/workflows/build-operator.yml +++ b/.github/workflows/build-operator.yml @@ -30,7 +30,7 @@ env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} # All agent variants built from Dockerfile.unified targets. - AGENTS: "kiro,claude,codex,copilot,cursor,devin,gemini,grok,kimi,mimocode,opencode,antigravity,pi,native,agentcore" + AGENTS: "kiro,claude,codex,commandcode,copilot,cursor,devin,gemini,grok,kimi,mimocode,opencode,antigravity,pi,native,agentcore" jobs: resolve-tag: diff --git a/.github/workflows/docker-smoke-test-unified.yml b/.github/workflows/docker-smoke-test-unified.yml index 3e1bae060..9cda62cc4 100644 --- a/.github/workflows/docker-smoke-test-unified.yml +++ b/.github/workflows/docker-smoke-test-unified.yml @@ -11,6 +11,8 @@ on: - 'openab-agent/**' - 'agy-acp/**' - '.github/scripts/node_base_arg.sh' + - '.github/scripts/commandcode_acp_smoke.mjs' + - '.github/workflows/docker-smoke-test-unified.yml' concurrency: group: smoke-test-unified-${{ github.event.pull_request.number }} @@ -52,6 +54,7 @@ jobs: - { name: kiro, agent: "kiro-cli" } - { name: claude, agent: "claude-agent-acp" } - { name: codex, agent: "codex-acp" } + - { name: commandcode, agent: "command-code" } - { name: copilot, agent: "copilot" } - { name: cursor, agent: "cursor-agent" } - { name: devin, agent: "devin" } @@ -139,6 +142,24 @@ jobs: '.result.agentInfo.name == "@agentclientprotocol/codex-acp"' \ >/dev/null + - name: Verify Command Code runtime version and defaults + if: matrix.target.name == 'commandcode' + run: | + EXPECTED=$(awk -F= '$1 == "ARG COMMAND_CODE_VERSION" {print $2; exit}' Dockerfile.unified) + test -n "$EXPECTED" + docker run --rm --entrypoint command-code openab-unified-commandcode:test --version \ + | grep -Fx "$EXPECTED" + docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' openab-unified-commandcode:test \ + | grep -Fx 'OPENAB_AGENT_COMMAND=command-code acp' + docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' openab-unified-commandcode:test \ + | grep -Fx 'OPENAB_AGENT_AUTH_COMMAND=command-code login' + + - name: Verify Command Code ACP handshake + if: matrix.target.name == 'commandcode' + run: | + docker run --rm -i --entrypoint node openab-unified-commandcode:test \ + --input-type=module < .github/scripts/commandcode_acp_smoke.mjs + - name: Verify Kimi runtime version if: matrix.target.name == 'kimi' run: | diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml index 42d30b24f..a37dac758 100644 --- a/.github/workflows/docker-smoke-test.yml +++ b/.github/workflows/docker-smoke-test.yml @@ -13,6 +13,8 @@ on: - 'Cargo.*' - '.github/scripts/node_base_arg.sh' - '.github/scripts/test_node_base_arg.sh' + - '.github/scripts/commandcode_acp_smoke.mjs' + - '.github/workflows/docker-smoke-test.yml' concurrency: group: smoke-test-${{ github.event.pull_request.number }} @@ -29,6 +31,7 @@ jobs: - { dockerfile: Dockerfile, suffix: "-unified", agent: "kiro-cli", agent_args: "acp --trust-all-tools", build_args: "--build-arg BUILD_MODE=unified" } - { dockerfile: Dockerfile.claude, suffix: "-claude", agent: "claude-agent-acp", agent_args: "", build_args: "" } - { dockerfile: Dockerfile.codex, suffix: "-codex", agent: "codex-acp", agent_args: "", build_args: "" } + - { dockerfile: Dockerfile.commandcode, suffix: "-commandcode", agent: "command-code", agent_args: "acp", build_args: "" } - { dockerfile: Dockerfile.gemini, suffix: "-gemini", agent: "gemini", agent_args: "--acp", build_args: "" } - { dockerfile: Dockerfile.copilot, suffix: "-copilot", agent: "copilot", agent_args: "--acp", build_args: "" } - { dockerfile: Dockerfile.opencode, suffix: "-opencode", agent: "opencode", agent_args: "acp", build_args: "" } @@ -97,6 +100,23 @@ jobs: docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' openab-test-codex \ | grep -Fx 'OPENAB_AGENT_COMMAND=codex-acp' + - name: Verify Command Code version and defaults + if: matrix.variant.suffix == '-commandcode' + run: | + EXPECTED=$(awk -F= '$1 == "ARG COMMAND_CODE_VERSION" {print $2; exit}' Dockerfile.commandcode) + test -n "$EXPECTED" + docker run --rm --entrypoint command-code openab-test-commandcode --version | grep -Fx "$EXPECTED" + docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' openab-test-commandcode \ + | grep -Fx 'OPENAB_AGENT_COMMAND=command-code acp' + docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' openab-test-commandcode \ + | grep -Fx 'OPENAB_AGENT_AUTH_COMMAND=command-code login' + + - name: Verify Command Code ACP handshake + if: matrix.variant.suffix == '-commandcode' + run: | + docker run --rm -i --entrypoint node openab-test-commandcode \ + --input-type=module < .github/scripts/commandcode_acp_smoke.mjs + - name: Verify bumped runtime versions if: matrix.variant.suffix == '' || matrix.variant.suffix == '-unified' || matrix.variant.suffix == '-copilot' || matrix.variant.suffix == '-devin' || matrix.variant.suffix == '-antigravity' shell: bash @@ -132,7 +152,7 @@ jobs: - name: Verify agent responds run: | - if [ "${{ matrix.variant.suffix }}" = "-codex" ]; then + if [ "${{ matrix.variant.suffix }}" = "-codex" ] || [ "${{ matrix.variant.suffix }}" = "-commandcode" ]; then INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"ci-test","version":"0.0.1"}}}' else INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientCapabilities":{},"clientInfo":{"name":"ci-test","version":"0.0.1"}}}' @@ -150,8 +170,8 @@ jobs: if [ -n "$RESPONSE" ] && echo "$RESPONSE" | jq -e '.result.agentInfo.name' > /dev/null 2>&1; then AGENT_NAME=$(echo "$RESPONSE" | jq -r '.result.agentInfo.name') echo "✅ ACP handshake ok — agent=$AGENT_NAME" - elif [ "${{ matrix.variant.suffix }}" = "-codex" ]; then - echo "❌ Codex ACP handshake failed" + elif [ "${{ matrix.variant.suffix }}" = "-codex" ] || [ "${{ matrix.variant.suffix }}" = "-commandcode" ]; then + echo "❌ ACP handshake failed for ${{ matrix.variant.suffix }}" exit 1 else echo "⚠️ ACP handshake returned no response — falling back to CLI check" @@ -210,6 +230,7 @@ jobs: assert_line 'ARG CODEX_ACP_VERSION=2.0.0' Dockerfile.codex Dockerfile.package Dockerfile.unified assert_line 'ARG CODEX_VERSION=0.166.0' Dockerfile.codex Dockerfile.package Dockerfile.unified assert_line 'ARG COPILOT_VERSION=1.0.105' Dockerfile.copilot Dockerfile.package Dockerfile.unified + assert_line 'ARG COMMAND_CODE_VERSION=1.88.2' Dockerfile.commandcode Dockerfile.package Dockerfile.unified assert_line 'ARG DEVIN_VERSION=3000.11.3' Dockerfile.devin Dockerfile.package Dockerfile.unified assert_line 'ARG DEVIN_SHA256_AMD64=83b3b113c01bf2a3e9e100db08d77e6b086806a7754f091e20831bdfe215157e' Dockerfile.devin Dockerfile.package Dockerfile.unified assert_line 'ARG DEVIN_SHA256_ARM64=21a2d7a8dea67987067cde7eb3fe7193a48daa8f8c3d50d414c603c4a2b67f15' Dockerfile.devin Dockerfile.package Dockerfile.unified diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 7065dec0c..ff8164d2c 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -50,7 +50,7 @@ env: # OPENAB_AGENT_COMMAND=openab-agent but does not ship that binary (a # pre-existing bug across all lanes — see follow-up issue), so a nightly-pi # tag would publish a runtime-broken image. Re-add once the pi target is fixed. - ALL_VARIANTS: 'kiro,claude,codex,copilot,cursor,devin,gemini,grok,kimi,mimocode,opencode,antigravity' + ALL_VARIANTS: 'kiro,claude,codex,commandcode,copilot,cursor,devin,gemini,grok,kimi,mimocode,opencode,antigravity' jobs: # --------------------------------------------------------------------------- @@ -251,6 +251,10 @@ jobs: REPORT="codex-acp $ACP + codex $CODE" BUILD_ARGS="CODEX_ACP_VERSION=$ACP CODEX_VERSION=$CODE" ;; + commandcode) + V=$(curl -fsSL --retry 3 https://registry.npmjs.org/command-code/latest | jq -r .version) + check_ver "$V"; REPORT="command-code $V" + BUILD_ARGS="COMMAND_CODE_VERSION=$V" ;; copilot) V=$(curl -fsSL --retry 3 https://registry.npmjs.org/@github/copilot/latest | jq -r .version) check_ver "$V"; REPORT="copilot $V" diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index a6b6864a5..6b3ff9573 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -19,6 +19,7 @@ on: - antigravity - claude - codex + - commandcode - copilot - cursor - devin diff --git a/.github/workflows/pre-beta-build.yml b/.github/workflows/pre-beta-build.yml index 02d1f7e99..5de445eaa 100644 --- a/.github/workflows/pre-beta-build.yml +++ b/.github/workflows/pre-beta-build.yml @@ -14,7 +14,7 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} - ALL_AGENTS: 'kiro,claude,codex,copilot,cursor,devin,gemini,grok,kimi,mimocode,opencode,antigravity,pi,native,agentcore' + ALL_AGENTS: 'kiro,claude,codex,commandcode,copilot,cursor,devin,gemini,grok,kimi,mimocode,opencode,antigravity,pi,native,agentcore' jobs: gate: diff --git a/.github/workflows/snapshot-build.yml b/.github/workflows/snapshot-build.yml index 5dc5e4e0a..edaa63ed5 100644 --- a/.github/workflows/snapshot-build.yml +++ b/.github/workflows/snapshot-build.yml @@ -21,7 +21,7 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} - ALL_VARIANTS: 'default,agentcore,antigravity,claude,codex,copilot,cursor,devin,gemini,grok,kimi,mimocode,native,opencode,pi' + ALL_VARIANTS: 'default,agentcore,antigravity,claude,codex,commandcode,copilot,cursor,devin,gemini,grok,kimi,mimocode,native,opencode,pi' jobs: matrix: diff --git a/Dockerfile.commandcode b/Dockerfile.commandcode new file mode 100644 index 000000000..3700348d8 --- /dev/null +++ b/Dockerfile.commandcode @@ -0,0 +1,55 @@ +# Standalone Command Code image. +# Usage: docker build -f Dockerfile.commandcode -t openab-commandcode . +# A prebuilt OpenAB builder may be selected with --build-arg BUILDER_IMAGE=... +ARG BUILDER_IMAGE=local_builder +ARG NODE22_VERSION=22.23.4 + +# Keep the Rust builder digest in sync with Dockerfile.unified. +FROM rust:1-bookworm@sha256:5e2214abe154fe26e39f64488952e5c991eeed1d6d6da7cc8381ae83927f0cfc AS local_builder +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY crates/openab-core/Cargo.toml crates/openab-core/Cargo.toml +COPY crates/openab-gateway/Cargo.toml crates/openab-gateway/Cargo.toml +COPY crates/openab-mcp/Cargo.toml crates/openab-mcp/Cargo.toml +COPY crates/openab-cp/Cargo.toml crates/openab-cp/Cargo.toml +RUN mkdir -p src crates/openab-core/src crates/openab-gateway/src crates/openab-mcp/src crates/openab-cp/src \ + && echo 'fn main() {}' > src/main.rs \ + && echo '' > crates/openab-core/src/lib.rs \ + && echo '' > crates/openab-gateway/src/lib.rs \ + && echo '' > crates/openab-mcp/src/lib.rs \ + && echo '' > crates/openab-cp/src/lib.rs \ + && cargo build --release --features unified \ + && rm -rf src crates/openab-core/src crates/openab-gateway/src crates/openab-mcp/src crates/openab-cp/src +COPY crates/ crates/ +COPY src/ src/ +RUN touch src/main.rs crates/openab-core/src/lib.rs crates/openab-gateway/src/lib.rs crates/openab-mcp/src/lib.rs crates/openab-cp/src/lib.rs \ + && cargo build --release --features unified + +FROM ${BUILDER_IMAGE} AS builder +RUN test -x /build/target/release/openab + +FROM node:${NODE22_VERSION}-trixie-slim AS base-node +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl procps ripgrep tini bubblewrap socat unzip git \ + && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update && apt-get install -y --no-install-recommends gh && \ + rm -rf /var/lib/apt/lists/* + +FROM base-node AS commandcode +ARG COMMAND_CODE_VERSION=1.88.2 +RUN npm install -g command-code@${COMMAND_CODE_VERSION} --retry 3 +ENV HOME=/home/node +WORKDIR /home/node +COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab +RUN mkdir -p /home/node/.commandcode && chown -R node:node /home/node +USER node +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD pgrep -x openab || exit 1 +ENV OPENAB_AGENT_COMMAND="command-code acp" +ENV OPENAB_AGENT_AUTH_COMMAND="command-code login" +ENTRYPOINT ["tini", "--"] +CMD ["openab", "run", "-c", "/etc/openab/config.toml"] diff --git a/Dockerfile.package b/Dockerfile.package index 854dc29a7..21dea55b4 100644 --- a/Dockerfile.package +++ b/Dockerfile.package @@ -162,6 +162,24 @@ ENV OPENAB_AGENT_AUTH_COMMAND="kimi" ENTRYPOINT ["tini", "--"] CMD ["openab", "run", "-c", "/etc/openab/config.toml"] +# ============================================================================= +# Target: commandcode +# ============================================================================= +FROM base-node AS commandcode +ARG COMMAND_CODE_VERSION=1.88.2 +RUN npm install -g command-code@${COMMAND_CODE_VERSION} --retry 3 +ENV HOME=/home/node +WORKDIR /home/node +COPY --from=bins --chown=node:node /openab /usr/local/bin/openab +RUN mkdir -p /home/node/.commandcode && chown -R node:node /home/node +USER node +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD pgrep -x openab || exit 1 +ENV OPENAB_AGENT_COMMAND="command-code acp" +ENV OPENAB_AGENT_AUTH_COMMAND="command-code login" +ENTRYPOINT ["tini", "--"] +CMD ["openab", "run", "-c", "/etc/openab/config.toml"] + # ============================================================================= # Target: copilot # ============================================================================= diff --git a/Dockerfile.unified b/Dockerfile.unified index 1de976f11..d28d9bda7 100644 --- a/Dockerfile.unified +++ b/Dockerfile.unified @@ -197,6 +197,24 @@ ENV OPENAB_AGENT_AUTH_COMMAND="kimi" ENTRYPOINT ["tini", "--"] CMD ["openab", "run", "-c", "/etc/openab/config.toml"] +# ============================================================================= +# Target: commandcode +# ============================================================================= +FROM base-node AS commandcode +ARG COMMAND_CODE_VERSION=1.88.2 +RUN npm install -g command-code@${COMMAND_CODE_VERSION} --retry 3 +ENV HOME=/home/node +WORKDIR /home/node +COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab +RUN mkdir -p /home/node/.commandcode && chown -R node:node /home/node +USER node +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD pgrep -x openab || exit 1 +ENV OPENAB_AGENT_COMMAND="command-code acp" +ENV OPENAB_AGENT_AUTH_COMMAND="command-code login" +ENTRYPOINT ["tini", "--"] +CMD ["openab", "run", "-c", "/etc/openab/config.toml"] + # ============================================================================= # Target: copilot # ============================================================================= diff --git a/README.md b/README.md index 99c3e5730..e12558244 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ English | [繁體中文](README.zh-TW.md) ![OpenAB banner](images/banner.jpg) -A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, OpenCode, MiMo-Code, Kimi Code, Copilot CLI, Grok Build, Devin, Antigravity, Pi, etc.) over stdio JSON-RPC — delivering the next-generation development experience. **Telegram, LINE, Feishu/Lark, Google Chat, WeCom, and Microsoft Teams** are available through gateway adapters, either compiled into the unified binary or deployed as the standalone [Custom Gateway](crates/openab-gateway/). +A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Command Code, MiMo-Code, Kimi Code, Copilot CLI, Grok Build, Devin, Antigravity, Pi, etc.) over stdio JSON-RPC — delivering the next-generation development experience. **Telegram, LINE, Feishu/Lark, Google Chat, WeCom, and Microsoft Teams** are available through gateway adapters, either compiled into the unified binary or deployed as the standalone [Custom Gateway](crates/openab-gateway/). 🪼 **Join our community!** Come say hi on Discord — we'd love to have you: **[🪼 OpenAB — Official](https://openab.dev/discord)** 🎉 @@ -22,7 +22,7 @@ A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, │ User │ │ │ WebSocket │ gemini --acp │ ├──────────────┤ │ │ (standalone) │ copilot --acp │ │ LINE │◄webhook/API─►┤ │ or in-process │ cursor-agent acp │ -│ User │ │ │ (unified) │ │ +│ User │ │ │ (unified) │ command-code acp │ ├──────────────┤ │ ┌──────┴───────────┐ │ opencode acp │ │ Feishu/Lark │◄─WS/webhook─►┼►│ gateway adapters │ │ mimo acp │ │ User │ │ │ standalone or │ │ kimi acp │ @@ -54,7 +54,7 @@ webhook platforms and `WS/webhook` for Feishu/Lark. - **Multi-platform** — supports Discord and Slack, run one or both simultaneously - **Gateway adapters** — extend to Telegram, LINE, Feishu/Lark, Google Chat, WeCom, and Microsoft Teams through the standalone [gateway](crates/openab-gateway/) or an opt-in unified build -- **Pluggable agent backend** — swap between Kiro CLI, Claude Code, Codex, Gemini, OpenCode, MiMo-Code, Kimi Code, Copilot CLI, Grok Build, Devin, Antigravity, Pi via config +- **Pluggable agent backend** — swap between Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Command Code, MiMo-Code, Kimi Code, Copilot CLI, Grok Build, Devin, Antigravity, Pi via config - **@mention trigger** — mention the bot in an allowed channel to start a conversation - **Thread-based multi-turn** — auto-creates threads; no @mention needed for follow-ups - **Multi-agent collaboration** — bot-to-bot messaging for coordinated workflows ([docs/multi-agent.md](docs/multi-agent.md)) @@ -185,6 +185,7 @@ The bot creates a thread. After that, just type in the thread — no @mention ne | Codex | `codex-acp` | [@agentclientprotocol/codex-acp](https://github.com/agentclientprotocol/codex-acp) | [docs/codex.md](docs/codex.md) | | Gemini | `gemini --acp` | Native | [docs/gemini.md](docs/gemini.md) | | OpenCode | `opencode acp` | Native | [docs/opencode.md](docs/opencode.md) | +| Command Code | `command-code acp` | Native | [docs/commandcode.md](docs/commandcode.md) | | MiMo-Code | `mimo acp` | Native | [docs/mimocode.md](docs/mimocode.md) | | Kimi Code | `kimi acp` | Native | [docs/kimi.md](docs/kimi.md) | | Copilot CLI ⚠️ | `copilot --acp --stdio` | Native | [docs/copilot.md](docs/copilot.md) | diff --git a/README.zh-TW.md b/README.zh-TW.md index ade1c38d0..344d7808a 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -6,7 +6,7 @@ ![OpenAB banner](images/banner.jpg) -一個輕量、安全、雲端原生的 ACP harness,透過 stdio JSON-RPC 將 **Discord、Slack** 與任何相容於 [Agent Client Protocol](https://github.com/anthropics/agent-protocol) 的程式開發 CLI(Kiro CLI、Claude Code、Codex、Gemini、OpenCode、MiMo-Code、Kimi Code、Copilot CLI、Grok Build、Devin、Antigravity、Pi 等)連接起來,帶來新一代的開發體驗。**Telegram、LINE、Feishu/Lark、Google Chat、WeCom 與 Microsoft Teams** 則由 gateway adapters 支援;可將 adapters 編入 unified binary,或部署為獨立的 [Custom Gateway](crates/openab-gateway/)。 +一個輕量、安全、雲端原生的 ACP harness,透過 stdio JSON-RPC 將 **Discord、Slack** 與任何相容於 [Agent Client Protocol](https://github.com/anthropics/agent-protocol) 的程式開發 CLI(Kiro CLI、Claude Code、Codex、Gemini、OpenCode、Command Code、MiMo-Code、Kimi Code、Copilot CLI、Grok Build、Devin、Antigravity、Pi 等)連接起來,帶來新一代的開發體驗。**Telegram、LINE、Feishu/Lark、Google Chat、WeCom 與 Microsoft Teams** 則由 gateway adapters 支援;可將 adapters 編入 unified binary,或部署為獨立的 [Custom Gateway](crates/openab-gateway/)。 🪼 **加入我們的社群!** 歡迎到 Discord 和大家打招呼:**[🪼 OpenAB — Official](https://openab.dev/discord)** 🎉 @@ -22,7 +22,7 @@ │ User │ │ │ WebSocket │ gemini --acp │ ├──────────────┤ │ │ (standalone) │ copilot --acp │ │ LINE │◄webhook/API─►┤ │ or in-process │ cursor-agent acp │ -│ User │ │ │ (unified) │ │ +│ User │ │ │ (unified) │ command-code acp │ ├──────────────┤ │ ┌──────┴───────────┐ │ opencode acp │ │ Feishu/Lark │◄─WS/webhook─►┼►│ gateway adapters │ │ mimo acp │ │ User │ │ │ standalone or │ │ kimi acp │ @@ -54,7 +54,7 @@ platforms 使用 `webhook/API`,Feishu/Lark 則使用 `WS/webhook`。 - **多平台支援** — 支援 Discord 與 Slack,可單獨或同時執行 - **Gateway adapters** — 可透過獨立的 [gateway](crates/openab-gateway/) 或 opt-in unified build 擴充至 Telegram、LINE、Feishu/Lark、Google Chat、WeCom 與 Microsoft Teams -- **可替換的 agent backend** — 可透過設定在 Kiro CLI、Claude Code、Codex、Gemini、OpenCode、MiMo-Code、Kimi Code、Copilot CLI、Grok Build、Devin、Antigravity、Pi 之間切換 +- **可替換的 agent backend** — 可透過設定在 Kiro CLI、Claude Code、Codex、Gemini、OpenCode、Command Code、MiMo-Code、Kimi Code、Copilot CLI、Grok Build、Devin、Antigravity、Pi 之間切換 - **@mention 觸發** — 在允許的頻道中 mention bot,即可開始對話 - **以討論串進行多輪對話** — 自動建立討論串;後續訊息不需再次 @mention - **多 agent 協作** — 支援 bot-to-bot 訊息,實現協調式工作流程([docs/multi-agent.md](docs/multi-agent.md)) @@ -185,6 +185,7 @@ bot 會建立一個討論串。之後只要直接在討論串中輸入即可, | Codex | `codex-acp` | [@agentclientprotocol/codex-acp](https://github.com/agentclientprotocol/codex-acp) | [docs/codex.md](docs/codex.md) | | Gemini | `gemini --acp` | Native | [docs/gemini.md](docs/gemini.md) | | OpenCode | `opencode acp` | Native | [docs/opencode.md](docs/opencode.md) | +| Command Code | `command-code acp` | Native | [docs/commandcode.md](docs/commandcode.md) | | MiMo-Code | `mimo acp` | Native | [docs/mimocode.md](docs/mimocode.md) | | Kimi Code | `kimi acp` | Native | [docs/kimi.md](docs/kimi.md) | | Copilot CLI ⚠️ | `copilot --acp --stdio` | Native | [docs/copilot.md](docs/copilot.md) | diff --git a/config.toml.example b/config.toml.example index c3d1df7b2..81bacc280 100644 --- a/config.toml.example +++ b/config.toml.example @@ -228,6 +228,15 @@ working_dir = "/home/agent" # # equivalent to --trust-all-tools on other backends. # # Run `opencode auth login` once before starting openab. +# [agent] +# command = "command-code" +# args = ["acp"] +# working_dir = "/home/node" +# # Command Code: npm install -g command-code (Node.js 22+). +# # Auth: command-code login, or explicitly pass an API key: +# # env = { COMMAND_CODE_API_KEY = "${COMMAND_CODE_API_KEY}" } +# # See docs/commandcode.md for authentication and persistence. + # [agent] # command = "devin" # args = ["acp"] diff --git a/crates/openab-core/src/setup/config.rs b/crates/openab-core/src/setup/config.rs index ee1483650..b869809c0 100644 --- a/crates/openab-core/src/setup/config.rs +++ b/crates/openab-core/src/setup/config.rs @@ -89,6 +89,7 @@ pub fn generate_config( "claude" => ("claude-agent-acp", vec![]), "codex" => ("codex-acp", vec![]), "gemini" => ("gemini", vec!["--acp".into()]), + "commandcode" => ("command-code", vec!["acp".into()]), other => (other, vec![]), }; AgentConfigToml { diff --git a/crates/openab-core/src/setup/validate.rs b/crates/openab-core/src/setup/validate.rs index b09401559..fa5deba87 100644 --- a/crates/openab-core/src/setup/validate.rs +++ b/crates/openab-core/src/setup/validate.rs @@ -24,7 +24,7 @@ pub fn validate_bot_token(token: &str) -> anyhow::Result<()> { /// Validate agent command #[cfg(test)] pub fn validate_agent_command(cmd: &str) -> anyhow::Result<()> { - let valid = ["kiro", "claude", "codex", "gemini"]; + let valid = ["kiro", "claude", "codex", "gemini", "commandcode"]; if !valid.contains(&cmd) { anyhow::bail!("Agent must be one of: {}", valid.join(", ")); } @@ -63,7 +63,7 @@ mod tests { #[test] fn validate_agent_command_known_and_unknown() { - for agent in &["kiro", "claude", "codex", "gemini"] { + for agent in &["kiro", "claude", "codex", "gemini", "commandcode"] { assert!(validate_agent_command(agent).is_ok()); } assert!(validate_agent_command("invalid").is_err()); diff --git a/crates/openab-core/src/setup/wizard.rs b/crates/openab-core/src/setup/wizard.rs index c8a9905e1..9b3389d1a 100644 --- a/crates/openab-core/src/setup/wizard.rs +++ b/crates/openab-core/src/setup/wizard.rs @@ -371,6 +371,18 @@ fn section_channels(client: &DiscordClient) -> anyhow::Result> { // Section 3: Agent Configuration // --------------------------------------------------------------------------- +fn resolve_agent_working_dir(agent: &str, working_dir: &str, is_local: bool) -> io::Result { + // Command Code requires an absolute ACP cwd. Resolve only local paths: + // Docker paths must not acquire the setup host's Windows drive prefix. + if agent == "commandcode" && is_local { + Ok(std::path::absolute(working_dir)? + .to_string_lossy() + .into_owned()) + } else { + Ok(working_dir.to_string()) + } +} + fn section_agent() -> (String, String, bool) { println!(); cprintln!(C.bold, "--- Step 3: Agent Configuration ---"); @@ -383,12 +395,13 @@ fn section_agent() -> (String, String, bool) { "kiro: npm install -g @koryhutchison/kiro-cli", "codex: npm install -g @openai/codex @agentclientprotocol/codex-acp", "gemini: npm install -g @google/gemini-cli", + "commandcode: npm install -g command-code (Node.js 22+)", "", "Make sure the agent is in your PATH before continuing.", ]); println!(); - let choices = ["claude", "kiro", "codex", "gemini"]; + let choices = ["claude", "kiro", "codex", "gemini", "commandcode"]; let idx = prompt_choice(" Select agent:", &choices); let agent = choices[idx]; @@ -530,6 +543,12 @@ fn print_next_steps(agent: &str, output_path: &Path, is_local: bool) { " 2. Authenticate via Google OAuth, or set GEMINI_API_KEY in config.toml" ); } + "commandcode" => { + cprintln!(C.cyan, " 1. Install Command Code (Node.js 22+):"); + println!(" npm install -g command-code"); + cprintln!(C.cyan, " 2. Authenticate:"); + println!(" command-code login"); + } _ => {} } @@ -543,11 +562,16 @@ fn print_next_steps(agent: &str, output_path: &Path, is_local: bool) { ); println!(); cprintln!(C.cyan, " 1. Deploy with Helm (or your preferred method):"); - println!(" helm install openab openab/openab \\"); - println!( - " --set agents.{}.discord.botToken=\"$BOT_TOKEN\"", - agent - ); + if agent == "commandcode" { + println!(" Prepare values.yaml using docs/commandcode.md"); + println!(" helm install openab openab/openab -f values.yaml"); + } else { + println!(" helm install openab openab/openab \\"); + println!( + " --set agents.{}.discord.botToken=\"$BOT_TOKEN\"", + agent + ); + } println!(); cprintln!( C.cyan, @@ -566,6 +590,10 @@ fn print_next_steps(agent: &str, output_path: &Path, is_local: bool) { "gemini" => { println!(" Set GEMINI_API_KEY via secret, or exec into the pod for OAuth") } + "commandcode" => { + println!(" Set COMMAND_CODE_API_KEY via secret and agent.inherit_env"); + println!(" See docs/commandcode.md for a complete Helm example"); + } _ => {} } println!(); @@ -643,6 +671,7 @@ pub fn run_setup(output_path: Option) -> anyhow::Result<()> { // Step 3: Agent let (agent, working_dir, is_local) = section_agent(); + let working_dir = resolve_agent_working_dir(&agent, &working_dir, is_local)?; // Step 4: Pool let (max_sessions, ttl_hours) = section_pool(); @@ -665,3 +694,28 @@ pub fn run_setup(output_path: Option) -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn commandcode_local_setup_resolves_directory_for_acp() { + let dir = resolve_agent_working_dir("commandcode", ".", true).unwrap(); + let raw = generate_config("tok", "commandcode", vec!["123".into()], &dir, 10, 24); + let config = crate::config::parse_config_str(&raw, "setup").unwrap(); + assert_eq!(config.agent.command, "command-code"); + assert_eq!(config.agent.args, ["acp"]); + assert!(Path::new(&config.agent.working_dir).is_absolute()); + assert_eq!( + std::fs::canonicalize(&config.agent.working_dir).unwrap(), + std::env::current_dir().unwrap().canonicalize().unwrap() + ); + } + + #[test] + fn commandcode_container_setup_preserves_linux_path() { + let dir = resolve_agent_working_dir("commandcode", "/home/node", false).unwrap(); + assert_eq!(dir, "/home/node"); + } +} diff --git a/crates/openab-core/tests/commandcode_acp.rs b/crates/openab-core/tests/commandcode_acp.rs new file mode 100644 index 000000000..aa614df32 --- /dev/null +++ b/crates/openab-core/tests/commandcode_acp.rs @@ -0,0 +1,133 @@ +//! Opt-in live test against the real CLI and the current user's login. +//! Runs two model turns and writes only into a disposable project directory. + +use anyhow::{ensure, Context, Result}; +use openab_core::acp::connection::{AcpConnection, ContentBlock}; +use openab_core::acp::{classify_notification, parse_turn_result, AcpEvent}; +use std::collections::HashMap; +use std::time::Duration; + +struct Reply { + text: String, + chunks: usize, + tools_started: usize, + tools_completed: usize, +} + +async fn prompt(connection: &mut AcpConnection, text: String) -> Result { + let (mut notifications, request_id) = connection + .session_prompt(vec![ContentBlock::Text { text }]) + .await?; + let streamed = tokio::time::timeout(Duration::from_secs(240), async { + let mut reply = Reply { + text: String::new(), + chunks: 0, + tools_started: 0, + tools_completed: 0, + }; + while let Some(message) = notifications.recv().await { + if message.id.as_ref().and_then(|id| id.as_u64()) == Some(request_id) { + if let Some(error) = message.error { + anyhow::bail!("prompt failed: {error}"); + } + let result = message.result.context("prompt result missing")?; + let turn = parse_turn_result(&result); + ensure!(turn.stop_reason.as_deref() == Some("end_turn")); + ensure!(!turn.is_silent_failure(), "silent provider failure"); + return Ok(reply); + } + match classify_notification(&message) { + Some(AcpEvent::Text(chunk)) => { + reply.text.push_str(&chunk); + reply.chunks += 1; + } + Some(AcpEvent::ToolStart { .. }) => reply.tools_started += 1, + Some(AcpEvent::ToolDone { status, .. }) if status == "completed" => { + reply.tools_completed += 1; + } + _ => {} + } + } + anyhow::bail!("ACP closed before prompt completion") + }) + .await; + if streamed.is_err() { + connection.abandon_request(request_id).await; + } + connection.prompt_done().await; + streamed.context("prompt timed out")? +} + +#[tokio::test] +#[ignore = "requires Command Code login; makes two real model calls"] +async fn commandcode_authenticated_roundtrip() -> Result<()> { + let project = tempfile::tempdir()?; + let cwd = project + .path() + .to_str() + .context("project path is not UTF-8")?; + let command = std::env::var("COMMANDCODE_ACP_COMMAND").unwrap_or("command-code".into()); + let env = HashMap::new(); + let args = vec!["acp".to_string()]; + let inherit_env = vec!["COMMAND_CODE_API_KEY".to_string()]; + let mut connection = AcpConnection::spawn(&command, &args, cwd, &env, &inherit_env).await?; + connection.initialize().await?; + ensure!(connection.agent_name == "Command Code"); + ensure!(connection.supports_load_session); + println!("PASS initialize through OpenAB AcpConnection"); + + let session_id = connection.session_new(cwd).await?; + ensure!(!session_id.is_empty()); + println!("PASS authenticated session/new"); + for option in &connection.config_options { + if option.id == "model" { + println!("Model: {}", option.current_value); + } + } + + let file_token = format!("FILE_{}", uuid::Uuid::new_v4().simple()); + let memory_token = format!("MEMORY_{}", uuid::Uuid::new_v4().simple()); + let ready = format!("READY_{}", uuid::Uuid::new_v4().simple()); + let first = prompt( + &mut connection, + format!( + "This is an ACP integration test in a disposable project. Do not delegate, \ + run unrelated commands, or access files outside the current directory. \ + Use a file-writing tool to create proof.txt containing exactly {file_token}. \ + Remember this separate token for our next turn: {memory_token}. \ + Do not write the memory token to any file. Then reply with only {ready}." + ), + ) + .await?; + ensure!(first.text.contains(&ready), "expected reply marker missing"); + ensure!(first.chunks > 0, "no streamed text"); + ensure!(first.tools_started > 0, "no tool-call notifications"); + ensure!(first.tools_completed > 0, "no successful tool completion"); + ensure!(std::fs::read_to_string(project.path().join("proof.txt"))?.trim() == file_token); + println!( + "PASS live prompt: {} text chunks, {} completed tool updates, verified file contents", + first.chunks, first.tools_completed + ); + + drop(connection); + tokio::time::sleep(Duration::from_secs(2)).await; + let mut resumed = AcpConnection::spawn(&command, &args, cwd, &env, &inherit_env).await?; + resumed.initialize().await?; + resumed.session_load(&session_id, cwd).await?; + println!("PASS session/load in a new agent process"); + + let second = prompt( + &mut resumed, + "Reply with only the separate memory token I asked you to remember in our previous turn. \ + Do not use tools, read files, or delegate." + .into(), + ) + .await?; + ensure!( + second.text.contains(&memory_token), + "restored conversation memory missing" + ); + ensure!(second.chunks > 0); + println!("PASS resumed prompt: recovered conversation-only memory token"); + Ok(()) +} diff --git a/docs/commandcode.md b/docs/commandcode.md new file mode 100644 index 000000000..747f35b1b --- /dev/null +++ b/docs/commandcode.md @@ -0,0 +1,156 @@ +# Command Code — Agent Backend Guide + +[Command Code](https://commandcode.ai/docs/acp) provides native ACP over stdio. +OpenAB runs `command-code acp` directly using its existing ACP connection. +The full executable name is used because the `cmd` alias conflicts with the +Windows command shell. + +## Local setup + +Install [Node.js 22 or newer and Command Code](https://commandcode.ai/docs/quickstart): + +```bash +npm install -g command-code +command-code --version +command-code login +``` + +Run login as the same user, with the same `HOME`, that will run OpenAB. Add this +to your OpenAB configuration, replacing the working directory with your project: + +```toml +[agent] +command = "command-code" +args = ["acp"] +working_dir = "/path/to/project" +``` + +Use an absolute `working_dir`: Command Code rejects relative ACP project paths. +The interactive `openab setup` wizard also offers `commandcode` and resolves +local relative directories to absolute paths when generating the configuration. + +## Authentication and persistence + +For a server or container, set `COMMAND_CODE_API_KEY` using a secret and explicitly +forward it to the child: + +```toml +[agent] +command = "command-code" +args = ["acp"] +working_dir = "/home/node" +inherit_env = ["COMMAND_CODE_API_KEY"] +``` + +Alternatively, use +`env = { COMMAND_CODE_API_KEY = "${COMMAND_CODE_API_KEY}" }` in `[agent]`. +OpenAB clears the agent's environment before spawning it, so setting a variable +on the OpenAB process alone does not pass it to Command Code. Keep Discord and +Slack tokens out of `[agent].env` and `inherit_env`. + +Interactive login stores credentials in `~/.commandcode/auth.json`. Command Code +also stores settings, provider configuration, and session history under +[`~/.commandcode/`](https://commandcode.ai/docs/settings). Persist that directory +or the entire home directory. The OpenAB image runs as `node`, with +`HOME=/home/node`. + +## Docker + +Build the standalone image: + +```bash +docker build -f Dockerfile.commandcode -t openab-commandcode . +``` + +Or use the unified Dockerfile target: + +```bash +docker build --target commandcode -f Dockerfile.unified -t openab-commandcode . +``` + +The `commandcode` target is also available in `Dockerfile.package` for packaging +prebuilt binaries. All three Dockerfiles pin `COMMAND_CODE_VERSION`. They set: + +```text +OPENAB_AGENT_COMMAND=command-code acp +OPENAB_AGENT_AUTH_COMMAND=command-code login +``` + +For API key authentication, pass `COMMAND_CODE_API_KEY` to the container and +include `inherit_env` as shown above. Mount persistent storage at `/home/node`. + +## Helm + +The chart accepts `commandcode` as an agent key. Use a built image and inject +credentials from an existing Secret with `DISCORD_BOT_TOKEN` and +`COMMAND_CODE_API_KEY` keys: + +```yaml +agents: + kiro: + enabled: false + commandcode: + enabled: true + image: openab-commandcode:local # replace with your pushed image reference + workingDir: /home/node + secretEnv: + - name: DISCORD_BOT_TOKEN + secretName: commandcode-secrets + secretKey: DISCORD_BOT_TOKEN + - name: COMMAND_CODE_API_KEY + secretName: commandcode-secrets + secretKey: COMMAND_CODE_API_KEY + persistence: + enabled: true + size: 1Gi + configToml: | + [discord] + bot_token = "${DISCORD_BOT_TOKEN}" + allowed_channels = ["YOUR_CHANNEL_ID"] + + [agent] + command = "command-code" + args = ["acp"] + working_dir = "/home/node" + inherit_env = ["COMMAND_CODE_API_KEY"] +``` + +Only the API key is forwarded to the agent. Raw `configToml` must include its own +`inherit_env`; the chart mounts that TOML verbatim. + +## ACP behavior and troubleshooting + +Command Code supports streaming, tool calls, permission requests, images, and +session loading. Audio is unsupported. Each `command-code acp` process serves +one project directory, matching OpenAB's per-session process lifecycle. + +OpenAB automatically selects an allow option for ACP tool permission requests, +as it does for other backends. Command Code's own configured permission rules +still apply. Agent questions and plan approval choices do not have a dedicated +OpenAB chat interaction in this integration. + +- **Not authenticated:** verify the login used OpenAB's `HOME`, or explicitly + forward `COMMAND_CODE_API_KEY`. +- **ACP mod disabled:** remove `acp` from the user-level `mods.disabled` settings. +- **Process exits:** verify Node.js is at least version 22 and `command-code` + is available on the agent's `PATH`. +- **Missing history after restart:** verify persistent storage covers + `/home/node/.commandcode` and is writable by UID 1000. + +CI runs `.github/scripts/commandcode_acp_smoke.mjs` against the real CLI with an +isolated home directory. It verifies initialization, absolute project directory +validation, and the structured authentication error from `session/new` without +credentials. + +With an existing Command Code login, run the opt-in live integration test: + +```bash +cargo test -p openab-core --no-default-features --test commandcode_acp -- --ignored --nocapture +``` + +This uses OpenAB's `AcpConnection` and the current user's login. It makes two real +model calls, verifies streamed text and a tool-written file in a disposable +project, then restarts the agent and checks that `session/load` restores a token +present only in the previous conversation. The test is ignored by default so +normal test runs do not require credentials or make model calls. Set +`COMMANDCODE_ACP_COMMAND` to override the CLI executable path if needed. diff --git a/docs/config-reference.md b/docs/config-reference.md index 0d3362076..10937ad85 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -399,6 +399,15 @@ command = "opencode" args = ["acp"] working_dir = "/home/node" +# Command Code (https://commandcode.ai/docs/acp) +[agent] +command = "command-code" +args = ["acp"] +working_dir = "/home/node" +# Auth: command-code login, or explicitly forward a key: +# env = { COMMAND_CODE_API_KEY = "${COMMAND_CODE_API_KEY}" } +# See docs/commandcode.md. + # Kimi Code CLI [agent] command = "kimi"