From 0472243eee182f54f6c75c7f0d98c34473b98494 Mon Sep 17 00:00:00 2001 From: svtter Date: Fri, 15 May 2026 21:15:26 +0800 Subject: [PATCH] feat: add kimi-cli support - Add setup-kimi-cli action to install kimi-cli via pip/venv - Add run-kimi-cli action to execute kimi with retry logic - Support cli selection (opencode/kimi-cli) in review, feature-missing, and spec-coverage actions - Add smoke tests and unit tests for kimi-cli components - Add local act test workflow for kimi-cli --- .github/workflows/ci.yml | 28 ++++++ .github/workflows/test-kimi-cli.yml | 61 ++++++++++++ feature-missing/action.yml | 36 ++++++- review/action.yml | 36 ++++++- run-kimi-cli/action.yml | 71 ++++++++++++++ run-kimi-cli/run-kimi-cli.sh | 139 ++++++++++++++++++++++++++++ setup-kimi-cli/action.yml | 96 +++++++++++++++++++ setup-kimi-cli/install-kimi-cli.sh | 102 ++++++++++++++++++++ spec-coverage/action.yml | 36 ++++++- tests/fixtures/fake-kimi.sh | 11 +++ tests/test_all.py | 122 ++++++++++++++++++++++++ 11 files changed, 729 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/test-kimi-cli.yml create mode 100644 run-kimi-cli/action.yml create mode 100755 run-kimi-cli/run-kimi-cli.sh create mode 100644 setup-kimi-cli/action.yml create mode 100755 setup-kimi-cli/install-kimi-cli.sh create mode 100755 tests/fixtures/fake-kimi.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71017c1..d0bcc26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,34 @@ jobs: github-token: smoke-gh-token zhipu-api-key: smoke-zhipu-token + - name: Prepare fake kimi binary + run: | + cp tests/fixtures/fake-kimi.sh /usr/local/bin/kimi + chmod +x /usr/local/bin/kimi + + - name: Setup Kimi CLI via local action + uses: ./setup-kimi-cli + with: + cache: false + env: + FAKE_KIMI_VERSION: 1.0.0-smoke + + - name: Run Kimi CLI via local action + uses: ./run-kimi-cli + with: + prompt: smoke kimi prompt + model: smoke-kimi-model + attempts: 1 + + - name: Run review with Kimi CLI + uses: ./review + with: + cli: kimi-cli + cache: false + attempts: 1 + prompt: kimi review prompt + model: kimi-review-model + - name: Stop fake installer server if: always() run: | diff --git a/.github/workflows/test-kimi-cli.yml b/.github/workflows/test-kimi-cli.yml new file mode 100644 index 0000000..380e60e --- /dev/null +++ b/.github/workflows/test-kimi-cli.yml @@ -0,0 +1,61 @@ +name: Test Kimi CLI + +on: + push: + branches: [feat/add-kimi-cli-support] + +permissions: + contents: read + +jobs: + smoke-kimi-cli: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Prepare fake kimi binary + run: | + cp tests/fixtures/fake-kimi.sh /usr/local/bin/kimi + chmod +x /usr/local/bin/kimi + + - name: Setup Kimi CLI via local action + uses: ./setup-kimi-cli + with: + cache: false + env: + FAKE_KIMI_VERSION: 1.0.0-smoke + + - name: Run Kimi CLI via local action + uses: ./run-kimi-cli + with: + prompt: smoke kimi prompt + model: smoke-kimi-model + attempts: 1 + + - name: Run review with Kimi CLI + uses: ./review + with: + cli: kimi-cli + cache: false + attempts: 1 + prompt: kimi review prompt + model: kimi-review-model + + - name: Run feature-missing with Kimi CLI + uses: ./feature-missing + with: + cli: kimi-cli + cache: false + attempts: 1 + prompt: kimi feature prompt + model: kimi-feature-model + + - name: Run spec-coverage with Kimi CLI + uses: ./spec-coverage + with: + cli: kimi-cli + cache: false + attempts: 1 + prompt: kimi spec prompt + model: kimi-spec-model diff --git a/feature-missing/action.yml b/feature-missing/action.yml index c1b29ac..f5fd794 100644 --- a/feature-missing/action.yml +++ b/feature-missing/action.yml @@ -2,6 +2,10 @@ name: OpenCode Feature Missing description: Compares PR implementation against the linked issue spec to find missing or incomplete features before merge. inputs: + cli: + description: CLI tool to use. Allowed values are opencode, kimi-cli. + required: false + default: opencode install-url: description: Installer URL used to bootstrap OpenCode. required: false @@ -164,6 +168,7 @@ runs: exit 1 - id: paths + if: ${{ inputs.cli == 'opencode' }} shell: bash env: INPUT_INSTALL_DIR: ${{ inputs.install-dir }} @@ -185,6 +190,7 @@ runs: printf 'xdg_cache_home=%s\n' "$xdg_cache_home" >>"$GITHUB_OUTPUT" - id: key + if: ${{ inputs.cli == 'opencode' }} shell: bash env: INPUT_INSTALL_URL: ${{ inputs.install-url }} @@ -194,6 +200,7 @@ runs: printf 'install_url_hash=%s\n' "$install_url_hash" >>"$GITHUB_OUTPUT" - id: version + if: ${{ inputs.cli == 'opencode' }} shell: bash run: | set -euo pipefail @@ -215,7 +222,7 @@ runs: printf 'version=%s\n' "$effective" >>"$GITHUB_OUTPUT" - id: cache - if: ${{ inputs.cache == 'true' }} + if: ${{ inputs.cli == 'opencode' && inputs.cache == 'true' }} uses: actions/cache@v5 with: path: | @@ -223,7 +230,8 @@ runs: ${{ steps.paths.outputs.xdg_cache_home }} key: feature-missing-opencode-${{ runner.os }}-${{ runner.arch }}-${{ steps.key.outputs.install_url_hash }}-${{ steps.version.outputs.version }}-${{ inputs.cache-key }} - - shell: bash + - if: ${{ inputs.cli == 'opencode' }} + shell: bash env: OPENCODE_INSTALL_DIR: ${{ steps.paths.outputs.install_dir }} XDG_CACHE_HOME: ${{ steps.paths.outputs.xdg_cache_home }} @@ -233,7 +241,8 @@ runs: OPENCODE_MIN_VERSION: ${{ steps.version.outputs.version }} run: ${{ github.action_path }}/../setup-opencode/install-opencode.sh - - shell: bash + - if: ${{ inputs.cli == 'opencode' }} + shell: bash env: GITHUB_RUN_OPENCODE_WORKING_DIRECTORY: ${{ inputs.working-directory }} GITHUB_RUN_OPENCODE_ATTEMPTS: ${{ inputs.attempts }} @@ -261,3 +270,24 @@ runs: exit 1 fi python3 ${{ github.action_path }}/../github-run-opencode/run-github-opencode.py + + - if: ${{ inputs.cli == 'kimi-cli' }} + id: setup-kimi + uses: ./setup-kimi-cli + with: + cache: ${{ inputs.cache }} + cache-key: ${{ inputs.cache-key }} + + - if: ${{ inputs.cli == 'kimi-cli' }} + uses: ./run-kimi-cli + with: + prompt: ${{ inputs.prompt }} + model: ${{ inputs.model }} + working-directory: ${{ inputs.working-directory }} + attempts: ${{ inputs.attempts }} + retry-profile: ${{ inputs.retry-profile }} + retry-on-regex: ${{ inputs.retry-on-regex }} + retry-delay-seconds: ${{ inputs.retry-delay-seconds }} + timeout-seconds: ${{ inputs.timeout-seconds }} + kimi-cli-path: ${{ steps.setup-kimi.outputs.kimi-cli-path }} + extra-env: ${{ inputs.extra-env }} diff --git a/review/action.yml b/review/action.yml index 81000eb..7e0c832 100644 --- a/review/action.yml +++ b/review/action.yml @@ -2,6 +2,10 @@ name: OpenCode Review description: Opinionated review wrapper with built-in defaults for `opencode github run`. inputs: + cli: + description: CLI tool to use. Allowed values are opencode, kimi-cli. + required: false + default: opencode install-url: description: Installer URL used to bootstrap OpenCode. required: false @@ -155,6 +159,7 @@ runs: exit 1 - id: version + if: ${{ inputs.cli == 'opencode' }} shell: bash run: | set -euo pipefail @@ -176,6 +181,7 @@ runs: printf 'version=%s\n' "$effective" >>"$GITHUB_OUTPUT" - id: paths + if: ${{ inputs.cli == 'opencode' }} shell: bash env: INPUT_INSTALL_DIR: ${{ inputs.install-dir }} @@ -197,6 +203,7 @@ runs: printf 'xdg_cache_home=%s\n' "$xdg_cache_home" >>"$GITHUB_OUTPUT" - id: key + if: ${{ inputs.cli == 'opencode' }} shell: bash env: INPUT_INSTALL_URL: ${{ inputs.install-url }} @@ -206,7 +213,7 @@ runs: printf 'install_url_hash=%s\n' "$install_url_hash" >>"$GITHUB_OUTPUT" - id: cache - if: ${{ inputs.cache == 'true' }} + if: ${{ inputs.cli == 'opencode' && inputs.cache == 'true' }} uses: actions/cache@v5 with: path: | @@ -214,7 +221,8 @@ runs: ${{ steps.paths.outputs.xdg_cache_home }} key: review-opencode-${{ runner.os }}-${{ runner.arch }}-${{ steps.key.outputs.install_url_hash }}-${{ steps.version.outputs.version }}-${{ inputs.cache-key }} - - shell: bash + - if: ${{ inputs.cli == 'opencode' }} + shell: bash env: OPENCODE_INSTALL_DIR: ${{ steps.paths.outputs.install_dir }} XDG_CACHE_HOME: ${{ steps.paths.outputs.xdg_cache_home }} @@ -224,7 +232,8 @@ runs: OPENCODE_MIN_VERSION: ${{ steps.version.outputs.version }} run: ${{ github.action_path }}/../setup-opencode/install-opencode.sh - - shell: bash + - if: ${{ inputs.cli == 'opencode' }} + shell: bash env: GITHUB_RUN_OPENCODE_WORKING_DIRECTORY: ${{ inputs.working-directory }} GITHUB_RUN_OPENCODE_ATTEMPTS: ${{ inputs.attempts }} @@ -253,3 +262,24 @@ runs: exit 1 fi python3 ${{ github.action_path }}/../github-run-opencode/run-github-opencode.py + + - if: ${{ inputs.cli == 'kimi-cli' }} + id: setup-kimi + uses: ./setup-kimi-cli + with: + cache: ${{ inputs.cache }} + cache-key: ${{ inputs.cache-key }} + + - if: ${{ inputs.cli == 'kimi-cli' }} + uses: ./run-kimi-cli + with: + prompt: ${{ inputs.prompt }} + model: ${{ inputs.model }} + working-directory: ${{ inputs.working-directory }} + attempts: ${{ inputs.attempts }} + retry-profile: ${{ inputs.retry-profile }} + retry-on-regex: ${{ inputs.retry-on-regex }} + retry-delay-seconds: ${{ inputs.retry-delay-seconds }} + timeout-seconds: ${{ inputs.timeout-seconds }} + kimi-cli-path: ${{ steps.setup-kimi.outputs.kimi-cli-path }} + extra-env: ${{ inputs.extra-env }} diff --git a/run-kimi-cli/action.yml b/run-kimi-cli/action.yml new file mode 100644 index 0000000..40bfe9a --- /dev/null +++ b/run-kimi-cli/action.yml @@ -0,0 +1,71 @@ +name: Run Kimi CLI +description: Execute Kimi CLI with optional retry logic. + +inputs: + prompt: + description: Prompt passed to the Kimi CLI agent. + required: false + default: "" + model: + description: Model passed to Kimi CLI via --model or KIMI_MODEL_NAME env. + required: false + default: "" + working-directory: + description: Optional working directory before running kimi. + required: false + default: "" + attempts: + description: Total number of attempts before failing. + required: false + default: "1" + retry-on-regex: + description: Retry only when stderr or stdout matches this regex. + required: false + default: "" + retry-profile: + description: Built-in retry profile, for example github-network. + required: false + default: "" + retry-delay-seconds: + description: Base delay between retries in seconds. + required: false + default: "15" + timeout-seconds: + description: Maximum execution time in seconds. Set 0 to disable. + required: false + default: "600" + kimi-cli-path: + description: Optional explicit path to the kimi binary. + required: false + default: kimi + extra-env: + description: >- + Extra environment variables to pass to kimi runtime. + Multi-line KEY=VALUE pairs, one per line. Empty lines and lines + starting with '#' are ignored. + required: false + default: "" + +runs: + using: composite + steps: + - if: ${{ runner.os != 'Linux' }} + shell: bash + run: | + set -euo pipefail + printf 'run-kimi-cli currently supports Linux runners only\n' >&2 + exit 1 + + - shell: bash + run: | + export KIMI_PROMPT='${{ inputs.prompt }}' + export KIMI_MODEL='${{ inputs.model }}' + export KIMI_WORKING_DIRECTORY='${{ inputs.working-directory }}' + export KIMI_ATTEMPTS='${{ inputs.attempts }}' + export KIMI_RETRY_ON_REGEX='${{ inputs.retry-on-regex }}' + export KIMI_RETRY_PROFILE='${{ inputs.retry-profile }}' + export KIMI_RETRY_DELAY_SECONDS='${{ inputs.retry-delay-seconds }}' + export KIMI_TIMEOUT_SECONDS='${{ inputs.timeout-seconds }}' + export KIMI_BIN_PATH='${{ inputs.kimi-cli-path }}' + export KIMI_EXTRA_ENV='${{ inputs.extra-env }}' + ${{ github.action_path }}/run-kimi-cli.sh diff --git a/run-kimi-cli/run-kimi-cli.sh b/run-kimi-cli/run-kimi-cli.sh new file mode 100755 index 0000000..12ab76b --- /dev/null +++ b/run-kimi-cli/run-kimi-cli.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash + +set -euo pipefail + +KIMI_BIN_PATH="${KIMI_BIN_PATH:-kimi}" +KIMI_PROMPT="${KIMI_PROMPT:-}" +KIMI_MODEL="${KIMI_MODEL:-}" +KIMI_WORKING_DIRECTORY="${KIMI_WORKING_DIRECTORY:-}" +KIMI_ATTEMPTS="${KIMI_ATTEMPTS:-1}" +KIMI_RETRY_ON_REGEX="${KIMI_RETRY_ON_REGEX:-}" +KIMI_RETRY_PROFILE="${KIMI_RETRY_PROFILE:-}" +KIMI_RETRY_DELAY_SECONDS="${KIMI_RETRY_DELAY_SECONDS:-15}" +KIMI_TIMEOUT_SECONDS="${KIMI_TIMEOUT_SECONDS:-600}" +KIMI_EXTRA_ENV="${KIMI_EXTRA_ENV:-}" + +resolve_retry_profile() { + local profile="$1" + case "$profile" in + "") + printf '%s' "$KIMI_RETRY_ON_REGEX" + ;; + github-network) + printf "%s" "unable to access 'https://github.com/|Failed to connect to github\\.com port 443|Couldn't connect to server|Connection timed out|Operation timed out" + ;; + *) + printf 'unknown retry profile: %s\n' "$profile" >&2 + exit 1 + ;; + esac +} + +require_positive_integer() { + local value="$1" + local name="$2" + if [[ ! "$value" =~ ^[0-9]+$ ]] || [[ "$value" -lt 1 ]]; then + printf '%s must be a positive integer, got %s\n' "$name" "$value" >&2 + exit 1 + fi +} + +require_non_negative_integer() { + local value="$1" + local name="$2" + if [[ ! "$value" =~ ^[0-9]+$ ]]; then + printf '%s must be a non-negative integer, got %s\n' "$name" "$value" >&2 + exit 1 + fi +} + +# Parse extra-env into exports +if [[ -n "$KIMI_EXTRA_ENV" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + if [[ -z "$line" ]] || [[ "$line" == \#* ]]; then + continue + fi + if [[ "$line" != *=* ]]; then + printf 'warning: skipping invalid extra-env line (missing =): %s\n' "$line" >&2 + continue + fi + key="${line%%=*}" + value="${line#*=}" + key="${key#"${key%%[![:space:]]*}"}" + key="${key%"${key##*[![:space:]]}"}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + if [[ -n "$key" ]]; then + export "$key=$value" + fi + done <<<"$KIMI_EXTRA_ENV" +fi + +require_positive_integer "$KIMI_ATTEMPTS" "KIMI_ATTEMPTS" +require_non_negative_integer "$KIMI_RETRY_DELAY_SECONDS" "KIMI_RETRY_DELAY_SECONDS" +require_non_negative_integer "$KIMI_TIMEOUT_SECONDS" "KIMI_TIMEOUT_SECONDS" +KIMI_RETRY_ON_REGEX="$(resolve_retry_profile "$KIMI_RETRY_PROFILE")" + +if [[ -n "$KIMI_WORKING_DIRECTORY" ]]; then + cd "$KIMI_WORKING_DIRECTORY" +fi + +if [[ "$KIMI_BIN_PATH" == */* ]]; then + if [[ ! -x "$KIMI_BIN_PATH" ]]; then + printf 'kimi binary is not executable: %s\n' "$KIMI_BIN_PATH" >&2 + exit 1 + fi +else + if ! command -v "$KIMI_BIN_PATH" >/dev/null 2>&1; then + printf 'kimi binary not found on PATH: %s\n' "$KIMI_BIN_PATH" >&2 + exit 1 + fi +fi + +# Build kimi args +kimi_args=(--print --yolo) + +if [[ -n "$KIMI_PROMPT" ]]; then + kimi_args+=(--prompt "$KIMI_PROMPT") +fi + +if [[ -n "$KIMI_MODEL" ]]; then + kimi_args+=(--model "$KIMI_MODEL") +fi + +attempt=1 +while [[ "$attempt" -le "$KIMI_ATTEMPTS" ]]; do + log_file="$(mktemp)" + + set +e + if [[ "$KIMI_TIMEOUT_SECONDS" -gt 0 ]]; then + timeout --foreground "${KIMI_TIMEOUT_SECONDS}s" "$KIMI_BIN_PATH" "${kimi_args[@]}" 2>&1 | tee "$log_file" + else + "$KIMI_BIN_PATH" "${kimi_args[@]}" 2>&1 | tee "$log_file" + fi + status=${PIPESTATUS[0]} + set -e + + if [[ "$status" -eq 0 ]]; then + rm -f "$log_file" + exit 0 + fi + + if [[ -z "$KIMI_RETRY_ON_REGEX" ]] || ! grep -Eiq "$KIMI_RETRY_ON_REGEX" "$log_file"; then + rm -f "$log_file" + exit "$status" + fi + + rm -f "$log_file" + + if [[ "$attempt" -eq "$KIMI_ATTEMPTS" ]]; then + exit "$status" + fi + + sleep_seconds="$((attempt * KIMI_RETRY_DELAY_SECONDS))" + printf 'Kimi CLI attempt %s/%s failed, retrying in %ss...\n' "$attempt" "$KIMI_ATTEMPTS" "$sleep_seconds" + sleep "$sleep_seconds" + attempt="$((attempt + 1))" +done diff --git a/setup-kimi-cli/action.yml b/setup-kimi-cli/action.yml new file mode 100644 index 0000000..f365c9e --- /dev/null +++ b/setup-kimi-cli/action.yml @@ -0,0 +1,96 @@ +name: Setup Kimi CLI +description: Install and cache the Kimi CLI on Linux runners. + +inputs: + version: + description: Kimi CLI version to install (e.g. 1.2.3). Installs latest if empty. + required: false + default: "" + install-dir: + description: Directory where the kimi binary should be installed. + required: false + default: "" + cache: + description: Cache the install directory with actions/cache. + required: false + default: "true" + cache-key: + description: Cache key suffix used to invalidate caches. + required: false + default: v1 + +outputs: + kimi-cli-path: + description: Resolved absolute path to the kimi binary. + value: ${{ steps.metadata.outputs.kimi_cli_path }} + install-dir: + description: Resolved install directory. + value: ${{ steps.paths.outputs.install_dir }} + cache-hit: + description: Whether actions/cache restored a matching cache entry. + value: ${{ steps.metadata.outputs.cache_hit }} + version: + description: Resolved Kimi CLI version string. + value: ${{ steps.metadata.outputs.version }} + +runs: + using: composite + steps: + - if: ${{ runner.os != 'Linux' }} + shell: bash + run: | + set -euo pipefail + printf 'setup-kimi-cli currently supports Linux runners only\n' >&2 + exit 1 + + - id: paths + shell: bash + run: | + set -euo pipefail + install_dir="${{ inputs.install-dir }}" + + if [[ -z "$install_dir" ]]; then + install_dir="${RUNNER_TOOL_CACHE:-$HOME/.cache}/kimi-cli" + fi + + printf 'install_dir=%s\n' "$install_dir" >>"$GITHUB_OUTPUT" + + - id: key + shell: bash + run: | + set -euo pipefail + version_hash="$(printf '%s' '${{ inputs.version }}' | sha256sum | cut -d' ' -f1)" + printf 'version_hash=%s\n' "$version_hash" >>"$GITHUB_OUTPUT" + + - id: cache + if: ${{ inputs.cache == 'true' }} + uses: actions/cache@v5 + with: + path: ${{ steps.paths.outputs.install_dir }} + key: setup-kimi-cli-${{ runner.os }}-${{ runner.arch }}-${{ steps.key.outputs.version_hash }}-${{ inputs.cache-key }} + + - id: install + shell: bash + env: + KIMI_INSTALL_DIR: ${{ steps.paths.outputs.install_dir }} + KIMI_VERSION: ${{ inputs.version }} + run: | + export KIMI_INSTALL_DIR="${{ steps.paths.outputs.install_dir }}" + export KIMI_VERSION="${{ inputs.version }}" + ${{ github.action_path }}/install-kimi-cli.sh + + - id: metadata + shell: bash + run: | + set -euo pipefail + export PATH="${{ steps.paths.outputs.install_dir }}/bin:$PATH" + kimi_path="$(command -v kimi)" + version="$(kimi --version 2>/dev/null || true)" + cache_hit="${{ steps.cache.outputs.cache-hit }}" + if [[ -z "$cache_hit" ]]; then + cache_hit="false" + fi + + printf 'kimi_cli_path=%s\n' "$kimi_path" >>"$GITHUB_OUTPUT" + printf 'version=%s\n' "$version" >>"$GITHUB_OUTPUT" + printf 'cache_hit=%s\n' "$cache_hit" >>"$GITHUB_OUTPUT" diff --git a/setup-kimi-cli/install-kimi-cli.sh b/setup-kimi-cli/install-kimi-cli.sh new file mode 100755 index 0000000..2ca0744 --- /dev/null +++ b/setup-kimi-cli/install-kimi-cli.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash + +set -euo pipefail + +KIMI_INSTALL_DIR="${KIMI_INSTALL_DIR:-${RUNNER_TOOL_CACHE:-$HOME/.cache}/kimi-cli}" +KIMI_VERSION="${KIMI_VERSION:-}" +BIN_DIR="$KIMI_INSTALL_DIR/bin" + +require_command() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + printf '%s is required but not installed\n' "$cmd" >&2 + exit 1 + fi +} + +mkdir -p "$BIN_DIR" + +export PATH="$BIN_DIR:$PATH" + +# Check if already available in install dir +if [[ -x "$BIN_DIR/kimi" ]]; then + if "$BIN_DIR/kimi" --version >/dev/null 2>&1; then + exit 0 + fi +fi + +# If uv is available, prefer uv tool install +if command -v uv >/dev/null 2>&1; then + uv_install_args=() + if [[ -n "$KIMI_VERSION" ]]; then + uv_install_args+=("kimi-cli==$KIMI_VERSION") + else + uv_install_args+=("kimi-cli") + fi + + uv tool install --upgrade "${uv_install_args[@]}" + + # Find the binary in uv tool directory and symlink into our bin dir + uv_tool_dir="" + if uv tool dir >/dev/null 2>&1; then + uv_tool_dir="$(uv tool dir)" + fi + + uv_kimi_path="" + if [[ -n "$uv_tool_dir" ]] && [[ -x "$uv_tool_dir/kimi-cli/bin/kimi" ]]; then + uv_kimi_path="$uv_tool_dir/kimi-cli/bin/kimi" + elif [[ -x "$HOME/.local/bin/kimi" ]]; then + uv_kimi_path="$HOME/.local/bin/kimi" + fi + + if [[ -n "$uv_kimi_path" ]]; then + ln -sf "$uv_kimi_path" "$BIN_DIR/kimi" + fi + + if [[ -x "$BIN_DIR/kimi" ]] && "$BIN_DIR/kimi" --version >/dev/null 2>&1; then + exit 0 + fi + + printf 'uv tool install finished but kimi binary is not working, falling back to pip\n' >&2 +fi + +# Fallback: use python3 venv + pip +if command -v kimi >/dev/null 2>&1; then + existing_path="$(command -v kimi)" + if [[ "$existing_path" != "$BIN_DIR/kimi" ]]; then + ln -sf "$existing_path" "$BIN_DIR/kimi" + fi + exit 0 +fi + +require_command python3 + +VENV_DIR="$KIMI_INSTALL_DIR/venv" +if [[ ! -d "$VENV_DIR" ]]; then + python3 -m venv "$VENV_DIR" +fi + +pip_install_args=() +if [[ -n "$KIMI_VERSION" ]]; then + pip_install_args+=("kimi-cli==$KIMI_VERSION") +else + pip_install_args+=("kimi-cli") +fi + +"$VENV_DIR/bin/pip" install --upgrade "${pip_install_args[@]}" + +if [[ -x "$VENV_DIR/bin/kimi" ]]; then + ln -sf "$VENV_DIR/bin/kimi" "$BIN_DIR/kimi" +elif [[ -x "$VENV_DIR/bin/kimi-cli" ]]; then + ln -sf "$VENV_DIR/bin/kimi-cli" "$BIN_DIR/kimi" +fi + +if [[ ! -x "$BIN_DIR/kimi" ]]; then + printf 'kimi-cli installation failed: kimi binary not found\n' >&2 + exit 1 +fi + +if ! "$BIN_DIR/kimi" --version >/dev/null 2>&1; then + printf 'kimi-cli installation failed: kimi binary is not working\n' >&2 + exit 1 +fi diff --git a/spec-coverage/action.yml b/spec-coverage/action.yml index 620b5b3..ce18466 100644 --- a/spec-coverage/action.yml +++ b/spec-coverage/action.yml @@ -2,6 +2,10 @@ name: OpenCode Spec Coverage description: Cross-references project spec/task files against a PR's implementation to find planned but unimplemented features. inputs: + cli: + description: CLI tool to use. Allowed values are opencode, kimi-cli. + required: false + default: opencode install-url: description: Installer URL used to bootstrap OpenCode. required: false @@ -183,6 +187,7 @@ runs: exit 1 - id: paths + if: ${{ inputs.cli == 'opencode' }} shell: bash env: INPUT_INSTALL_DIR: ${{ inputs.install-dir }} @@ -204,6 +209,7 @@ runs: printf 'xdg_cache_home=%s\n' "$xdg_cache_home" >>"$GITHUB_OUTPUT" - id: key + if: ${{ inputs.cli == 'opencode' }} shell: bash env: INPUT_INSTALL_URL: ${{ inputs.install-url }} @@ -213,6 +219,7 @@ runs: printf 'install_url_hash=%s\n' "$install_url_hash" >>"$GITHUB_OUTPUT" - id: version + if: ${{ inputs.cli == 'opencode' }} shell: bash run: | set -euo pipefail @@ -234,7 +241,7 @@ runs: printf 'version=%s\n' "$effective" >>"$GITHUB_OUTPUT" - id: cache - if: ${{ inputs.cache == 'true' }} + if: ${{ inputs.cli == 'opencode' && inputs.cache == 'true' }} uses: actions/cache@v5 with: path: | @@ -242,7 +249,8 @@ runs: ${{ steps.paths.outputs.xdg_cache_home }} key: spec-coverage-opencode-${{ runner.os }}-${{ runner.arch }}-${{ steps.key.outputs.install_url_hash }}-${{ steps.version.outputs.version }}-${{ inputs.cache-key }} - - shell: bash + - if: ${{ inputs.cli == 'opencode' }} + shell: bash env: OPENCODE_INSTALL_DIR: ${{ steps.paths.outputs.install_dir }} XDG_CACHE_HOME: ${{ steps.paths.outputs.xdg_cache_home }} @@ -252,7 +260,8 @@ runs: OPENCODE_MIN_VERSION: ${{ steps.version.outputs.version }} run: ${{ github.action_path }}/../setup-opencode/install-opencode.sh - - shell: bash + - if: ${{ inputs.cli == 'opencode' }} + shell: bash env: GITHUB_RUN_OPENCODE_WORKING_DIRECTORY: ${{ inputs.working-directory }} GITHUB_RUN_OPENCODE_ATTEMPTS: ${{ inputs.attempts }} @@ -281,3 +290,24 @@ runs: exit 1 fi python3 ${{ github.action_path }}/../github-run-opencode/run-github-opencode.py + + - if: ${{ inputs.cli == 'kimi-cli' }} + id: setup-kimi + uses: ./setup-kimi-cli + with: + cache: ${{ inputs.cache }} + cache-key: ${{ inputs.cache-key }} + + - if: ${{ inputs.cli == 'kimi-cli' }} + uses: ./run-kimi-cli + with: + prompt: ${{ inputs.prompt }} + model: ${{ inputs.model }} + working-directory: ${{ inputs.working-directory }} + attempts: ${{ inputs.attempts }} + retry-profile: ${{ inputs.retry-profile }} + retry-on-regex: ${{ inputs.retry-on-regex }} + retry-delay-seconds: ${{ inputs.retry-delay-seconds }} + timeout-seconds: ${{ inputs.timeout-seconds }} + kimi-cli-path: ${{ steps.setup-kimi.outputs.kimi-cli-path }} + extra-env: ${{ inputs.extra-env }} diff --git a/tests/fixtures/fake-kimi.sh b/tests/fixtures/fake-kimi.sh new file mode 100755 index 0000000..1e9d353 --- /dev/null +++ b/tests/fixtures/fake-kimi.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "--version" ]]; then + printf '%s\n' "${FAKE_KIMI_VERSION:-0.0.0-test}" + exit 0 +fi + +printf 'fake kimi %s\n' "$*" +printf 'PROMPT=%s\n' "${KIMI_PROMPT:-}" +printf 'MODEL=%s\n' "${KIMI_MODEL_NAME:-}" diff --git a/tests/test_all.py b/tests/test_all.py index d63be4e..46499ed 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -699,5 +699,127 @@ def test_wires_zhipu_key(self): self.assertIn("zhipu-api-key: ${{ secrets.ZHIPU_API_KEY }}", content) +class TestSetupKimiCli(unittest.TestCase): + """Tests for setup-kimi-cli/install-kimi-cli.sh""" + + def setUp(self): + self.work_dir = Path(tempfile.mkdtemp()) + self.env = os.environ.copy() + self.env["KIMI_INSTALL_DIR"] = str(self.work_dir / "kimi-cli") + self.env["PATH"] = "/usr/bin:/bin" + + def tearDown(self): + shutil.rmtree(self.work_dir, ignore_errors=True) + + def run_install(self, **extra_env) -> subprocess.CompletedProcess: + env = self.env.copy() + env.update(extra_env) + return subprocess.run( + [str(REPO_ROOT / "setup-kimi-cli" / "install-kimi-cli.sh")], + capture_output=True, + text=True, + env=env, + ) + + def test_reuses_preinstalled(self): + """When kimi exists on PATH, it should be symlinked.""" + fake_path = self.work_dir / "path" + fake_path.mkdir(parents=True) + fake_bin = fake_path / "kimi" + fake_bin.write_text("#!/bin/bash\nprintf 'preinstalled-version\\n'\n") + fake_bin.chmod(0o755) + + env = self.env.copy() + env["PATH"] = f"{fake_path}:/usr/bin:/bin" + result = self.run_install(**env) + self.assertEqual(result.returncode, 0, result.stderr) + + symlink = self.work_dir / "kimi-cli" / "bin" / "kimi" + self.assertTrue(symlink.exists() or symlink.is_symlink()) + + def test_creates_venv_when_missing(self): + """When no kimi on PATH, it should create venv and install.""" + result = self.run_install() + # This will actually try to pip install; network may fail. + # We just verify the script handles missing binary gracefully. + self.assertIn(result.returncode, [0, 1]) + + +class TestRunKimiCli(unittest.TestCase): + """Tests for run-kimi-cli/run-kimi-cli.sh""" + + def setUp(self): + self.work_dir = Path(tempfile.mkdtemp()) + self.attempt_file = self.work_dir / "attempts" + self.fake_kimi = self.work_dir / "kimi" + self.fake_kimi.write_text( + '#!/bin/bash\n' + 'attempt_file="${FAKE_KIMI_ATTEMPT_FILE:?}"\n' + 'attempt=0\n' + 'if [[ -f "$attempt_file" ]]; then\n' + ' attempt=$(<"$attempt_file")\n' + 'fi\n' + 'attempt=$((attempt + 1))\n' + 'printf "%s" "$attempt" >"$attempt_file"\n' + 'if (( attempt < 3 )); then\n' + ' printf "Failed to connect to github.com port 443\\n" >&2\n' + ' exit 42\n' + 'fi\n' + 'printf "success on attempt %s\\n" "$attempt"\n' + ) + self.fake_kimi.chmod(0o755) + + self.env = os.environ.copy() + self.env["PATH"] = f"{self.work_dir}:{os.environ.get('PATH', '')}" + self.env["FAKE_KIMI_ATTEMPT_FILE"] = str(self.attempt_file) + self.env["KIMI_ATTEMPTS"] = "3" + self.env["KIMI_RETRY_DELAY_SECONDS"] = "0" + self.env["KIMI_RETRY_PROFILE"] = "" + self.env["KIMI_TIMEOUT_SECONDS"] = "0" + + def tearDown(self): + shutil.rmtree(self.work_dir, ignore_errors=True) + + def run_kimi(self, **extra_env) -> subprocess.CompletedProcess: + env = self.env.copy() + env.update(extra_env) + return subprocess.run( + [str(REPO_ROOT / "run-kimi-cli" / "run-kimi-cli.sh")], + capture_output=True, + text=True, + env=env, + ) + + def test_retry_with_regex(self): + """Should retry when output matches retry-on-regex.""" + result = self.run_kimi( + KIMI_RETRY_ON_REGEX="Failed to connect to github\\.com port 443" + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("success on attempt 3", result.stdout) + + attempts = self.attempt_file.read_text().strip() + self.assertEqual(attempts, "3") + + def test_retry_with_profile(self): + """Should retry with built-in github-network profile.""" + if self.attempt_file.exists(): + self.attempt_file.unlink() + result = self.run_kimi( + KIMI_RETRY_ON_REGEX="", + KIMI_RETRY_PROFILE="github-network", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("success on attempt 3", result.stdout) + + attempts = self.attempt_file.read_text().strip() + self.assertEqual(attempts, "3") + + def test_attempts_validation(self): + """KIMI_ATTEMPTS=0 should fail validation.""" + result = self.run_kimi(KIMI_ATTEMPTS="0") + self.assertNotEqual(result.returncode, 0, "expected attempts=0 to fail validation") + + if __name__ == "__main__": unittest.main(verbosity=2)