diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fbc81c48..39fabf86 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,7 @@ on:
branches: [main]
pull_request:
branches: [main]
+ types: [opened, synchronize, reopened, edited]
workflow_dispatch:
permissions:
@@ -70,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
@@ -87,6 +89,23 @@ jobs:
- name: Run errs/ lint guards (lintcheck)
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
+ script-test:
+ needs: fast-gate
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
+ with:
+ go-version-file: go.mod
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: '22'
+ - name: Run script tests
+ run: make script-test
+
deterministic-gate:
needs: fast-gate
runs-on: ubuntu-latest
@@ -109,8 +128,28 @@ jobs:
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: make quality-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
@@ -220,7 +259,7 @@ jobs:
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
- needs: [unit-test, lint, deterministic-gate]
+ needs: [unit-test, lint, script-test, deterministic-gate]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
@@ -241,7 +280,7 @@ jobs:
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
- needs: [unit-test, lint, deterministic-gate]
+ 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:
@@ -333,7 +372,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
- needs: [fast-gate, unit-test, lint, deterministic-gate, 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
@@ -345,6 +384,7 @@ 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
@@ -361,6 +401,7 @@ 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 }}" \
diff --git a/.github/workflows/comment-audit.yml b/.github/workflows/comment-audit.yml
new file mode 100644
index 00000000..0508fa52
--- /dev/null
+++ b/.github/workflows/comment-audit.yml
@@ -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"
diff --git a/.github/workflows/semantic-review.yml b/.github/workflows/semantic-review.yml
index 7d39a4b4..2fcf298c 100644
--- a/.github/workflows/semantic-review.yml
+++ b/.github/workflows/semantic-review.yml
@@ -88,31 +88,44 @@ jobs:
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
- candidate.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
- if (candidatePRs.length > 1) {
- throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
+ 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 (candidatePRs.length === 1) {
- prNumber = candidatePRs[0].number;
+ 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: "open",
+ state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
- if (candidatePRs.length !== 1) {
+ 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}`);
}
- prNumber = candidatePRs[0].number;
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
@@ -121,6 +134,11 @@ jobs:
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");
@@ -299,31 +317,44 @@ jobs:
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
- candidate.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
- if (candidatePRs.length > 1) {
- throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
+ 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 (candidatePRs.length === 1) {
- prNumber = candidatePRs[0].number;
+ 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: "open",
+ state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
- if (candidatePRs.length !== 1) {
+ 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}`);
}
- prNumber = candidatePRs[0].number;
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
@@ -332,6 +363,16 @@ jobs:
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");
@@ -389,6 +430,10 @@ jobs:
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;
diff --git a/.gitignore b/.gitignore
index 8b7fc5ef..5666621b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,3 +51,4 @@ app.log
cover*.out
lark-env.sh
+/automations/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ef2bc094..c4a3c0ff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,47 @@
All notable changes to this project will be documented in this file.
+## [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
@@ -1236,6 +1277,8 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
+[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
diff --git a/Makefile b/Makefile
index 3d8c9861..d401cd17 100644
--- a/Makefile
+++ b/Makefile
@@ -12,6 +12,7 @@ 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
@@ -69,7 +70,8 @@ integration-test: build
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))
+ 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 \
@@ -89,6 +91,7 @@ quality-gate: build
--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
diff --git a/README.md b/README.md
index 5ea47838..e0f371f6 100644
--- a/README.md
+++ b/README.md
@@ -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 $'
Weekly Report \n# Progress\n- Completed feature X'
+lark-cli docs +create --doc-format markdown --content $'Weekly Report \n# Progress\n- Completed feature X'
```
Run `lark-cli --help` to see all shortcut commands.
diff --git a/README.zh.md b/README.zh.md
index f597ca68..6d8221f2 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -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 $'周报 \n# 本周进展\n- 完成了 X 功能'
+lark-cli docs +create --doc-format markdown --content $'周报 \n# 本周进展\n- 完成了 X 功能'
```
运行 `lark-cli --help` 查看所有快捷命令。
diff --git a/internal/qualitygate/cmd/comment-audit/main.go b/internal/qualitygate/cmd/comment-audit/main.go
new file mode 100644
index 00000000..4425206d
--- /dev/null
+++ b/internal/qualitygate/cmd/comment-audit/main.go
@@ -0,0 +1,92 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package main
+
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "os"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/qualitygate/publiccontent"
+ "github.com/larksuite/cli/internal/qualitygate/report"
+ "github.com/larksuite/cli/internal/validate"
+ "github.com/larksuite/cli/internal/vfs"
+)
+
+type eventPayload struct {
+ Comment *struct {
+ Body string `json:"body"`
+ } `json:"comment"`
+ Review *struct {
+ Body string `json:"body"`
+ } `json:"review"`
+}
+
+func main() {
+ eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
+ kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
+ flag.Parse()
+
+ if *eventPath == "" {
+ fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
+ os.Exit(2)
+ }
+ body, err := commentBody(*eventPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
+ os.Exit(2)
+ }
+ diags := diagnostics(publiccontent.ScanComment(*kind, body))
+ if len(diags) > 0 {
+ fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
+ }
+ report.Print(os.Stderr, diags)
+ os.Exit(report.ExitCode(diags))
+}
+
+func auditFailureSummary(count int) string {
+ return fmt.Sprintf("post-publication audit found public content findings: %d", count)
+}
+
+func commentBody(path string) (string, error) {
+ safePath, err := validate.SafeInputPath(path)
+ if err != nil {
+ return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
+ WithParam("--event").
+ WithCause(err)
+ }
+ data, err := vfs.ReadFile(safePath)
+ if err != nil {
+ return "", err
+ }
+ var payload eventPayload
+ if err := json.Unmarshal(data, &payload); err != nil {
+ return "", err
+ }
+ switch {
+ case payload.Comment != nil:
+ return payload.Comment.Body, nil
+ case payload.Review != nil:
+ return payload.Review.Body, nil
+ default:
+ return "", nil
+ }
+}
+
+func diagnostics(items []publiccontent.Finding) []report.Diagnostic {
+ out := make([]report.Diagnostic, 0, len(items))
+ for _, item := range items {
+ out = append(out, report.Diagnostic{
+ Rule: item.Rule,
+ Action: item.Action,
+ File: item.File,
+ Line: item.Line,
+ Message: item.Message,
+ Suggestion: item.Suggestion,
+ })
+ }
+ return out
+}
diff --git a/internal/qualitygate/cmd/comment-audit/main_test.go b/internal/qualitygate/cmd/comment-audit/main_test.go
new file mode 100644
index 00000000..5e7aea46
--- /dev/null
+++ b/internal/qualitygate/cmd/comment-audit/main_test.go
@@ -0,0 +1,70 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package main
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/larksuite/cli/errs"
+)
+
+func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
+ dir := t.TempDir()
+ if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"clean comment"}}`); err != nil {
+ t.Fatal(err)
+ }
+ origDir, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chdir(dir); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = os.Chdir(origDir)
+ })
+
+ got, err := commentBody("event.json")
+ if err != nil {
+ t.Fatalf("commentBody() error = %v", err)
+ }
+ if got != "clean comment" {
+ t.Fatalf("comment body = %q", got)
+ }
+}
+
+func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "event.json")
+ if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := commentBody(path)
+ problem, ok := errs.ProblemOf(err)
+ if err == nil || !ok {
+ t.Fatalf("commentBody(%q) error = %v, want unsafe path validation error", path, err)
+ }
+ if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("commentBody(%q) problem = %#v, want invalid argument validation", path, problem)
+ }
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) || validationErr.Param != "--event" {
+ t.Fatalf("commentBody(%q) error = %v, want --event validation param", path, err)
+ }
+}
+
+func TestAuditFailureSummaryStatesPostPublicationAudit(t *testing.T) {
+ got := auditFailureSummary(2)
+ want := "post-publication audit found public content findings: 2"
+ if got != want {
+ t.Fatalf("auditFailureSummary() = %q, want %q", got, want)
+ }
+}
+
+func writeTestFile(path, data string) error {
+ return os.WriteFile(path, []byte(data), 0o644)
+}
diff --git a/internal/qualitygate/cmd/quality-gate/main.go b/internal/qualitygate/cmd/quality-gate/main.go
index 08cf3951..1ec3c90b 100644
--- a/internal/qualitygate/cmd/quality-gate/main.go
+++ b/internal/qualitygate/cmd/quality-gate/main.go
@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/qualitygate/rules"
+ "github.com/larksuite/cli/internal/validate"
)
func main() {
@@ -41,6 +42,7 @@ func runCheck(args []string) int {
fs.StringVar(&opts.FactsOut, "facts-out", "", "write facts JSON to this path")
fs.StringVar(&opts.ManifestPath, "manifest", "", "hand-authored command manifest JSON")
fs.StringVar(&opts.CommandIndexPath, "command-index", "", "full command index JSON")
+ fs.StringVar(&opts.PublicContentMetadataPath, "public-content-metadata", "", "PR title/body metadata JSON for public content checks")
fs.BoolVar(&printLegacyCommandCandidates, "print-legacy-command-candidates", false, "print current non-kebab-case hand-authored command candidates")
fs.BoolVar(&printLegacyFlagCandidates, "print-legacy-flag-candidates", false, "print current non-kebab-case flag candidates")
if err := fs.Parse(args); err != nil {
@@ -48,6 +50,15 @@ func runCheck(args []string) int {
return 2
}
+ if opts.PublicContentMetadataPath != "" {
+ safePath, err := validate.SafeInputPath(opts.PublicContentMetadataPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "quality-gate check: --public-content-metadata: %v\n", err)
+ return 2
+ }
+ opts.PublicContentMetadataPath = safePath
+ }
+
if opts.ManifestPath == "" || opts.CommandIndexPath == "" {
fmt.Fprintln(os.Stderr, "quality-gate check: --manifest and --command-index are required")
return 2
diff --git a/internal/qualitygate/cmd/quality-gate/main_test.go b/internal/qualitygate/cmd/quality-gate/main_test.go
index cc89695e..9420de89 100644
--- a/internal/qualitygate/cmd/quality-gate/main_test.go
+++ b/internal/qualitygate/cmd/quality-gate/main_test.go
@@ -37,6 +37,37 @@ func TestCheckRequiresManifestInputs(t *testing.T) {
}
}
+func TestCheckAcceptsPublicContentMetadataFlag(t *testing.T) {
+ code, stderr := runCheckCaptureStderr(t, []string{
+ "--repo", t.TempDir(),
+ "--cli-bin", "./lark-cli",
+ "--public-content-metadata", ".tmp/quality-gate/pr.json",
+ })
+ if code != 2 {
+ t.Fatalf("exit code = %d, stderr=%s", code, stderr)
+ }
+ if strings.Contains(stderr, "flag provided but not defined") {
+ t.Fatalf("public content metadata flag was not registered: %s", stderr)
+ }
+ if !strings.Contains(stderr, "--manifest and --command-index are required") {
+ t.Fatalf("stderr = %s", stderr)
+ }
+}
+
+func TestCheckRejectsUnsafePublicContentMetadataPath(t *testing.T) {
+ code, stderr := runCheckCaptureStderr(t, []string{
+ "--repo", t.TempDir(),
+ "--cli-bin", "./lark-cli",
+ "--public-content-metadata", filepath.Join(t.TempDir(), "pr.json"),
+ })
+ if code != 2 {
+ t.Fatalf("exit code = %d, stderr=%s", code, stderr)
+ }
+ if !strings.Contains(stderr, "--public-content-metadata") || !strings.Contains(stderr, "--file") {
+ t.Fatalf("stderr = %s, want unsafe public content metadata path error", stderr)
+ }
+}
+
func TestCheckReportsManifestReadErrorsWithFlagName(t *testing.T) {
dir := t.TempDir()
manifestPath := filepath.Join(dir, "command-manifest.json")
diff --git a/internal/qualitygate/cmd/semantic-review/main.go b/internal/qualitygate/cmd/semantic-review/main.go
index 8d4603a0..9ef67674 100644
--- a/internal/qualitygate/cmd/semantic-review/main.go
+++ b/internal/qualitygate/cmd/semantic-review/main.go
@@ -56,6 +56,14 @@ func run(args []string) int {
_ = semantic.WriteMarkdown(markdownOut, decision)
return 0
}
+ if reviewPath == "" && !semantic.BuildInputView(f).HasReviewableFacts() {
+ decision := finalizeDecision(block, waiverDiags, semantic.Decision{})
+ if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
+ fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
+ return 2
+ }
+ return decisionExitCode(decision)
+ }
review, err := semantic.LoadOrReviewWithConfig(context.Background(), f, reviewPath, modelConfig)
if err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
@@ -72,6 +80,15 @@ func run(args []string) int {
return 0
}
decision := semantic.DecideWithWaivers(f, review, policy, waivers)
+ decision = finalizeDecision(block, waiverDiags, decision)
+ if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
+ fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
+ return 2
+ }
+ return decisionExitCode(decision)
+}
+
+func finalizeDecision(block bool, waiverDiags []report.Diagnostic, decision semantic.Decision) semantic.Decision {
decision.BlockMode = block
if !block && len(decision.Blockers) > 0 {
for i := range decision.Blockers {
@@ -81,15 +98,21 @@ func run(args []string) int {
decision.Blockers = nil
}
decision.SystemWarnings = append(diagnosticSystemWarnings(waiverDiags), decision.SystemWarnings...)
+ return decision
+}
+
+func writeSemanticOutputs(decisionOut, markdownOut string, decision semantic.Decision) error {
if err := semantic.WriteDecision(decisionOut, decision); err != nil {
- fmt.Fprintf(os.Stderr, "semantic-review: write decision: %v\n", err)
- return 2
+ return fmt.Errorf("write decision: %w", err)
}
if err := semantic.WriteMarkdown(markdownOut, decision); err != nil {
- fmt.Fprintf(os.Stderr, "semantic-review: write markdown: %v\n", err)
- return 2
+ return fmt.Errorf("write markdown: %w", err)
}
- if block && len(decision.Blockers) > 0 {
+ return nil
+}
+
+func decisionExitCode(decision semantic.Decision) int {
+ if decision.BlockMode && len(decision.Blockers) > 0 {
return 1
}
return 0
diff --git a/internal/qualitygate/cmd/semantic-review/main_test.go b/internal/qualitygate/cmd/semantic-review/main_test.go
index 6bf80a38..366e24ac 100644
--- a/internal/qualitygate/cmd/semantic-review/main_test.go
+++ b/internal/qualitygate/cmd/semantic-review/main_test.go
@@ -7,6 +7,7 @@ import (
"encoding/json"
"os"
"path/filepath"
+ "strings"
"testing"
"github.com/larksuite/cli/internal/qualitygate/facts"
@@ -211,7 +212,19 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
- if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
+ f := facts.Facts{
+ SchemaVersion: 1,
+ Skills: []facts.SkillFact{{
+ SourceFile: "skills/lark-wiki/SKILL.md",
+ Line: 30,
+ Changed: true,
+ ReferencesInvalidCommand: true,
+ }},
+ }
+ if !semantic.BuildInputView(f).HasReviewableFacts() {
+ t.Fatal("test setup must contain reviewable facts")
+ }
+ if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")
@@ -228,6 +241,71 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
}
}
+func TestRunShortCircuitsEmptySemanticInputWithoutReviewer(t *testing.T) {
+ t.Setenv("ARK_API_KEY", "")
+ t.Setenv("ARK_BASE_URL", "")
+ t.Setenv("ARK_MODEL", "")
+
+ repo := t.TempDir()
+ writeSemanticConfig(t, repo, `{
+ "schema_version": 1,
+ "default_enforcement": "observe",
+ "block_categories": ["skill_quality"]
+ }`, `{
+ "allowed": ["semantic-review-v1"],
+ "allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
+ }`, "")
+ factsPath := filepath.Join(t.TempDir(), "facts.json")
+ f := facts.Facts{
+ SchemaVersion: 1,
+ Commands: []facts.CommandFact{{
+ Path: "service command 1",
+ Domain: "service",
+ Changed: true,
+ Source: "service",
+ }},
+ Outputs: []facts.OutputFact{{
+ Command: "service command 1",
+ Domain: "service",
+ Changed: true,
+ Source: "service",
+ IsList: true,
+ HasDefaultLimit: true,
+ HasDecisionField: true,
+ }},
+ }
+ if semantic.BuildInputView(f).HasReviewableFacts() {
+ t.Fatal("test setup must not contain reviewable facts")
+ }
+ if err := f.WriteFile(factsPath); err != nil {
+ t.Fatalf("write facts: %v", err)
+ }
+ decisionPath := filepath.Join(t.TempDir(), "decision.json")
+ markdownPath := filepath.Join(t.TempDir(), "semantic.md")
+ code := run([]string{"--repo", repo, "--facts", factsPath, "--decision-out", decisionPath, "--markdown-out", markdownPath, "--block"})
+ if code != 0 {
+ t.Fatalf("run() = %d, want clean pass", code)
+ }
+ decision := readDecision(t, decisionPath)
+ if decision.Skipped || decision.Degraded || decision.InfrastructureFailure || !decision.BlockMode {
+ t.Fatalf("expected non-degraded pass decision: %#v", decision)
+ }
+ if len(decision.SystemWarnings) != 0 || len(decision.Warnings) != 0 || len(decision.Blockers) != 0 {
+ t.Fatalf("empty semantic view should not produce findings: %#v", decision)
+ }
+ data, err := os.ReadFile(markdownPath)
+ if err != nil {
+ t.Fatalf("read markdown: %v", err)
+ }
+ markdown := string(data)
+ if !strings.Contains(markdown, "No semantic blockers.") {
+ t.Fatalf("markdown missing pass summary: %s", markdown)
+ }
+ if strings.Contains(strings.ToLower(markdown), "skipped") || strings.Contains(strings.ToLower(markdown), "degraded") {
+ t.Fatalf("markdown should not report semantic review as skipped/degraded: %s", markdown)
+ }
+}
+
func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testing.T) {
t.Setenv("ARK_API_KEY", "test-key")
t.Setenv("ARK_BASE_URL", "")
@@ -243,7 +321,19 @@ func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testi
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
- if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
+ f := facts.Facts{
+ SchemaVersion: 1,
+ Skills: []facts.SkillFact{{
+ SourceFile: "skills/lark-wiki/SKILL.md",
+ Line: 30,
+ Changed: true,
+ ReferencesInvalidCommand: true,
+ }},
+ }
+ if !semantic.BuildInputView(f).HasReviewableFacts() {
+ t.Fatal("test setup must contain reviewable facts")
+ }
+ if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")
diff --git a/internal/qualitygate/config/semantic/policy.json b/internal/qualitygate/config/semantic/policy.json
index fac5e11f..3fd4e9cb 100644
--- a/internal/qualitygate/config/semantic/policy.json
+++ b/internal/qualitygate/config/semantic/policy.json
@@ -5,7 +5,8 @@
"error_hint",
"default_output",
"naming",
- "skill_quality"
+ "skill_quality",
+ "public_content_leakage"
],
"rollout_groups": [
{
@@ -16,7 +17,8 @@
},
"categories": [
"error_hint",
- "skill_quality"
+ "skill_quality",
+ "public_content_leakage"
],
"owner": "cli-owner",
"reason": "first semantic blocking rollout only affects changed facts"
diff --git a/internal/qualitygate/facts/schema.go b/internal/qualitygate/facts/schema.go
index 8133dd66..a6e37ca7 100644
--- a/internal/qualitygate/facts/schema.go
+++ b/internal/qualitygate/facts/schema.go
@@ -13,14 +13,15 @@ import (
)
type Facts struct {
- SchemaVersion int `json:"schema_version"`
- Commands []CommandFact `json:"commands,omitempty"`
- Skills []SkillFact `json:"skills,omitempty"`
- SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
- Errors []ErrorFact `json:"errors,omitempty"`
- Outputs []OutputFact `json:"outputs,omitempty"`
- Examples []CommandExample `json:"examples,omitempty"`
- Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
+ SchemaVersion int `json:"schema_version"`
+ Commands []CommandFact `json:"commands,omitempty"`
+ Skills []SkillFact `json:"skills,omitempty"`
+ SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
+ Errors []ErrorFact `json:"errors,omitempty"`
+ Outputs []OutputFact `json:"outputs,omitempty"`
+ Examples []CommandExample `json:"examples,omitempty"`
+ PublicContent []PublicContentFact `json:"public_content,omitempty"`
+ Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
}
type CommandFact struct {
@@ -109,6 +110,17 @@ type OutputFact struct {
HasDecisionField bool `json:"has_decision_field,omitempty"`
}
+type PublicContentFact struct {
+ Rule string `json:"rule"`
+ Action report.Action `json:"action"`
+ File string `json:"file"`
+ Line int `json:"line"`
+ Source string `json:"source,omitempty"`
+ Excerpt string `json:"excerpt,omitempty"`
+ Message string `json:"message,omitempty"`
+ Suggestion string `json:"suggestion,omitempty"`
+}
+
type DryRunRequest struct {
Method string `json:"method"`
URL string `json:"url"`
@@ -206,6 +218,11 @@ func BuildWithCommandLookup(m manifest.Manifest, commandLookup manifest.Manifest
}
}
+func WithPublicContent(f Facts, publicContent []PublicContentFact) Facts {
+ f.PublicContent = publicContent
+ return f
+}
+
type commandScope struct {
Domain string
Source string
diff --git a/internal/qualitygate/facts/schema_test.go b/internal/qualitygate/facts/schema_test.go
index 551bed7b..0a790ea0 100644
--- a/internal/qualitygate/facts/schema_test.go
+++ b/internal/qualitygate/facts/schema_test.go
@@ -34,6 +34,7 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
Errors: []ErrorFact{{Code: "invalid_input", Message: "bad path", Hint: "pass --file", Retryable: false, HintActionCount: 1, RequiredHint: true}},
Outputs: []OutputFact{{Command: "im messages list", Fields: []string{"message_id", "sender", "create_time"}, IsList: true, HasDefaultLimit: true, HasDecisionField: true}},
Skills: []SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 1, DestructiveWithoutGuard: true, ScopeConflict: true}},
+ PublicContent: []PublicContentFact{{Rule: "public_content_generic_credential", Action: report.ActionReject, File: "docs/public.md", Line: 4, Excerpt: "api_key = "}},
}
data, err := json.Marshal(f)
if err != nil {
@@ -43,7 +44,10 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal facts: %v", err)
}
- if !got.Errors[0].RequiredHint || got.Outputs[0].Fields[0] != "message_id" || !got.Skills[0].ScopeConflict {
+ if !got.Errors[0].RequiredHint ||
+ got.Outputs[0].Fields[0] != "message_id" ||
+ !got.Skills[0].ScopeConflict ||
+ got.PublicContent[0].Rule != "public_content_generic_credential" {
t.Fatalf("facts lost gatekeeper fields: %#v", got)
}
}
diff --git a/internal/qualitygate/publiccontent/collect.go b/internal/qualitygate/publiccontent/collect.go
new file mode 100644
index 00000000..f21a0e5e
--- /dev/null
+++ b/internal/qualitygate/publiccontent/collect.go
@@ -0,0 +1,343 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package publiccontent
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+func Collect(ctx context.Context, opts Options) ([]Finding, error) {
+ metadata, err := LoadMetadata(opts.MetadataPath)
+ if err != nil {
+ return nil, err
+ }
+
+ var out []Finding
+ changedFiles, base, err := changedFiles(ctx, opts.Repo, opts.ChangedFrom)
+ if err != nil {
+ return nil, err
+ }
+ patches := map[string][]changedChunk{}
+ if base != "" {
+ patches, err = changedPatches(ctx, opts.Repo, base)
+ if err != nil {
+ return nil, err
+ }
+ }
+ for _, file := range changedFiles {
+ if !scanChangedFile(file) {
+ continue
+ }
+ for _, chunk := range patches[file] {
+ findings := scanText(file, "file", chunk.Text, isDetectorRuleFile(file))
+ for i := range findings {
+ findings[i].Line += chunk.StartLine - 1
+ }
+ out = append(out, findings...)
+ out = append(out, semanticCandidate(file, "file", chunk.Text, chunk.StartLine)...)
+ }
+ privateKeyFindings, err := scanTouchedPrivateKeyBlocks(ctx, opts.Repo, file, patches[file])
+ if err != nil {
+ return nil, err
+ }
+ out = appendUniqueFindings(out, privateKeyFindings...)
+ }
+ if base != "" {
+ commitFindings, err := scanCommitMessages(ctx, opts.Repo, base)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, commitFindings...)
+ }
+ branchName := opts.BranchName
+ if branchName == "" {
+ branchName = metadata.Branch
+ }
+ if branchName == "" {
+ branchName = branchFromEnv()
+ }
+ if branchName == "" {
+ branchName = currentBranch(ctx, opts.Repo)
+ }
+ if branchName != "" {
+ out = append(out, scanText("branch", "branch", branchName, false)...)
+ }
+ out = append(out, scanMetadata(metadata)...)
+ sort.SliceStable(out, func(i, j int) bool {
+ if out[i].File != out[j].File {
+ return out[i].File < out[j].File
+ }
+ if out[i].Line != out[j].Line {
+ return out[i].Line < out[j].Line
+ }
+ return out[i].Rule < out[j].Rule
+ })
+ return out, nil
+}
+
+func currentBranch(ctx context.Context, repo string) string {
+ data, err := gitOutput(ctx, repo, "branch", "--show-current")
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(data))
+}
+
+func branchFromEnv() string {
+ for _, key := range []string{"PR_BRANCH", "GITHUB_HEAD_REF", "GITHUB_REF_NAME"} {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func changedFiles(ctx context.Context, repo, changedFrom string) ([]string, string, error) {
+ if changedFrom == "" {
+ return nil, "", nil
+ }
+ baseBytes, err := gitOutput(ctx, repo, "merge-base", changedFrom, "HEAD")
+ if err != nil {
+ return nil, "", err
+ }
+ base := strings.TrimSpace(string(baseBytes))
+ files, err := diffFileNames(ctx, repo, base)
+ if err != nil {
+ return nil, "", err
+ }
+ sort.Strings(files)
+ return files, base, nil
+}
+
+func diffFileNames(ctx context.Context, repo, base string) ([]string, error) {
+ data, err := gitOutput(ctx, repo, "diff", "--name-only", "-z", "--diff-filter=ACMR", base+"..HEAD")
+ if err != nil {
+ return nil, err
+ }
+ var files []string
+ for _, file := range bytes.Split(data, []byte{0}) {
+ if len(file) == 0 {
+ continue
+ }
+ files = append(files, filepath.ToSlash(string(file)))
+ }
+ return files, nil
+}
+
+var detectorFixtureExclusions = map[string]bool{
+ "internal/qualitygate/publiccontent/collect_test.go": true,
+ "internal/qualitygate/publiccontent/rules.go": true,
+ "internal/qualitygate/publiccontent/scan.go": true,
+ "internal/qualitygate/publiccontent/scan_test.go": true,
+}
+
+func scanChangedFile(file string) bool {
+ normalized := strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), "./")
+ return !detectorFixtureExclusions[normalized]
+}
+
+type changedChunk struct {
+ StartLine int
+ Text string
+}
+
+func (c changedChunk) endLine() int {
+ lines := strings.Count(strings.TrimRight(c.Text, "\n"), "\n") + 1
+ if lines < 1 {
+ lines = 1
+ }
+ return c.StartLine + lines - 1
+}
+
+func changedPatches(ctx context.Context, repo, base string) (map[string][]changedChunk, error) {
+ files, err := diffFileNames(ctx, repo, base)
+ if err != nil {
+ return nil, err
+ }
+ data, err := gitOutput(ctx, repo, "diff", "--no-ext-diff", "--unified=0", "--diff-filter=ACMR", base+"..HEAD")
+ if err != nil {
+ return nil, err
+ }
+ out := map[string][]changedChunk{}
+ var file string
+ var chunk *changedChunk
+ nextLine := 0
+ nextFile := 0
+ flush := func() {
+ if file == "" || chunk == nil || chunk.Text == "" {
+ chunk = nil
+ return
+ }
+ out[file] = append(out[file], *chunk)
+ chunk = nil
+ }
+ for _, raw := range strings.Split(string(data), "\n") {
+ switch {
+ case strings.HasPrefix(raw, "diff --git "):
+ flush()
+ file = ""
+ if nextFile < len(files) {
+ file = files[nextFile]
+ nextFile++
+ }
+ case strings.HasPrefix(raw, "@@ "):
+ flush()
+ start, ok := parseNewHunkStart(raw)
+ if !ok {
+ nextLine = 0
+ continue
+ }
+ nextLine = start
+ chunk = &changedChunk{StartLine: start}
+ case strings.HasPrefix(raw, "+") && !strings.HasPrefix(raw, "+++"):
+ if chunk == nil {
+ chunk = &changedChunk{StartLine: max(nextLine, 1)}
+ }
+ chunk.Text += strings.TrimPrefix(raw, "+") + "\n"
+ nextLine++
+ case strings.HasPrefix(raw, "-"):
+ continue
+ default:
+ if chunk != nil && strings.HasPrefix(raw, `\ No newline at end of file`) {
+ continue
+ }
+ flush()
+ }
+ }
+ flush()
+ return out, nil
+}
+
+func parseNewHunkStart(header string) (int, bool) {
+ parts := strings.Split(header, " ")
+ for _, part := range parts {
+ if !strings.HasPrefix(part, "+") {
+ continue
+ }
+ raw := strings.TrimPrefix(part, "+")
+ if before, _, ok := strings.Cut(raw, ","); ok {
+ raw = before
+ }
+ start, err := strconv.Atoi(raw)
+ return start, err == nil && start > 0
+ }
+ return 0, false
+}
+
+func scanCommitMessages(ctx context.Context, repo, base string) ([]Finding, error) {
+ data, err := gitOutput(ctx, repo, "log", "--format=%H%x00%B%x00", base+"..HEAD")
+ if err != nil {
+ return nil, err
+ }
+ parts := bytes.Split(data, []byte{0})
+ var out []Finding
+ for i := 0; i+1 < len(parts); i += 2 {
+ sha := strings.TrimSpace(string(parts[i]))
+ body := string(parts[i+1])
+ if sha == "" || body == "" {
+ continue
+ }
+ short := sha
+ if len(short) > 12 {
+ short = short[:12]
+ }
+ out = append(out, scanText("commit:"+short, "commit", body, false)...)
+ out = append(out, semanticCandidate("commit:"+short, "commit", body, 1)...)
+ }
+ return out, nil
+}
+
+type lineRange struct {
+ Start int
+ End int
+}
+
+func scanTouchedPrivateKeyBlocks(ctx context.Context, repo, file string, chunks []changedChunk) ([]Finding, error) {
+ if len(chunks) == 0 {
+ return nil, nil
+ }
+ data, err := gitOutput(ctx, repo, "show", "HEAD:"+file)
+ if err != nil {
+ return nil, err
+ }
+ var added []lineRange
+ for _, chunk := range chunks {
+ added = append(added, lineRange{Start: chunk.StartLine, End: chunk.endLine()})
+ }
+ var out []Finding
+ for _, block := range privateKeyBlocks(string(data)) {
+ if !rangesIntersectAny(block, added) {
+ continue
+ }
+ out = append(out, newFinding("public_content_private_key_block", file, block.Start, "file", "private key block"))
+ }
+ return out, nil
+}
+
+func privateKeyBlocks(text string) []lineRange {
+ lines := strings.Split(text, "\n")
+ var out []lineRange
+ inPrivateKey := false
+ start := 0
+ for i, line := range lines {
+ lineNo := i + 1
+ if !inPrivateKey && strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
+ inPrivateKey = true
+ start = lineNo
+ }
+ if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
+ out = append(out, lineRange{Start: start, End: lineNo})
+ inPrivateKey = false
+ }
+ }
+ return out
+}
+
+func rangesIntersectAny(block lineRange, ranges []lineRange) bool {
+ for _, r := range ranges {
+ if block.Start <= r.End && r.Start <= block.End {
+ return true
+ }
+ }
+ return false
+}
+
+func appendUniqueFindings(items []Finding, additions ...Finding) []Finding {
+ for _, addition := range additions {
+ duplicate := false
+ for _, item := range items {
+ if item.Rule == addition.Rule &&
+ item.File == addition.File &&
+ item.Line == addition.Line &&
+ item.Source == addition.Source {
+ duplicate = true
+ break
+ }
+ }
+ if !duplicate {
+ items = append(items, addition)
+ }
+ }
+ return items
+}
+
+func gitOutput(ctx context.Context, repo string, args ...string) ([]byte, error) {
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = repo
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, stderr.Bytes())
+ }
+ return stdout.Bytes(), nil
+}
diff --git a/internal/qualitygate/publiccontent/collect_test.go b/internal/qualitygate/publiccontent/collect_test.go
new file mode 100644
index 00000000..5ea92779
--- /dev/null
+++ b/internal/qualitygate/publiccontent/collect_test.go
@@ -0,0 +1,885 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package publiccontent
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
+ repo := t.TempDir()
+ runGit(t, repo, "init")
+ runGit(t, repo, "config", "user.email", "test@example.com")
+ runGit(t, repo, "config", "user.name", "Test User")
+
+ writeFile(t, filepath.Join(repo, "baseline.md"), `BASE_`+`TOKEN="baseline-only"
+`)
+ runGit(t, repo, "add", "baseline.md")
+ runGit(t, repo, "commit", "-m", "base")
+
+ writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
+
+api_`+`key = "example-public-key"
+`)
+ runGit(t, repo, "add", "docs/public.md")
+ runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
+
+ metadataPath := filepath.Join(repo, "pr-metadata.json")
+ writeFile(t, metadataPath, `{"title":"publish public docs","body":"Reviewed`+`-on: https://review.example.test/c/project/+/123"}`)
+
+ got, err := Collect(context.Background(), Options{
+ Repo: repo,
+ ChangedFrom: "HEAD~1",
+ MetadataPath: metadataPath,
+ })
+ if err != nil {
+ t.Fatalf("Collect() error = %v", err)
+ }
+
+ rules := findingRules(got)
+ for _, want := range []string{
+ "public_content_generic_credential",
+ "public_content_change_id_trailer",
+ "public_content_reviewed_on_trailer",
+ } {
+ if !rules[want] {
+ t.Fatalf("missing rule %s in findings %#v", want, got)
+ }
+ }
+ for _, item := range got {
+ if item.File == "baseline.md" {
+ t.Fatalf("collector scanned unchanged baseline file: %#v", got)
+ }
+ }
+}
+
+func TestCollectScansOnlyChangedLinesInChangedFiles(t *testing.T) {
+ repo := t.TempDir()
+ runGit(t, repo, "init")
+ runGit(t, repo, "config", "user.email", "test@example.com")
+ runGit(t, repo, "config", "user.name", "Test User")
+
+ writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\n")
+ runGit(t, repo, "add", "docs/workflow.md")
+ runGit(t, repo, "commit", "-m", "base")
+
+ writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\nnew public line\n")
+ runGit(t, repo, "add", "docs/workflow.md")
+ runGit(t, repo, "commit", "-m", "add public line")
+
+ metadataPath := filepath.Join(repo, "pr-metadata.json")
+ writeFile(t, metadataPath, `{}`)
+
+ got, err := Collect(context.Background(), Options{
+ Repo: repo,
+ ChangedFrom: "HEAD~1",
+ MetadataPath: metadataPath,
+ })
+ if err != nil {
+ t.Fatalf("Collect() error = %v", err)
+ }
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" && item.File == "docs/workflow.md" {
+ t.Fatalf("collector scanned unchanged legacy line in changed file: %#v", got)
+ }
+ }
+}
+
+func TestCollectSemanticCandidatesStoreSanitizedReviewText(t *testing.T) {
+ repo := t.TempDir()
+ runGit(t, repo, "init")
+ runGit(t, repo, "config", "user.email", "test@example.com")
+ runGit(t, repo, "config", "user.name", "Test User")
+
+ writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
+ runGit(t, repo, "add", "docs/public.md")
+ runGit(t, repo, "commit", "-m", "base")
+
+ raw := "private launch plan for alpha-service rollout on Friday with SERVICE_" + "TOKEN=real-" + "secret-value"
+ writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
+ runGit(t, repo, "add", "docs/public.md")
+ runGit(t, repo, "commit", "-m", "add semantic candidate")
+
+ metadataPath := filepath.Join(repo, "pr-metadata.json")
+ writeFile(t, metadataPath, `{}`)
+
+ got, err := Collect(context.Background(), Options{
+ Repo: repo,
+ ChangedFrom: "HEAD~1",
+ MetadataPath: metadataPath,
+ })
+ if err != nil {
+ t.Fatalf("Collect() error = %v", err)
+ }
+ var found bool
+ for _, item := range got {
+ if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
+ continue
+ }
+ found = true
+ if !strings.Contains(item.Excerpt, "alpha-service rollout on Friday") {
+ t.Fatalf("semantic candidate should include sanitized review text, got %#v", item)
+ }
+ if strings.Contains(item.Excerpt, "real-"+"secret-value") {
+ t.Fatalf("semantic candidate leaked credential value: %#v", item)
+ }
+ if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=") {
+ t.Fatalf("semantic candidate should redact credentials in review text, got %#v", item)
+ }
+ if !strings.Contains(item.Excerpt, "semantic signals") || !strings.Contains(item.Excerpt, "roadmap_timing") {
+ t.Fatalf("semantic candidate excerpt should preserve semantic signals, got %#v", item)
+ }
+ }
+ if !found {
+ t.Fatalf("missing semantic candidate in findings %#v", got)
+ }
+}
+
+func TestCollectSemanticCandidatesDoNotLeakWhitespaceCredentialTail(t *testing.T) {
+ repo := newGitRepo(t)
+ writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
+ runGit(t, repo, "add", "docs/public.md")
+ runGit(t, repo, "commit", "-m", "base")
+
+ raw := "private launch plan for internal rollout on Friday with SERVICE_" + "TOKEN=\"real " + "secret value\""
+ writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
+ runGit(t, repo, "add", "docs/public.md")
+ runGit(t, repo, "commit", "-m", "add semantic candidate")
+
+ got := collectFromPreviousCommit(t, repo)
+ for _, item := range got {
+ if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
+ continue
+ }
+ if strings.Contains(item.Excerpt, "secret value") || strings.Contains(item.Excerpt, "real "+"secret value") {
+ t.Fatalf("semantic candidate leaked credential tail: %#v", item)
+ }
+ if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=") {
+ t.Fatalf("semantic candidate should redact full credential assignment, got %#v", item)
+ }
+ return
+ }
+ t.Fatalf("missing semantic candidate in findings %#v", got)
+}
+
+func TestCollectJSONBearerHeadersDoNotLeakIntoSemanticCandidates(t *testing.T) {
+ repo := newGitRepo(t)
+ writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
+ runGit(t, repo, "add", "docs/public.md")
+ runGit(t, repo, "commit", "-m", "base")
+
+ token := "abcdefghijklmnopqrstuvwxyz"
+ raw := "private launch plan for internal rollout on Friday with " +
+ `{"headers":{"Authorization":"Bearer ` + token + `"}}`
+ writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
+ runGit(t, repo, "add", "docs/public.md")
+ runGit(t, repo, "commit", "-m", "add json bearer")
+
+ got := collectFromPreviousCommit(t, repo)
+ requireFinding(t, got, "docs/public.md", "public_content_bearer_header")
+ for _, item := range got {
+ if item.File != "docs/public.md" {
+ continue
+ }
+ if strings.Contains(item.Excerpt, token) {
+ t.Fatalf("finding leaked JSON bearer token: %#v", item)
+ }
+ }
+}
+
+func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
+ repo := newGitRepo(t)
+ writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
+ runGit(t, repo, "add", "docs/public.json")
+ runGit(t, repo, "commit", "-m", "base")
+
+ writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
+ `{"access_` + `token":"real-json-token"}`,
+ `{"client_` + `secret": "real ` + `secret value"}`,
+ `{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
+ `{"github` + `Token":"real-github-token"}`,
+ `{"vendorApi` + `Key":"real-vendor-key"}`,
+ `{"slackBot` + `Token":"xoxb-real-token"}`,
+ }, "\n")+"\n")
+ runGit(t, repo, "add", "docs/public.json")
+ runGit(t, repo, "commit", "-m", "add json config")
+
+ got := collectFromPreviousCommit(t, repo)
+ var count int
+ for _, item := range got {
+ if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
+ count++
+ for _, forbidden := range []string{
+ "real-json-token",
+ "real secret value",
+ "real-tenant-camel-token",
+ "real-github-token",
+ "real-vendor-key",
+ "xoxb-real-token",
+ } {
+ if strings.Contains(item.Excerpt, forbidden) {
+ t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
+ }
+ }
+ }
+ }
+ if count != 6 {
+ t.Fatalf("JSON credential findings = %d, want 6: %#v", count, got)
+ }
+}
+
+func TestCollectAllowsBenignJSONTokenFields(t *testing.T) {
+ repo := newGitRepo(t)
+ writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
+ runGit(t, repo, "add", "docs/public.json")
+ runGit(t, repo, "commit", "-m", "base")
+
+ writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
+ `{"tokenizer":"cl100k_base"}`,
+ `{"token_count": 42}`,
+ `{"page_token":"next"}`,
+ `{"next_page_token":"next"}`,
+ `{"file_token":"file-example"}`,
+ `{"doc_token":"doc-example"}`,
+ `{"node_token":"node-example"}`,
+ `{"wiki_token":"wikcn_public_doc_example"}`,
+ `{"folder_token":"folder-example"}`,
+ `{"obj_token":"obj-example"}`,
+ `{"spreadsheet_token":"sheet-example"}`,
+ `{"parent_node_token":"parent-example"}`,
+ `{"origin_node_token":"origin-example"}`,
+ `{"drive_route_token":"route-example"}`,
+ `{"token":""}`,
+ `{"token":"wiki_token"}`,
+ `{"token_url":"https://example.com/oauth/token"}`,
+ `{"token_endpoint":"https://example.com/oauth/token"}`,
+ `{"token_format":"Bearer"}`,
+ `{"secret_name":"public-example-secret"}`,
+ `{"base_token":"base-example"}`,
+ `{"app_token":"app-example"}`,
+ `{"sync_token":"sync-example"}`,
+ `{"parent_token":"parent-example"}`,
+ `{"target_token":"target-example"}`,
+ `{"parent_file_token":"parent-file-example"}`,
+ `{"refresh_token_expires_in": 7200}`,
+ `{"access_token_expires_in": 7200}`,
+ `{"token_expires_in": 7200}`,
+ `{"token_status":"active"}`,
+ }, "\n")+"\n")
+ runGit(t, repo, "add", "docs/public.json")
+ runGit(t, repo, "commit", "-m", "add benign json token fields")
+
+ got := collectFromPreviousCommit(t, repo)
+ for _, item := range got {
+ if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
+ t.Fatalf("benign JSON token field should not be credential finding: %#v", got)
+ }
+ }
+}
+
+func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
+ repo := newGitRepo(t)
+ writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
+ runGit(t, repo, "add", "docs/config.yaml")
+ runGit(t, repo, "commit", "-m", "base")
+ stripeLike := "sk_" + "live_1234567890abcdef"
+ patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
+
+ writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
+ "API_KEY: <" + stripeLike + ">",
+ "SECRET_TOKEN: <" + patLike + ">",
+ "CLIENT_SECRET: ",
+ }, "\n")+"\n")
+ runGit(t, repo, "add", "docs/config.yaml")
+ runGit(t, repo, "commit", "-m", "add credential config")
+
+ got := collectFromPreviousCommit(t, repo)
+ var count int
+ for _, item := range got {
+ if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
+ count++
+ }
+ }
+ if count != 3 {
+ t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
+ }
+}
+
+func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
+ repo := newGitRepo(t)
+ writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
+ runGit(t, repo, "add", "docs/public.json")
+ runGit(t, repo, "commit", "-m", "base")
+ stripeLike := "sk_" + "live_1234567890abcdef"
+ patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
+
+ writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
+ `{"access_token_expires_in":"` + patLike + `"}`,
+ `{"refresh_token_expires_in":"` + stripeLike + `"}`,
+ `{"client_secret_status":"real-client-secret-value"}`,
+ `{"client_secret_name":"real-client-secret-value"}`,
+ `{"app_token":"` + patLike + `"}`,
+ `{"sync_token":"` + stripeLike + `"}`,
+ `{"target_token":"real-client-secret-value"}`,
+ }, "\n")+"\n")
+ runGit(t, repo, "add", "docs/public.json")
+ runGit(t, repo, "commit", "-m", "add credential-shaped benign fields")
+
+ got := collectFromPreviousCommit(t, repo)
+ var count int
+ for _, item := range got {
+ if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
+ count++
+ }
+ }
+ if count != 7 {
+ t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
+ }
+}
+
+func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
+ repo := newGitRepo(t)
+ writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
+ runGit(t, repo, "add", "docs/config.yaml")
+ runGit(t, repo, "commit", "-m", "base")
+
+ writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
+ "API_KEY_NAME: prod_key",
+ "CLIENT_SECRET_NAME: prod_secret",
+ "SECRET_STATUS: prod_secret",
+ }, "\n")+"\n")
+ runGit(t, repo, "add", "docs/config.yaml")
+ runGit(t, repo, "commit", "-m", "add credential config")
+
+ got := collectFromPreviousCommit(t, repo)
+ var count int
+ for _, item := range got {
+ if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
+ count++
+ }
+ }
+ if count != 3 {
+ t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
+ }
+}
+
+func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
+ repo := newGitRepo(t)
+ writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
+ runGit(t, repo, "add", "docs/config.yaml")
+ runGit(t, repo, "commit", "-m", "base")
+ accessKey := "AK" + "IAIOSFODNN7EXAMPX"
+
+ writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
+ "AWS_ACCESS_KEY_ID: " + accessKey,
+ "ACCESS_KEY_ID: " + accessKey,
+ "ACCESS_KEY: " + accessKey,
+ }, "\n")+"\n")
+ runGit(t, repo, "add", "docs/config.yaml")
+ runGit(t, repo, "commit", "-m", "add access key config")
+
+ got := collectFromPreviousCommit(t, repo)
+ var count int
+ for _, item := range got {
+ if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
+ continue
+ }
+ count++
+ if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
+ t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
+ }
+ }
+ if count != 3 {
+ t.Fatalf("access key credential findings = %d, want 3: %#v", count, got)
+ }
+}
+
+func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
+ repo := newGitRepo(t)
+ writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
+ runGit(t, repo, "add", "docs/config.yaml")
+ runGit(t, repo, "commit", "-m", "base")
+
+ privateKey := "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t"
+ writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
+ "PRIVATE_KEY: " + privateKey,
+ "SSH_PRIVATE_KEY: " + privateKey,
+ "JWT_PRIVATE_KEY: " + privateKey,
+ "SIGNING_PRIVATE_KEY: " + privateKey,
+ }, "\n")+"\n")
+ runGit(t, repo, "add", "docs/config.yaml")
+ runGit(t, repo, "commit", "-m", "add private key config")
+
+ got := collectFromPreviousCommit(t, repo)
+ var count int
+ for _, item := range got {
+ if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
+ continue
+ }
+ count++
+ if strings.Contains(item.Excerpt, privateKey) {
+ t.Fatalf("private key finding leaked value in excerpt %q", item.Excerpt)
+ }
+ }
+ if count != 4 {
+ t.Fatalf("private key assignment findings = %d, want 4: %#v", count, got)
+ }
+}
+
+func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
+ repo := newGitRepo(t)
+ writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
+ runGit(t, repo, "add", "docs/config.yaml")
+ runGit(t, repo, "commit", "-m", "base")
+
+ writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
+ "API_KEY_OPENAI: prod_key",
+ "CLIENT_SECRET_GOOGLE: prod_secret",
+ "TOKEN_GITHUB: github_token",
+ "APP_PASSWORD_PROD: prod_password",
+ }, "\n")+"\n")
+ runGit(t, repo, "add", "docs/config.yaml")
+ runGit(t, repo, "commit", "-m", "add credential config")
+
+ got := collectFromPreviousCommit(t, repo)
+ var count int
+ for _, item := range got {
+ if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
+ count++
+ }
+ }
+ if count != 4 {
+ t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
+ }
+}
+
+func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
+ repo := newGitRepo(t)
+ writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
+ runGit(t, repo, "add", "docs/config.yaml")
+ runGit(t, repo, "commit", "-m", "base")
+
+ writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
+ "tokens: 128",
+ "token_type: bearer",
+ "max_tokens: 2000",
+ "completion_tokens: 200",
+ "prompt_tokens: 100",
+ }, "\n")+"\n")
+ runGit(t, repo, "add", "docs/config.yaml")
+ runGit(t, repo, "commit", "-m", "add benign token config")
+
+ got := collectFromPreviousCommit(t, repo)
+ for _, item := range got {
+ if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
+ t.Fatalf("benign unquoted token field should not be credential finding: %#v", got)
+ }
+ }
+}
+
+func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
+ repo := newGitRepo(t)
+ writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
+ runGit(t, repo, "add", "docs/config.yaml")
+ runGit(t, repo, "commit", "-m", "base")
+
+ writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
+ "API_KEY_OPENAI: real-openai-key",
+ "TOKEN_GITHUB: real-github-token",
+ "CLIENT_SECRET_GOOGLE: real-google-secret",
+ "SECRET_KEY_BASE: real-secret-key-base",
+ "APP_PASSWORD_PROD: real-prod-password",
+ }, "\n")+"\n")
+ runGit(t, repo, "add", "docs/config.yaml")
+ runGit(t, repo, "commit", "-m", "add credential config")
+
+ got := collectFromPreviousCommit(t, repo)
+ var count int
+ for _, item := range got {
+ if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
+ continue
+ }
+ count++
+ for _, forbidden := range []string{
+ "real-openai-key",
+ "real-github-token",
+ "real-google-secret",
+ "real-secret-key-base",
+ "real-prod-password",
+ } {
+ if strings.Contains(item.Excerpt, forbidden) {
+ t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
+ }
+ }
+ }
+ if count != 5 {
+ t.Fatalf("credential suffix variants findings = %d, want 5: %#v", count, got)
+ }
+}
+
+func TestCollectDetectsPrivateKeyWhenOnlyEndIsAdded(t *testing.T) {
+ repo := newGitRepo(t)
+
+ writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n")
+ runGit(t, repo, "add", "docs/key.pem")
+ runGit(t, repo, "commit", "-m", "base")
+
+ writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\nnew-body\n"+privateKeyEnd())
+ runGit(t, repo, "add", "docs/key.pem")
+ runGit(t, repo, "commit", "-m", "complete key")
+
+ got := collectFromPreviousCommit(t, repo)
+ requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
+}
+
+func TestCollectDetectsPrivateKeyWhenOnlyBeginIsAdded(t *testing.T) {
+ repo := newGitRepo(t)
+
+ writeFile(t, filepath.Join(repo, "docs", "key.pem"), "legacy-body\n"+privateKeyEnd())
+ runGit(t, repo, "add", "docs/key.pem")
+ runGit(t, repo, "commit", "-m", "base")
+
+ writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
+ runGit(t, repo, "add", "docs/key.pem")
+ runGit(t, repo, "commit", "-m", "complete key")
+
+ got := collectFromPreviousCommit(t, repo)
+ requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
+}
+
+func TestCollectDetectsPrivateKeyWhenOnlyBodyIsAdded(t *testing.T) {
+ repo := newGitRepo(t)
+
+ writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
+ runGit(t, repo, "add", "docs/key.pem")
+ runGit(t, repo, "commit", "-m", "base")
+
+ writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"new-body\n"+privateKeyEnd())
+ runGit(t, repo, "add", "docs/key.pem")
+ runGit(t, repo, "commit", "-m", "add body")
+
+ got := collectFromPreviousCommit(t, repo)
+ requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
+}
+
+func TestCollectIgnoresUntouchedHistoricalPrivateKey(t *testing.T) {
+ repo := newGitRepo(t)
+
+ writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
+ runGit(t, repo, "add", "docs/key.pem")
+ runGit(t, repo, "commit", "-m", "base")
+
+ writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
+ writeFile(t, filepath.Join(repo, "docs", "public.md"), "public docs update\n")
+ runGit(t, repo, "add", "docs/public.md")
+ runGit(t, repo, "commit", "-m", "docs update")
+
+ got := collectFromPreviousCommit(t, repo)
+ for _, item := range got {
+ if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
+ t.Fatalf("collector reported untouched historical private key: %#v", got)
+ }
+ }
+}
+
+func TestCollectIgnoresDeletedPrivateKeyLine(t *testing.T) {
+ repo := newGitRepo(t)
+
+ writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
+ runGit(t, repo, "add", "docs/key.pem")
+ runGit(t, repo, "commit", "-m", "base")
+
+ writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
+ runGit(t, repo, "add", "docs/key.pem")
+ runGit(t, repo, "commit", "-m", "remove body")
+
+ got := collectFromPreviousCommit(t, repo)
+ for _, item := range got {
+ if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
+ t.Fatalf("collector reported delete-only private key cleanup: %#v", got)
+ }
+ }
+}
+
+func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
+ repo := t.TempDir()
+ runGit(t, repo, "init")
+ runGit(t, repo, "config", "user.email", "test@example.com")
+ runGit(t, repo, "config", "user.name", "Test User")
+
+ writeFile(t, filepath.Join(repo, "README.md"), "base\n")
+ runGit(t, repo, "add", "README.md")
+ runGit(t, repo, "commit", "-m", "base")
+
+ writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "collect_test.go"), "SECRET_TOKEN=fixture\n")
+ writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
+ writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
+ writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
+ writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
+ runGit(t, repo, "add", ".")
+ runGit(t, repo, "commit", "-m", "add scanner fixtures")
+
+ metadataPath := filepath.Join(repo, "pr-metadata.json")
+ writeFile(t, metadataPath, `{}`)
+
+ got, err := Collect(context.Background(), Options{
+ Repo: repo,
+ ChangedFrom: "HEAD~1",
+ MetadataPath: metadataPath,
+ })
+ if err != nil {
+ t.Fatalf("Collect() error = %v", err)
+ }
+ var foundOrdinaryTestLeak bool
+ for _, item := range got {
+ switch item.File {
+ case "internal/qualitygate/publiccontent/collect_test.go",
+ "internal/qualitygate/publiccontent/scan.go",
+ "internal/qualitygate/publiccontent/scan_test.go",
+ "internal/qualitygate/publiccontent/rules.go":
+ t.Fatalf("collector scanned known fixture or detector implementation file: %#v", got)
+ }
+ if item.File == "tests/e2e/new-public-workflow.test.sh" && item.Rule == "public_content_generic_credential" {
+ foundOrdinaryTestLeak = true
+ }
+ }
+ if !foundOrdinaryTestLeak {
+ t.Fatalf("collector should still scan ordinary test files for real leaks: %#v", got)
+ }
+}
+
+func TestScanChangedFileDocumentsFixtureExclusions(t *testing.T) {
+ excluded := []string{
+ "internal/qualitygate/publiccontent/collect_test.go",
+ "internal/qualitygate/publiccontent/rules.go",
+ "internal/qualitygate/publiccontent/scan.go",
+ "internal/qualitygate/publiccontent/scan_test.go",
+ }
+ for _, file := range excluded {
+ if scanChangedFile(file) {
+ t.Fatalf("scanChangedFile(%q) = true, want false for detector fixture/implementation path", file)
+ }
+ }
+
+ included := []string{
+ "internal/qualitygate/publiccontent/new_test.go",
+ "tests/e2e/new-public-workflow.test.sh",
+ "docs/public.md",
+ }
+ for _, file := range included {
+ if !scanChangedFile(file) {
+ t.Fatalf("scanChangedFile(%q) = false, want true", file)
+ }
+ }
+}
+
+func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
+ repo := newGitRepo(t)
+ writeFile(t, filepath.Join(repo, "docs", "old.md"), "base\n")
+ runGit(t, repo, "add", ".")
+ runGit(t, repo, "commit", "-m", "base")
+
+ writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
+ writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
+ runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
+ writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
+ runGit(t, repo, "add", ".")
+ runGit(t, repo, "commit", "-m", "add special paths")
+
+ got := collectFromPreviousCommit(t, repo)
+ requireFinding(t, got, "docs/has space.md", "public_content_generic_credential")
+ requireFinding(t, got, `weird"quote.md`, "public_content_generic_credential")
+ requireFinding(t, got, "docs/new name.md", "public_content_generic_credential")
+}
+
+func TestCollectScansBranchNameAsWarning(t *testing.T) {
+ repo := t.TempDir()
+ metadataPath := filepath.Join(repo, "pr-metadata.json")
+ writeFile(t, metadataPath, `{"branch":"bot/public-doc-update"}`)
+ got, err := Collect(context.Background(), Options{
+ Repo: repo,
+ MetadataPath: metadataPath,
+ })
+ if err != nil {
+ t.Fatalf("Collect() error = %v", err)
+ }
+ if len(got) != 1 || got[0].Rule != "public_content_automation_branch" {
+ t.Fatalf("branch findings = %#v", got)
+ }
+}
+
+func TestCollectUsesExplicitBranchNameWhenDetached(t *testing.T) {
+ repo := newGitRepo(t)
+ writeFile(t, filepath.Join(repo, "README.md"), "base\n")
+ runGit(t, repo, "add", "README.md")
+ runGit(t, repo, "commit", "-m", "base")
+ runGit(t, repo, "checkout", "-b", "bot/public-doc-update")
+ writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
+ runGit(t, repo, "add", "docs.md")
+ runGit(t, repo, "commit", "-m", "docs")
+ head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
+ runGit(t, repo, "checkout", "--detach", head)
+
+ metadataPath := filepath.Join(repo, "pr-metadata.json")
+ writeFile(t, metadataPath, `{}`)
+ got, err := Collect(context.Background(), Options{
+ Repo: repo,
+ MetadataPath: metadataPath,
+ BranchName: "bot/public-doc-update",
+ })
+ if err != nil {
+ t.Fatalf("Collect() error = %v", err)
+ }
+ requireFinding(t, got, "branch", "public_content_automation_branch")
+}
+
+func TestCollectUsesBranchEnvironmentWhenDetached(t *testing.T) {
+ repo := newGitRepo(t)
+ writeFile(t, filepath.Join(repo, "README.md"), "base\n")
+ runGit(t, repo, "add", "README.md")
+ runGit(t, repo, "commit", "-m", "base")
+ runGit(t, repo, "checkout", "-b", "bot/public-env-update")
+ writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
+ runGit(t, repo, "add", "docs.md")
+ runGit(t, repo, "commit", "-m", "docs")
+ head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
+ runGit(t, repo, "checkout", "--detach", head)
+ t.Setenv("GITHUB_HEAD_REF", "bot/public-env-update")
+
+ metadataPath := filepath.Join(repo, "pr-metadata.json")
+ writeFile(t, metadataPath, `{}`)
+ got, err := Collect(context.Background(), Options{
+ Repo: repo,
+ MetadataPath: metadataPath,
+ })
+ if err != nil {
+ t.Fatalf("Collect() error = %v", err)
+ }
+ requireFinding(t, got, "branch", "public_content_automation_branch")
+}
+
+func TestCollectPreservesFindingAttributionForChangedLines(t *testing.T) {
+ repo := newGitRepo(t)
+ writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\n")
+ runGit(t, repo, "add", "docs/auth.md")
+ runGit(t, repo, "commit", "-m", "base")
+
+ writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\nAuthorization: Bearer abcdefghijklmnopqrstuvwxyz\n")
+ runGit(t, repo, "add", "docs/auth.md")
+ runGit(t, repo, "commit", "-m", "add auth docs")
+
+ got := collectFromPreviousCommit(t, repo)
+ for _, item := range got {
+ if item.Rule == "public_content_bearer_header" {
+ if item.File != "docs/auth.md" || item.Line != 2 || item.Source != "file" {
+ t.Fatalf("changed-line attribution = %#v", item)
+ }
+ return
+ }
+ }
+ t.Fatalf("missing bearer finding: %#v", got)
+}
+
+func TestAppendUniqueFindingsDeduplicatesByRuleFileLineAndSource(t *testing.T) {
+ base := []Finding{newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block")}
+ got := appendUniqueFindings(base,
+ newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block"),
+ newFinding("public_content_private_key_block", "docs/key.pem", 2, "file", "private key block"),
+ )
+ if len(got) != 2 {
+ t.Fatalf("appendUniqueFindings len = %d, want 2: %#v", len(got), got)
+ }
+}
+
+func newGitRepo(t *testing.T) string {
+ t.Helper()
+ repo := t.TempDir()
+ runGit(t, repo, "init")
+ runGit(t, repo, "config", "user.email", "test@example.com")
+ runGit(t, repo, "config", "user.name", "Test User")
+ return repo
+}
+
+func privateKeyBegin() string {
+ return privateKeyBeginPrefix + privateKeyMarker + "\n"
+}
+
+func privateKeyEnd() string {
+ return privateKeyEndPrefix + privateKeyMarker + "\n"
+}
+
+func collectFromPreviousCommit(t *testing.T, repo string) []Finding {
+ t.Helper()
+ metadataPath := filepath.Join(repo, "pr-metadata.json")
+ writeFile(t, metadataPath, `{}`)
+ got, err := Collect(context.Background(), Options{
+ Repo: repo,
+ ChangedFrom: "HEAD~1",
+ MetadataPath: metadataPath,
+ })
+ if err != nil {
+ t.Fatalf("Collect() error = %v", err)
+ }
+ return got
+}
+
+func requireFinding(t *testing.T, got []Finding, file, rule string) {
+ t.Helper()
+ for _, item := range got {
+ if item.File == file && item.Rule == rule {
+ return
+ }
+ }
+ t.Fatalf("missing %s in %s findings: %#v", rule, file, got)
+}
+
+func TestCollectRequiresValidMetadataJSON(t *testing.T) {
+ repo := t.TempDir()
+ metadataPath := filepath.Join(repo, "pr-metadata.json")
+ writeFile(t, metadataPath, `{"title":`)
+
+ _, err := Collect(context.Background(), Options{Repo: repo, MetadataPath: metadataPath})
+ if err == nil || !strings.Contains(err.Error(), "public content metadata") {
+ t.Fatalf("Collect() error = %v, want metadata parse error", err)
+ }
+}
+
+func runGit(t *testing.T, repo string, args ...string) {
+ t.Helper()
+ if len(args) > 0 && args[0] == "commit" {
+ args = append([]string{"commit", "--no-verify"}, args[1:]...)
+ }
+ cmd := exec.Command("git", args...)
+ cmd.Dir = repo
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v failed: %v\n%s", args, err, out)
+ }
+}
+
+func runGitOutput(t *testing.T, repo string, args ...string) []byte {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = repo
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v failed: %v\n%s", args, err, out)
+ }
+ return out
+}
+
+func writeFile(t *testing.T, path, data string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/qualitygate/publiccontent/comment_audit.go b/internal/qualitygate/publiccontent/comment_audit.go
new file mode 100644
index 00000000..760fdcf9
--- /dev/null
+++ b/internal/qualitygate/publiccontent/comment_audit.go
@@ -0,0 +1,11 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package publiccontent
+
+func ScanComment(kind, body string) []Finding {
+ if kind == "" {
+ kind = "comment"
+ }
+ return scanText(kind, "comment", body, false)
+}
diff --git a/internal/qualitygate/publiccontent/comment_audit_test.go b/internal/qualitygate/publiccontent/comment_audit_test.go
new file mode 100644
index 00000000..6d05e675
--- /dev/null
+++ b/internal/qualitygate/publiccontent/comment_audit_test.go
@@ -0,0 +1,19 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package publiccontent
+
+import "testing"
+
+func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
+ got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
+ rules := findingRules(got)
+ if !rules["public_content_harness_metadata"] || !rules["public_content_ccm_harness_trailer"] {
+ t.Fatalf("comment audit findings = %#v", got)
+ }
+ for _, item := range got {
+ if item.File != "issue_comment" {
+ t.Fatalf("comment finding file = %q, want issue_comment", item.File)
+ }
+ }
+}
diff --git a/internal/qualitygate/publiccontent/metadata.go b/internal/qualitygate/publiccontent/metadata.go
new file mode 100644
index 00000000..14fd9907
--- /dev/null
+++ b/internal/qualitygate/publiccontent/metadata.go
@@ -0,0 +1,45 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package publiccontent
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/larksuite/cli/internal/vfs"
+)
+
+func LoadMetadata(path string) (Metadata, error) {
+ if path == "" {
+ return Metadata{}, nil
+ }
+ data, err := vfs.ReadFile(path)
+ if err != nil {
+ return Metadata{}, fmt.Errorf("public content metadata: %w", err)
+ }
+ if len(data) == 0 {
+ return Metadata{}, nil
+ }
+ var out Metadata
+ if err := json.Unmarshal(data, &out); err != nil {
+ return Metadata{}, fmt.Errorf("public content metadata: %w", err)
+ }
+ return out, nil
+}
+
+func scanMetadata(m Metadata) []Finding {
+ text := ""
+ if m.Title != "" {
+ text += "title: " + m.Title + "\n"
+ }
+ if m.Body != "" {
+ text += "body:\n" + m.Body + "\n"
+ }
+ if text == "" {
+ return nil
+ }
+ out := scanText("pull_request_metadata", "metadata", text, false)
+ out = append(out, semanticCandidate("pull_request_metadata", "metadata", text, 1)...)
+ return out
+}
diff --git a/internal/qualitygate/publiccontent/metadata_test.go b/internal/qualitygate/publiccontent/metadata_test.go
new file mode 100644
index 00000000..a9e6616c
--- /dev/null
+++ b/internal/qualitygate/publiccontent/metadata_test.go
@@ -0,0 +1,22 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package publiccontent
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestLoadMetadataReadsTitleAndBody(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "metadata.json")
+ writeFile(t, path, `{"title":"public change","body":"pass`+`word = \"example-password\""}`)
+
+ got, err := LoadMetadata(path)
+ if err != nil {
+ t.Fatalf("LoadMetadata() error = %v", err)
+ }
+ if got.Title != "public change" || got.Body == "" {
+ t.Fatalf("metadata = %#v", got)
+ }
+}
diff --git a/internal/qualitygate/publiccontent/rules.go b/internal/qualitygate/publiccontent/rules.go
new file mode 100644
index 00000000..e517c31f
--- /dev/null
+++ b/internal/qualitygate/publiccontent/rules.go
@@ -0,0 +1,441 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package publiccontent
+
+import (
+ "net/url"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "github.com/larksuite/cli/internal/qualitygate/report"
+)
+
+var (
+ credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
+ jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
+ credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
+ bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
+ semanticBearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+[^"'\s,}\]]+|["']Authorization["']\s*:\s*["']Bearer\s+[^"'\\\s,}\]]+)`)
+ changeIDTrailerRE = regexp.MustCompile(`(?i)^\s*Change-Id:\s*\S+`)
+ reviewedOnTrailerRE = regexp.MustCompile(`(?i)^\s*Reviewed-on:\s*\S+`)
+ ccmHarnessTrailerRE = regexp.MustCompile(`(?i)\bCCM-Harness:\s*\S+`)
+ privateIPv4RE = regexp.MustCompile(`\b(?:10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|172\.(?:1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3})\b`)
+ automationBranchRE = regexp.MustCompile(`(?i)(^|/)(bot|automation)[-/]`)
+)
+
+func actionForRule(rule string) report.Action {
+ switch rule {
+ case "public_content_generic_credential",
+ "public_content_private_key_block",
+ "public_content_jwt_like_token",
+ "public_content_bearer_header",
+ "public_content_credential_url",
+ "public_content_change_id_trailer",
+ "public_content_reviewed_on_trailer",
+ "public_content_provenance_marker",
+ "public_content_detector_fingerprint",
+ "public_content_harness_metadata",
+ "public_content_ccm_harness_trailer":
+ return report.ActionReject
+ case "public_content_private_ipv4",
+ "public_content_automation_branch":
+ return report.ActionWarning
+ default:
+ return report.ActionWarning
+ }
+}
+
+func isPlaceholderValue(value string) bool {
+ trimmed := strings.Trim(value, `"'`)
+ normalized := strings.ToLower(trimmed)
+ if normalized == "" ||
+ normalized == "=" ||
+ percentWrappedPlaceholder(normalized) ||
+ angleWrappedPlaceholder(normalized) ||
+ urlWithAnglePlaceholder(normalized) ||
+ isCredentialReferenceValue(trimmed) {
+ return true
+ }
+ return namedPlaceholderValue(normalized)
+}
+
+func namedPlaceholderValue(value string) bool {
+ switch value {
+ case "...", "placeholder", "redacted", "", "xxxx", "test-secret":
+ return true
+ }
+ return strings.Contains(value, "cli_example") || allXPlaceholder(value)
+}
+
+func allXPlaceholder(value string) bool {
+ if len(value) < 4 {
+ return false
+ }
+ for _, r := range value {
+ if r != 'x' {
+ return false
+ }
+ }
+ return true
+}
+
+func urlWithAnglePlaceholder(value string) bool {
+ if !strings.Contains(value, "://") ||
+ !strings.Contains(value, "<") ||
+ !strings.Contains(value, ">") {
+ return false
+ }
+ return !urlRemainderLooksCredentialLike(removeAnglePlaceholders(value))
+}
+
+func removeAnglePlaceholders(value string) string {
+ var out strings.Builder
+ for len(value) > 0 {
+ start := strings.Index(value, "<")
+ if start < 0 {
+ out.WriteString(value)
+ break
+ }
+ out.WriteString(value[:start])
+ end := strings.Index(value[start+1:], ">")
+ if end < 0 {
+ out.WriteString(value[start:])
+ break
+ }
+ value = value[start+end+2:]
+ }
+ return out.String()
+}
+
+func urlRemainderLooksCredentialLike(value string) bool {
+ normalized := strings.ToLower(value)
+ for _, marker := range []string{
+ "secret",
+ "token",
+ "password",
+ "passwd",
+ "api_key",
+ "apikey",
+ "private_key",
+ "privatekey",
+ "client_secret",
+ "clientsecret",
+ } {
+ if strings.Contains(normalized, marker) {
+ return true
+ }
+ }
+ for _, part := range strings.FieldsFunc(normalized, func(r rune) bool {
+ return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
+ }) {
+ if credentialShapedIdentifier(part) || longCredentialSegment(part) {
+ return true
+ }
+ }
+ return false
+}
+
+func longCredentialSegment(value string) bool {
+ if len(value) < 16 {
+ return false
+ }
+ var hasLetter, hasDigit bool
+ for _, r := range value {
+ switch {
+ case r >= 'a' && r <= 'z':
+ hasLetter = true
+ case r >= '0' && r <= '9':
+ hasDigit = true
+ case r == '_' || r == '-':
+ default:
+ return false
+ }
+ }
+ return hasLetter || hasDigit
+}
+
+func isCredentialReferenceValue(value string) bool {
+ normalized := strings.ToLower(value)
+ switch {
+ case strings.HasPrefix(normalized, "${{"):
+ return githubExpressionReference(normalized)
+ case strings.HasPrefix(normalized, "$("):
+ return !commandSubstitutionLooksCredentialLike(normalized)
+ case strings.HasPrefix(normalized, "process.env."):
+ return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "process.env."))
+ case strings.HasPrefix(normalized, "${"):
+ return credentialReferenceIdentifier(strings.TrimSuffix(strings.TrimPrefix(normalized, "${"), "}"))
+ case strings.HasPrefix(value, "$"):
+ return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "$"))
+ default:
+ return false
+ }
+}
+
+func commandSubstitutionLooksCredentialLike(value string) bool {
+ if !strings.HasPrefix(value, "$(") || !strings.HasSuffix(value, ")") {
+ return false
+ }
+ inner := strings.TrimSuffix(strings.TrimPrefix(value, "$("), ")")
+ for _, part := range strings.FieldsFunc(inner, func(r rune) bool {
+ return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
+ }) {
+ if credentialShapedIdentifier(part) || longCredentialSegment(part) {
+ return true
+ }
+ }
+ return false
+}
+
+func githubExpressionReference(value string) bool {
+ if !strings.HasPrefix(value, "${{") || !strings.HasSuffix(value, "}}") {
+ return false
+ }
+ expr := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
+ switch {
+ case strings.HasPrefix(expr, "secrets."):
+ return dottedReferenceIdentifier(strings.TrimPrefix(expr, "secrets."))
+ case strings.HasPrefix(expr, "env."):
+ return dottedReferenceIdentifier(strings.TrimPrefix(expr, "env."))
+ case strings.HasPrefix(expr, "vars."):
+ return dottedReferenceIdentifier(strings.TrimPrefix(expr, "vars."))
+ case expr == "github.token":
+ return true
+ default:
+ return false
+ }
+}
+
+func dottedReferenceIdentifier(value string) bool {
+ if value == "" {
+ return false
+ }
+ for _, part := range strings.Split(value, ".") {
+ if !referenceIdentifier(part) {
+ return false
+ }
+ }
+ return true
+}
+
+func credentialReferenceIdentifier(value string) bool {
+ return referenceIdentifier(value) && !credentialShapedIdentifier(value)
+}
+
+func referenceIdentifier(value string) bool {
+ if value == "" {
+ return false
+ }
+ for i, r := range value {
+ switch {
+ case r >= 'a' && r <= 'z':
+ case r >= '0' && r <= '9' && i > 0:
+ case r == '_' && i > 0:
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+func angleWrappedPlaceholder(value string) bool {
+ if len(value) < 3 || !strings.HasPrefix(value, "<") || !strings.HasSuffix(value, ">") {
+ return false
+ }
+ return anglePlaceholderIdentifier(strings.Trim(value, "<>"))
+}
+
+func percentWrappedPlaceholder(value string) bool {
+ if len(value) < 3 || !strings.HasPrefix(value, "%") || !strings.HasSuffix(value, "%") {
+ return false
+ }
+ inner := strings.Trim(value, "%")
+ return delimitedPlaceholderIdentifier(inner) && !credentialShapedIdentifier(inner)
+}
+
+func delimitedPlaceholderIdentifier(value string) bool {
+ if value == "" {
+ return false
+ }
+ for _, r := range value {
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
+ continue
+ }
+ return false
+ }
+ return true
+}
+
+func anglePlaceholderIdentifier(value string) bool {
+ if value == "" {
+ return false
+ }
+ for _, r := range value {
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
+ continue
+ }
+ return false
+ }
+ if credentialShapedIdentifier(value) {
+ return false
+ }
+ switch value {
+ case "token",
+ "id",
+ "userid",
+ "openid",
+ "key",
+ "secret",
+ "password",
+ "api-key",
+ "user-id",
+ "open-id",
+ "client-secret",
+ "access-token",
+ "refresh-token",
+ "auth-token",
+ "bearer-token",
+ "session-token",
+ "service-token":
+ return true
+ }
+ for _, suffix := range []string{"_token", "_id", "_key", "_secret", "_password"} {
+ if strings.HasSuffix(value, suffix) {
+ return true
+ }
+ }
+ for _, suffix := range []string{"-token", "-id", "-key", "-secret", "-password"} {
+ if strings.HasSuffix(value, suffix) {
+ return true
+ }
+ }
+ return false
+}
+
+func credentialShapedValue(value string) bool {
+ normalized := strings.ToLower(strings.Trim(value, `"'<>`))
+ return credentialShapedIdentifier(normalized)
+}
+
+func credentialShapedIdentifier(value string) bool {
+ switch {
+ case strings.HasPrefix(value, "sk_live_"),
+ strings.HasPrefix(value, "sk_test_"),
+ strings.HasPrefix(value, "ghp_"),
+ strings.HasPrefix(value, "gho_"),
+ strings.HasPrefix(value, "ghu_"),
+ strings.HasPrefix(value, "github_pat_"),
+ strings.HasPrefix(value, "xoxb_"),
+ strings.HasPrefix(value, "xoxp_"),
+ strings.HasPrefix(value, "xoxa_"):
+ return true
+ case strings.HasPrefix(value, "real-") &&
+ (strings.Contains(value, "secret") ||
+ strings.Contains(value, "token") ||
+ strings.Contains(value, "key") ||
+ strings.Contains(value, "password")):
+ return true
+ default:
+ return false
+ }
+}
+
+func resourceTokenPlaceholderValue(value string) bool {
+ normalized := strings.ToLower(strings.Trim(value, `"'`))
+ switch normalized {
+ case "wiki_token",
+ "folder_token",
+ "obj_token",
+ "spreadsheet_token",
+ "file_token",
+ "doc_token",
+ "node_token",
+ "parent_node_token",
+ "origin_node_token",
+ "drive_route_token":
+ return true
+ default:
+ return minuteTokenFixturePlaceholder(normalized)
+ }
+}
+
+func minuteTokenFixturePlaceholder(value string) bool {
+ if value == "minute_no_meta" {
+ return true
+ }
+ suffix, ok := strings.CutPrefix(value, "minute_")
+ if !ok || suffix == "" {
+ return false
+ }
+ for _, r := range suffix {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
+
+func provenanceMarker(line string) bool {
+ normalized := strings.ToLower(line)
+ markers := []string{
+ "generat" + "ed by tool",
+ "creat" + "ed by tool",
+ "generat" + "ed by automation",
+ "creat" + "ed by automation",
+ "machine-" + "generated",
+ "generated with automated",
+ "generated with automation",
+ "🤖 generated",
+ }
+ for _, marker := range markers {
+ if strings.Contains(normalized, marker) {
+ return true
+ }
+ }
+ if strings.HasPrefix(normalized, "co-authored-by:") &&
+ (strings.Contains(normalized, ""
+ }
+ u.User = url.UserPassword("", "")
+ return u.String()
+}
diff --git a/internal/qualitygate/publiccontent/scan.go b/internal/qualitygate/publiccontent/scan.go
new file mode 100644
index 00000000..9e9f72a5
--- /dev/null
+++ b/internal/qualitygate/publiccontent/scan.go
@@ -0,0 +1,797 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package publiccontent
+
+import (
+ "fmt"
+ "path/filepath"
+ "sort"
+ "strings"
+ "unicode"
+)
+
+const (
+ privateKeyBeginPrefix = "-----" + "BEGIN "
+ privateKeyEndPrefix = "-----" + "END "
+ privateKeyMarker = "PRIVATE " + "KEY-----"
+)
+
+func ScanFile(path string, data []byte) []Finding {
+ return scanText(filepath.ToSlash(path), "file", string(data), isDetectorRuleFile(path))
+}
+
+func semanticCandidate(file, source, text string, line int) []Finding {
+ excerpt := redactedSemanticExcerpt(text)
+ if excerpt == "" {
+ return nil
+ }
+ return []Finding{newFinding("public_content_semantic_candidate", file, line, source, excerpt)}
+}
+
+func scanText(file, source, text string, detectorFile bool) []Finding {
+ var out []Finding
+ lines := strings.Split(text, "\n")
+ inPrivateKey := false
+ privateKeyLine := 0
+ for i, line := range lines {
+ lineNo := i + 1
+ if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
+ inPrivateKey = true
+ privateKeyLine = lineNo
+ }
+ if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
+ out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
+ inPrivateKey = false
+ }
+ for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
+ if !isCredentialAssignmentMatch(match[0]) {
+ continue
+ }
+ value := credentialAssignmentValue(match)
+ keyName, _ := normalizedCredentialAssignmentKey(match[0])
+ if value == "" ||
+ isNonSecretLiteralValue(value) ||
+ isBenignCodeCredentialExpression(file, value) ||
+ isPlaceholderValue(value) ||
+ isResourceTokenPlaceholderAssignment(keyName, value) {
+ continue
+ }
+ if looksLikeEqualityComparison(value) {
+ continue
+ }
+ out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
+ }
+ for _, match := range jwtLikeRE.FindAllString(line, -1) {
+ if isSchemaDottedIdentifier(line, match) {
+ continue
+ }
+ out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
+ }
+ for range bearerHeaderRE.FindAllString(line, -1) {
+ out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer "))
+ }
+ for _, match := range credentialURLRE.FindAllString(line, -1) {
+ if isPlaceholderCredentialURL(match) {
+ continue
+ }
+ out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
+ }
+ for _, match := range privateIPv4RE.FindAllString(line, -1) {
+ out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
+ }
+ if source == "branch" && automationBranchRE.MatchString(line) {
+ out = append(out, newFinding("public_content_automation_branch", file, lineNo, source, "automation branch marker"))
+ }
+ switch {
+ case changeIDTrailerRE.MatchString(line):
+ out = append(out, newFinding("public_content_change_id_trailer", file, lineNo, source, "Change-Id: "))
+ case reviewedOnTrailerRE.MatchString(line):
+ out = append(out, newFinding("public_content_reviewed_on_trailer", file, lineNo, source, "Reviewed-on: "))
+ case ccmHarnessTrailerRE.MatchString(line):
+ out = append(out, newFinding("public_content_ccm_harness_trailer", file, lineNo, source, "CCM-Harness: "))
+ }
+ if provenanceMarker(line) {
+ out = append(out, newFinding("public_content_provenance_marker", file, lineNo, source, "provenance marker"))
+ }
+ if strings.Contains(line, "/tmp/harness-agent") {
+ out = append(out, newFinding("public_content_harness_metadata", file, lineNo, source, "/tmp/harness-agent"))
+ }
+ if detectorFile && detectorFingerprint(line) {
+ out = append(out, newFinding("public_content_detector_fingerprint", file, lineNo, source, "public detector fingerprint"))
+ }
+ }
+ sort.SliceStable(out, func(i, j int) bool {
+ if out[i].File != out[j].File {
+ return out[i].File < out[j].File
+ }
+ if out[i].Line != out[j].Line {
+ return out[i].Line < out[j].Line
+ }
+ return out[i].Rule < out[j].Rule
+ })
+ return out
+}
+
+func isCredentialAssignmentMatch(match string) bool {
+ name, value, ok := normalizedCredentialAssignment(match)
+ if !ok {
+ return false
+ }
+ if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
+ return true
+ }
+ if isBenignTokenField(name) && !credentialShapedValue(value) {
+ return false
+ }
+ return isExplicitCredentialKey(name)
+}
+
+func normalizedCredentialAssignmentKey(match string) (string, bool) {
+ key, _, ok := normalizedCredentialAssignment(match)
+ return key, ok
+}
+
+func normalizedCredentialAssignment(match string) (string, string, bool) {
+ key, ok := credentialAssignmentKey(match)
+ if !ok {
+ return "", "", false
+ }
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return "", "", false
+ }
+ submatches := credentialAssignmentRE.FindStringSubmatch(match)
+ return normalizedCredentialKey(strings.Trim(key, `"'`)), credentialAssignmentValue(submatches), true
+}
+
+func normalizedCredentialKey(key string) string {
+ key = strings.TrimSpace(key)
+ var out []rune
+ var prev rune
+ for i, r := range key {
+ if r == '-' {
+ r = '_'
+ }
+ if i > 0 && isCredentialKeyBoundary(prev, r) {
+ out = append(out, '_')
+ }
+ out = append(out, unicode.ToLower(r))
+ prev = r
+ }
+ key = string(out)
+ key = strings.ReplaceAll(key, "-", "_")
+ return key
+}
+
+func isCredentialKeyBoundary(prev, current rune) bool {
+ if prev == '_' || current == '_' {
+ return false
+ }
+ return (unicode.IsLower(prev) || unicode.IsDigit(prev)) && unicode.IsUpper(current)
+}
+
+func isBenignTokenField(key string) bool {
+ if isTokenMetricField(key) ||
+ isTokenMetadataField(key) ||
+ isResourceTokenField(key) ||
+ isPaginationOrSyncTokenField(key) {
+ return true
+ }
+ return false
+}
+
+func isTokenMetricField(key string) bool {
+ switch key {
+ case "tokenizer",
+ "token_count",
+ "tokens",
+ "max_tokens",
+ "completion_tokens",
+ "prompt_tokens":
+ return true
+ default:
+ return false
+ }
+}
+
+func isTokenMetadataField(key string) bool {
+ switch key {
+ case "access_token_expires_in",
+ "refresh_token_expires_in",
+ "token_expires_in",
+ "token_status",
+ "token_type",
+ "token_url",
+ "token_endpoint",
+ "token_format",
+ "secret_name":
+ return true
+ default:
+ return false
+ }
+}
+
+func isPaginationOrSyncTokenField(key string) bool {
+ switch key {
+ case "page_token",
+ "next_page_token",
+ "sync_token":
+ return true
+ default:
+ return false
+ }
+}
+
+func isResourceTokenField(key string) bool {
+ if !strings.HasSuffix(key, "_token") {
+ return false
+ }
+ prefix := strings.TrimSuffix(key, "_token")
+ switch prefix {
+ case "app",
+ "base",
+ "board",
+ "doc",
+ "drive_route",
+ "file",
+ "folder",
+ "host_node",
+ "minute",
+ "node",
+ "obj",
+ "origin_node",
+ "parent",
+ "parent_file",
+ "parent_node",
+ "share",
+ "spreadsheet",
+ "target",
+ "wiki":
+ return true
+ default:
+ return false
+ }
+}
+
+func isResourceTokenPlaceholderAssignment(key, value string) bool {
+ switch {
+ case key == "client_token" && idempotencyTokenPlaceholderValue(value):
+ return true
+ case key == "retry_without_token" && numericStringPlaceholderValue(value):
+ return true
+ case tokenLikePlaceholderKey(key):
+ return tokenLikePlaceholderValue(value)
+ default:
+ return false
+ }
+}
+
+func tokenLikePlaceholderKey(key string) bool {
+ return key == "token" ||
+ strings.HasSuffix(key, "_token") ||
+ strings.HasSuffix(key, "-token")
+}
+
+func tokenLikePlaceholderValue(value string) bool {
+ normalized := strings.ToLower(strings.Trim(value, `"'`))
+ if normalized == "" || credentialShapedIdentifier(normalized) {
+ return false
+ }
+ return resourceTokenPlaceholderValue(value) ||
+ isPlaceholderValue(value) ||
+ normalized == "token" ||
+ strings.Contains(normalized, "...") ||
+ strings.Contains(normalized, "xxx") ||
+ strings.Contains(normalized, "_or_") ||
+ strings.HasSuffix(normalized, "_token") ||
+ strings.HasPrefix(normalized, ".")
+}
+
+func idempotencyTokenPlaceholderValue(value string) bool {
+ return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
+}
+
+func uuidStringPlaceholderValue(value string) bool {
+ normalized := strings.Trim(value, `"'`)
+ parts := strings.Split(normalized, "-")
+ if len(parts) != 5 {
+ return false
+ }
+ for i, part := range parts {
+ want := []int{8, 4, 4, 4, 12}[i]
+ if len(part) != want {
+ return false
+ }
+ for _, r := range part {
+ if (r >= '0' && r <= '9') ||
+ (r >= 'a' && r <= 'f') ||
+ (r >= 'A' && r <= 'F') {
+ continue
+ }
+ return false
+ }
+ }
+ return true
+}
+
+func numericStringPlaceholderValue(value string) bool {
+ normalized := strings.Trim(value, `"'`)
+ if normalized == "" {
+ return false
+ }
+ for _, r := range normalized {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
+
+func isBenignCodeCredentialExpression(file, value string) bool {
+ normalized := strings.TrimSpace(value)
+ if strings.HasPrefix(normalized, "regexp.MustCompile(") {
+ return true
+ }
+ if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
+ return false
+ }
+ return codeReferenceExpression(normalized)
+}
+
+func sourceCodeFile(file string) bool {
+ switch filepath.Ext(file) {
+ case ".go", ".py":
+ return true
+ default:
+ return false
+ }
+}
+
+func quotedLiteral(value string) bool {
+ normalized := strings.TrimSpace(value)
+ return len(normalized) >= 2 &&
+ ((strings.HasPrefix(normalized, `"`) && strings.HasSuffix(normalized, `"`)) ||
+ (strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
+}
+
+func codeReferenceExpression(value string) bool {
+ if value == "" {
+ return false
+ }
+ for _, marker := range []string{".", "(", ")", "[", "]", "{"} {
+ if strings.Contains(value, marker) {
+ return true
+ }
+ }
+ return codeIdentifier(value) && !credentialNameFragment(value)
+}
+
+func codeIdentifier(value string) bool {
+ for i, r := range value {
+ switch {
+ case r >= 'a' && r <= 'z':
+ case r >= 'A' && r <= 'Z':
+ case r == '_' && i > 0:
+ case r >= '0' && r <= '9' && i > 0:
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+func credentialNameFragment(value string) bool {
+ normalized := strings.ToLower(value)
+ for _, marker := range []string{"secret", "token", "password", "passwd", "key"} {
+ if strings.Contains(normalized, marker) {
+ return true
+ }
+ }
+ return false
+}
+
+func isSchemaDottedIdentifier(line, match string) bool {
+ return strings.Contains(line, "schema ") && strings.Contains(match, "_")
+}
+
+func isNonSecretLiteralValue(value string) bool {
+ switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
+ case "true", "false", "null", "nil", "{", "[":
+ return true
+ default:
+ return false
+ }
+}
+
+func isWebhookCredentialKey(key string) bool {
+ return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook")
+}
+
+func webhookAssignmentValueLooksCredentialLike(value string) bool {
+ normalized := strings.ToLower(strings.Trim(value, `"'`))
+ if normalized == "" || isPlaceholderValue(normalized) || isNonSecretLiteralValue(normalized) {
+ return false
+ }
+ return urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)) ||
+ credentialShapedIdentifier(strings.Trim(normalized, "$"))
+}
+
+func isExplicitCredentialKey(key string) bool {
+ compact := strings.ReplaceAll(key, "_", "")
+ switch compact {
+ case "token",
+ "accesstoken",
+ "refreshtoken",
+ "authtoken",
+ "bearertoken",
+ "sessiontoken",
+ "servicetoken",
+ "apikey",
+ "accesskey",
+ "privatekey",
+ "apisecret",
+ "secret",
+ "secretkey",
+ "clientsecret",
+ "password",
+ "passwd":
+ return true
+ }
+ for _, phrase := range []string{
+ "accesstoken",
+ "refreshtoken",
+ "authtoken",
+ "bearertoken",
+ "sessiontoken",
+ "servicetoken",
+ "bottoken",
+ "apikey",
+ "accesskey",
+ "privatekey",
+ "apisecret",
+ "clientsecret",
+ "secretkey",
+ } {
+ if strings.Contains(compact, phrase) {
+ return true
+ }
+ }
+ parts := credentialKeyParts(key)
+ for _, phrase := range [][2]string{
+ {"access", "token"},
+ {"refresh", "token"},
+ {"auth", "token"},
+ {"bearer", "token"},
+ {"session", "token"},
+ {"service", "token"},
+ {"bot", "token"},
+ {"api", "key"},
+ {"access", "key"},
+ {"private", "key"},
+ {"api", "secret"},
+ {"client", "secret"},
+ {"secret", "key"},
+ } {
+ if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
+ return true
+ }
+ }
+ for _, part := range parts {
+ switch part {
+ case "token", "secret", "password", "passwd":
+ return true
+ }
+ }
+ for _, suffix := range []string{
+ "token",
+ "accesstoken",
+ "refreshtoken",
+ "authtoken",
+ "bearertoken",
+ "sessiontoken",
+ "servicetoken",
+ "bottoken",
+ "apikey",
+ "accesskey",
+ "privatekey",
+ "apisecret",
+ "clientsecret",
+ "secret",
+ "secretkey",
+ "password",
+ "passwd",
+ } {
+ if strings.HasSuffix(compact, suffix) {
+ return true
+ }
+ }
+ for _, suffix := range []string{
+ "_access_token",
+ "_refresh_token",
+ "_auth_token",
+ "_bearer_token",
+ "_session_token",
+ "_service_token",
+ "_api_key",
+ "_access_key",
+ "_private_key",
+ "_api_secret",
+ "_client_secret",
+ "_secret",
+ "_secret_key",
+ "_password",
+ "_passwd",
+ } {
+ if strings.HasSuffix(key, suffix) {
+ return true
+ }
+ }
+ return false
+}
+
+func credentialKeyParts(key string) []string {
+ var parts []string
+ for _, part := range strings.Split(key, "_") {
+ if part != "" {
+ parts = append(parts, part)
+ }
+ }
+ return parts
+}
+
+func hasAdjacentCredentialParts(parts []string, first, second string) bool {
+ for i := 0; i+1 < len(parts); i++ {
+ if parts[i] == first && parts[i+1] == second {
+ return true
+ }
+ }
+ return false
+}
+
+func credentialAssignmentValue(match []string) string {
+ for _, value := range match[1:] {
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func looksLikeEqualityComparison(value string) bool {
+ return strings.HasPrefix(strings.TrimSpace(value), "=")
+}
+
+func isPlaceholderCredentialURL(raw string) bool {
+ userInfo, ok := credentialURLUserInfo(raw)
+ if !ok {
+ return false
+ }
+ _, password, ok := strings.Cut(userInfo, ":")
+ if !ok {
+ return false
+ }
+ return credentialURLPasswordPlaceholder(password)
+}
+
+func credentialURLPasswordPlaceholder(password string) bool {
+ normalized := strings.ToLower(password)
+ decoded := strings.ReplaceAll(normalized, "%3c", "<")
+ decoded = strings.ReplaceAll(decoded, "%3e", ">")
+ switch decoded {
+ case "placeholder", "redacted", "", "xxxx":
+ return true
+ }
+ return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
+}
+
+func credentialURLUserInfo(raw string) (string, bool) {
+ schemeIdx := strings.Index(raw, "://")
+ if schemeIdx < 0 {
+ return "", false
+ }
+ rest := raw[schemeIdx+len("://"):]
+ atIdx := strings.Index(rest, "@")
+ if atIdx < 0 {
+ return "", false
+ }
+ return rest[:atIdx], true
+}
+
+func newFinding(rule, file string, line int, source, excerpt string) Finding {
+ return Finding{
+ Rule: rule,
+ Action: actionForRule(rule),
+ File: file,
+ Line: line,
+ Source: source,
+ Excerpt: excerpt,
+ Message: messageForRule(rule),
+ Suggestion: suggestionForRule(rule),
+ }
+}
+
+func messageForRule(rule string) string {
+ switch rule {
+ case "public_content_generic_credential":
+ return "public contribution contains a generic credential assignment"
+ case "public_content_private_key_block":
+ return "public contribution contains a private key block"
+ case "public_content_jwt_like_token":
+ return "public contribution contains a JWT-like token"
+ case "public_content_bearer_header":
+ return "public contribution contains an Authorization bearer token"
+ case "public_content_credential_url":
+ return "public contribution contains credentials embedded in a URL"
+ case "public_content_private_ipv4":
+ return "public contribution contains a private-network IP address"
+ case "public_content_automation_branch":
+ return "public contribution uses an automation-shaped branch name"
+ case "public_content_change_id_trailer":
+ return "public contribution contains a Change-Id trailer"
+ case "public_content_reviewed_on_trailer":
+ return "public contribution contains a Reviewed-on trailer"
+ case "public_content_provenance_marker":
+ return "public contribution contains a prohibited provenance marker"
+ case "public_content_detector_fingerprint":
+ return "public rule/config content exposes public detector fingerprints"
+ case "public_content_harness_metadata":
+ return "public contribution contains visible harness pipeline metadata"
+ case "public_content_ccm_harness_trailer":
+ return "public contribution contains a CCM-Harness trailer"
+ case "public_content_semantic_candidate":
+ return "public contribution contains text for semantic public content review"
+ default:
+ return "public contribution contains content that should not be published"
+ }
+}
+
+func suggestionForRule(rule string) string {
+ switch actionForRule(rule) {
+ case "REJECT":
+ return "remove the value from the public contribution and replace it with a non-sensitive placeholder"
+ default:
+ return "remove private workflow metadata before publishing the public contribution"
+ }
+}
+
+func redactAssignment(match string) string {
+ key, ok := credentialAssignmentKey(match)
+ if !ok {
+ return ""
+ }
+ return fmt.Sprintf("%s= ", strings.TrimSpace(key))
+}
+
+func credentialAssignmentKey(match string) (string, bool) {
+ idx := -1
+ for _, sep := range []string{":", "="} {
+ if candidate := strings.Index(match, sep); candidate >= 0 && (idx < 0 || candidate < idx) {
+ idx = candidate
+ }
+ }
+ if idx < 0 {
+ return "", false
+ }
+ return match[:idx], true
+}
+
+func redactToken(_ string) string {
+ return ""
+}
+
+func redactedSemanticExcerpt(text string) string {
+ normalized := strings.Join(strings.Fields(text), " ")
+ if normalized == "" {
+ return ""
+ }
+ signals := semanticSignals(normalized)
+ if len(signals) == 0 {
+ return ""
+ }
+ sanitized := truncateRunes(sanitizeSemanticExcerpt(text), 600)
+ return fmt.Sprintf("semantic signals: %s; excerpt: %q", strings.Join(signals, ","), sanitized)
+}
+
+func semanticSignals(normalized string) []string {
+ lower := strings.ToLower(normalized)
+ var signals []string
+ add := func(signal string) {
+ for _, existing := range signals {
+ if existing == signal {
+ return
+ }
+ }
+ signals = append(signals, signal)
+ }
+
+ hasPrivateScope := strings.Contains(lower, "private") || strings.Contains(lower, "internal-only")
+ hasRequestMetadata := strings.Contains(lower, "request header") || strings.Contains(lower, "request headers") || strings.Contains(lower, "authorization header") || strings.Contains(lower, "metadata header")
+ hasTrustBoundary := strings.Contains(lower, "spoof") || strings.Contains(lower, "trust") || strings.Contains(lower, "risk scoring") || strings.Contains(lower, "classification")
+ hasRoadmap := strings.Contains(lower, "roadmap") || strings.Contains(lower, "migration") || strings.Contains(lower, "rollout") || strings.Contains(lower, "cutover") || strings.Contains(lower, "unpublished")
+ hasTiming := strings.Contains(lower, "target date") || strings.Contains(lower, "friday") || strings.Contains(lower, "monday") || strings.Contains(lower, "tuesday") || strings.Contains(lower, "wednesday") || strings.Contains(lower, "thursday") || strings.Contains(lower, "customer-visible")
+ hasImplementation := strings.Contains(lower, "server-side") || strings.Contains(lower, "implementation")
+
+ if hasPrivateScope && hasRequestMetadata && hasTrustBoundary {
+ add("private_scope")
+ add("request_metadata")
+ add("trust_boundary_detail")
+ }
+ if hasRoadmap && (hasPrivateScope || hasTiming) {
+ add("roadmap_detail")
+ if hasPrivateScope {
+ add("private_scope")
+ }
+ if hasTiming {
+ add("roadmap_timing")
+ }
+ }
+ if hasPrivateScope && hasImplementation && hasTrustBoundary {
+ add("private_scope")
+ add("implementation_detail")
+ add("trust_boundary_detail")
+ }
+
+ return signals
+}
+
+func sanitizeSemanticExcerpt(text string) string {
+ text = redactPrivateKeyBlocks(text)
+ text = credentialAssignmentRE.ReplaceAllStringFunc(text, sanitizeCredentialAssignment)
+ text = strings.ReplaceAll(text, `"`, ``)
+ text = strings.ReplaceAll(text, `'`, ``)
+ text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer ")
+ text = jwtLikeRE.ReplaceAllString(text, "")
+ text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL)
+ return strings.Join(strings.Fields(text), " ")
+}
+
+func redactPrivateKeyBlocks(text string) string {
+ lines := strings.Split(text, "\n")
+ var out []string
+ inPrivateKey := false
+ for _, line := range lines {
+ if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
+ out = append(out, "")
+ inPrivateKey = true
+ if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
+ inPrivateKey = false
+ }
+ continue
+ }
+ if inPrivateKey {
+ if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
+ inPrivateKey = false
+ }
+ continue
+ }
+ out = append(out, line)
+ }
+ return strings.Join(out, "\n")
+}
+
+func sanitizeCredentialAssignment(match string) string {
+ key, ok := credentialAssignmentKey(match)
+ if !ok {
+ return ""
+ }
+ return strings.TrimSpace(key) + "="
+}
+
+func sanitizeCredentialURL(raw string) string {
+ redacted := redactCredentialURL(raw)
+ redacted = strings.ReplaceAll(redacted, "%3Cuser%3E", "")
+ redacted = strings.ReplaceAll(redacted, "%3Credacted%3E", "")
+ return redacted
+}
+
+func truncateRunes(text string, limit int) string {
+ if limit <= 0 {
+ return ""
+ }
+ runes := []rune(text)
+ if len(runes) <= limit {
+ return text
+ }
+ return string(runes[:limit]) + "..."
+}
diff --git a/internal/qualitygate/publiccontent/scan_test.go b/internal/qualitygate/publiccontent/scan_test.go
new file mode 100644
index 00000000..8faed8d2
--- /dev/null
+++ b/internal/qualitygate/publiccontent/scan_test.go
@@ -0,0 +1,1056 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package publiccontent
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestScanFileDetectsPublicLeakSignalsInPRDocs(t *testing.T) {
+ text := `# Pull Request
+
+The public README accidentally contains realistic leak-shaped content:
+
+` + "```bash\n" + `export SERVICE_` + `PASSWORD="example-password"
+curl https://user:pass@` + `example.com/repo.git
+` + "```\n" + `
+ ` + privateKeyBeginPrefix + privateKeyMarker + `
+ example-key-body
+ ` + privateKeyEndPrefix + privateKeyMarker + `
+
+session_token: "` + jwtFixture("ZXhhbXBsZQ") + `"
+
+Change` + `-Id: I0123456789abcdef0123456789abcdef01234567
+Reviewed` + `-on: https://review.example.test/c/project/+/123
+` + "Generated by " + "auto" + "mation" + `
+/tmp/harness` + `-agent/work
+CCM` + `-Harness: stage-17
+`
+
+ got := ScanFile("docs/public-pr.md", []byte(text))
+ rules := findingRules(got)
+ for _, want := range []string{
+ "public_content_generic_credential",
+ "public_content_private_key_block",
+ "public_content_jwt_like_token",
+ "public_content_credential_url",
+ "public_content_change_id_trailer",
+ "public_content_reviewed_on_trailer",
+ "public_content_provenance_marker",
+ "public_content_harness_metadata",
+ "public_content_ccm_harness_trailer",
+ } {
+ if !rules[want] {
+ t.Fatalf("missing rule %s in findings %#v", want, got)
+ }
+ }
+}
+
+func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) {
+ got := ScanFile("docs/network.md", []byte("Local lab address: 192.168."+"0.10\n"))
+ rules := findingRules(got)
+ if !rules["public_content_private_ipv4"] {
+ t.Fatalf("missing private IPv4 warning, got %#v", got)
+ }
+ for _, item := range got {
+ if item.Rule == "public_content_private_ipv4" && string(item.Action) != "WARNING" {
+ t.Fatalf("private IPv4 action = %s, want WARNING", item.Action)
+ }
+ }
+}
+
+func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) {
+ benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
+ if len(benign) != 0 {
+ t.Fatalf("benign RFC1918 documentation should not produce semantic candidates: %#v", benign)
+ }
+
+ risky := semanticCandidate("docs/roadmap.md", "file", "private launch plan for internal migration rollout on Friday", 1)
+ if len(risky) != 1 {
+ t.Fatalf("risky semantic text should produce one semantic candidate, got %#v", risky)
+ }
+ if !strings.Contains(risky[0].Excerpt, "private_scope") || !strings.Contains(risky[0].Excerpt, "roadmap_detail") {
+ t.Fatalf("semantic candidate should retain redacted risk signals, got %#v", risky[0])
+ }
+ if !strings.Contains(risky[0].Excerpt, "private launch plan") {
+ t.Fatalf("semantic candidate should include sanitized review text, got %#v", risky[0])
+ }
+}
+
+func TestSemanticCandidateIgnoresBroadBenignSignals(t *testing.T) {
+ cases := []string{
+ "internal package refactor",
+ "internal request handling docs",
+ "request header behavior",
+ "implementation detail cleanup",
+ }
+ for _, tc := range cases {
+ if got := semanticCandidate("docs/public.md", "file", tc, 1); len(got) != 0 {
+ t.Fatalf("semanticCandidate(%q) = %#v, want none", tc, got)
+ }
+ }
+}
+
+func TestSemanticCandidateKeepsHighRiskCombinations(t *testing.T) {
+ cases := []string{
+ "private request header controls trust classification and spoof-prevention behavior",
+ "unpublished migration rollout has target date next Tuesday",
+ "private roadmap cutover exposes customer-visible timing",
+ }
+ for _, tc := range cases {
+ if got := semanticCandidate("docs/public.md", "file", tc, 1); len(got) != 1 {
+ t.Fatalf("semanticCandidate(%q) len = %d, want 1: %#v", tc, len(got), got)
+ }
+ }
+}
+
+func TestSemanticCandidateSanitizesReviewText(t *testing.T) {
+ text := `private rollout uses internal request headers.
+SERVICE_PASSWORD="real-password-value"
+Authorization: Bearer abcdefghijklmnopqrstuvwxyz
+Authorization: Bearer abcdefghijkl+/Zm9vQmFy==
+callback=https://user:secretpass@example.com/hook
+token: ` + jwtFixture("c2VjcmV0") + `
+standalone ` + jwtFixture("c3RhbmRhbG9uZQ") + `
+` + privateKeyBeginPrefix + privateKeyMarker + `
+secret-key-body
+` + privateKeyEndPrefix + privateKeyMarker + `
+`
+
+ got := semanticCandidate("docs/public.md", "file", text, 1)
+ if len(got) != 1 {
+ t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got)
+ }
+ excerpt := got[0].Excerpt
+ for _, forbidden := range []string{
+ "real-password-value",
+ "abcdefghijklmnopqrstuvwxyz",
+ "Zm9vQmFy",
+ "user:secretpass@example.com",
+ jwtHeaderFixture(),
+ "secret-key-body",
+ } {
+ if strings.Contains(excerpt, forbidden) {
+ t.Fatalf("semantic candidate leaked %q in excerpt %q", forbidden, excerpt)
+ }
+ }
+ for _, want := range []string{
+ "SERVICE_PASSWORD=",
+ "Authorization: Bearer ",
+ "https://:@example.com/hook",
+ "",
+ "",
+ } {
+ if !strings.Contains(excerpt, want) {
+ t.Fatalf("semantic candidate missing sanitized marker %q in excerpt %q", want, excerpt)
+ }
+ }
+}
+
+func TestSemanticCandidateRedactsCredentialValuesWithWhitespace(t *testing.T) {
+ text := "private launch plan for internal rollout on Friday\n" +
+ "SERVICE_" + "TOKEN=\"real " + "secret value\"\n"
+
+ got := semanticCandidate("docs/public.md", "file", text, 1)
+ if len(got) != 1 {
+ t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got)
+ }
+ excerpt := got[0].Excerpt
+ for _, forbidden := range []string{"real " + "secret value", "secret value"} {
+ if strings.Contains(excerpt, forbidden) {
+ t.Fatalf("semantic candidate leaked credential tail %q in excerpt %q", forbidden, excerpt)
+ }
+ }
+ if !strings.Contains(excerpt, "SERVICE_TOKEN=") {
+ t.Fatalf("semantic candidate should redact full credential assignment, got %q", excerpt)
+ }
+}
+
+func TestSemanticCandidateCoversRealE2ESemanticCases(t *testing.T) {
+ cases := []struct {
+ name string
+ text string
+ signals []string
+ }{
+ {
+ name: "private header detail",
+ text: "Public docs describe a private request header, server-side trust classification, and spoof-prevention behavior in enough detail for an implementation review.",
+ signals: []string{
+ "private_scope",
+ "request_metadata",
+ "trust_boundary_detail",
+ "implementation_detail",
+ },
+ },
+ {
+ name: "specific roadmap",
+ text: "Public release notes mention a specific unpublished migration phase, target date, and rollout direction for an internal-only plan.",
+ signals: []string{
+ "private_scope",
+ "roadmap_detail",
+ "roadmap_timing",
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := semanticCandidate("docs/public.md", "file", tc.text, 1)
+ if len(got) != 1 {
+ t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got)
+ }
+ for _, signal := range tc.signals {
+ if !strings.Contains(got[0].Excerpt, signal) {
+ t.Fatalf("semantic candidate missing signal %q: %#v", signal, got[0])
+ }
+ }
+ })
+ }
+}
+
+func TestScanFileDetectsDetectorFingerprintOnlyInPublicRuleFiles(t *testing.T) {
+ got := ScanFile(".gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n"))
+ if !findingRules(got)["public_content_detector_fingerprint"] {
+ t.Fatalf("expected detector fingerprint finding, got %#v", got)
+ }
+
+ clean := ScanFile("docs/release-notes.md", []byte("public-content-leakage is discussed as ordinary release text\n"))
+ if findingRules(clean)["public_content_detector_fingerprint"] {
+ t.Fatalf("detector fingerprint should be scoped to public rule/config files: %#v", clean)
+ }
+}
+
+func TestScanFileIgnoresBenignPublicPlaceholders(t *testing.T) {
+ got := ScanFile("docs/examples.md", []byte(`Use APP_ID=cli_example_app_id and APP_SECRET=cli_example_app_secret in examples.
+The docs may mention bearer-token placeholders, but they should not contain realistic tokens.
+`))
+ if len(got) != 0 {
+ t.Fatalf("benign placeholders produced findings: %#v", got)
+ }
+}
+
+func TestScanFileDoesNotTreatURLEncodedCredentialAsPlaceholder(t *testing.T) {
+ got := ScanFile("docs/config.md", []byte("client_secret=abc%2Fdef%3Drealvalue\n"))
+ if !findingRules(got)["public_content_generic_credential"] {
+ t.Fatalf("URL-encoded credential should still be reported, got %#v", got)
+ }
+}
+
+func TestScanFileDoesNotTreatPlaceholderMarkerSubstringsAsPlaceholders(t *testing.T) {
+ got := ScanFile("docs/config.md", []byte(strings.Join([]string{
+ "API_KEY=notredactedreal",
+ "API_KEY=notplaceholdersecret",
+ "API_KEY=abcxxxxreal",
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ count++
+ }
+ }
+ if count != 3 {
+ t.Fatalf("placeholder-marker substring findings = %d, want 3: %#v", count, got)
+ }
+}
+
+func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
+ paddedSecretPrefix := "dGhpc2lz" + "YXNlY3JldA"
+ paddedTokenPrefix := "YWJj" + "ZGVmZ2g"
+ paddedSecret := base64PaddedFixture(paddedSecretPrefix)
+ paddedToken := base64PaddedFixture(paddedTokenPrefix)
+ got := ScanFile("docs/config.md", []byte(strings.Join([]string{
+ `API_SECRET="` + paddedSecret + `"`,
+ "api_secret=" + paddedToken,
+ "api_secret: " + paddedToken,
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ count++
+ for _, forbidden := range []string{paddedSecret, paddedToken, paddedSecretPrefix, paddedTokenPrefix} {
+ if strings.Contains(item.Excerpt, forbidden) {
+ t.Fatalf("credential finding leaked base64 value %q in excerpt %q", forbidden, item.Excerpt)
+ }
+ }
+ }
+ }
+ if count != 3 {
+ t.Fatalf("base64 padded credentials findings = %d, want 3: %#v", count, got)
+ }
+}
+
+func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
+ jsonToken := "real-json-token"
+ jsonSecret := "real " + "secret value"
+ jsonKey := "real-json-key"
+ jsonTenantToken := "real-tenant-json-token"
+ jsonAppSecret := "real-app-secret"
+ jsonPrefixedKey := "real-prefixed-key"
+ jsonTenantCamelToken := "real-tenant-camel-token"
+ jsonGithubToken := "real-github-token"
+ jsonVendorKey := "real-vendor-key"
+ jsonSlackBotToken := "xoxb-real-token"
+ got := ScanFile("docs/public.json", []byte(strings.Join([]string{
+ `{"access_` + `token":"` + jsonToken + `"}`,
+ `{"client_` + `secret": "` + jsonSecret + `"}`,
+ `{'api_` + `key': '` + jsonKey + `'}`,
+ `{"tenant_access_` + `token":"` + jsonTenantToken + `"}`,
+ `{"app_` + `secret":"` + jsonAppSecret + `"}`,
+ `{"x_api_` + `key":"` + jsonPrefixedKey + `"}`,
+ `{"tenantAccess` + `Token":"` + jsonTenantCamelToken + `"}`,
+ `{"github` + `Token":"` + jsonGithubToken + `"}`,
+ `{"vendorApi` + `Key":"` + jsonVendorKey + `"}`,
+ `{"slackBot` + `Token":"` + jsonSlackBotToken + `"}`,
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ count++
+ for _, forbidden := range []string{jsonToken, jsonSecret, jsonKey, jsonTenantToken, jsonAppSecret, jsonPrefixedKey, jsonTenantCamelToken, jsonGithubToken, jsonVendorKey, jsonSlackBotToken} {
+ if strings.Contains(item.Excerpt, forbidden) {
+ t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
+ }
+ }
+ }
+ }
+ if count != 10 {
+ t.Fatalf("JSON credential findings = %d, want 10: %#v", count, got)
+ }
+}
+
+func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "API_KEY_OPENAI: real-openai-key",
+ "TOKEN_GITHUB: real-github-token",
+ "CLIENT_SECRET_GOOGLE: real-google-secret",
+ "SECRET_KEY_BASE: real-secret-key-base",
+ "APP_PASSWORD_PROD: real-prod-password",
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule != "public_content_generic_credential" {
+ continue
+ }
+ count++
+ for _, forbidden := range []string{
+ "real-openai-key",
+ "real-github-token",
+ "real-google-secret",
+ "real-secret-key-base",
+ "real-prod-password",
+ } {
+ if strings.Contains(item.Excerpt, forbidden) {
+ t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
+ }
+ }
+ }
+ if count != 5 {
+ t.Fatalf("credential suffix variants findings = %d, want 5: %#v", count, got)
+ }
+}
+
+func TestScanFileDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "API_KEY_OPENAI: prod_key",
+ "CLIENT_SECRET_GOOGLE: prod_secret",
+ "TOKEN_GITHUB: github_token",
+ "APP_PASSWORD_PROD: prod_password",
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ count++
+ }
+ }
+ if count != 4 {
+ t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
+ }
+}
+
+func TestScanFileDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
+ stripeLike := "sk_" + "live_1234567890abcdef"
+ patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "API_KEY: <" + stripeLike + ">",
+ "SECRET_TOKEN: <" + patLike + ">",
+ "CLIENT_SECRET: ",
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ count++
+ }
+ }
+ if count != 3 {
+ t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
+ }
+}
+
+func TestScanFileDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
+ stripeLike := "sk_" + "live_1234567890abcdef"
+ patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
+ got := ScanFile("docs/public.json", []byte(strings.Join([]string{
+ `{"access_token_expires_in":"` + patLike + `"}`,
+ `{"refresh_token_expires_in":"` + stripeLike + `"}`,
+ `{"client_secret_status":"real-client-secret-value"}`,
+ `{"client_secret_name":"real-client-secret-value"}`,
+ `{"app_token":"` + patLike + `"}`,
+ `{"sync_token":"` + stripeLike + `"}`,
+ `{"target_token":"real-client-secret-value"}`,
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ count++
+ }
+ }
+ if count != 7 {
+ t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
+ }
+}
+
+func TestScanFileDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "API_KEY_NAME: prod_key",
+ "CLIENT_SECRET_NAME: prod_secret",
+ "SECRET_STATUS: prod_secret",
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ count++
+ }
+ }
+ if count != 3 {
+ t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
+ }
+}
+
+func TestScanFileDetectsAccessKeyCredentials(t *testing.T) {
+ accessKey := "AK" + "IAIOSFODNN7EXAMPX"
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "AWS_ACCESS_KEY_ID: " + accessKey,
+ "ACCESS_KEY_ID: " + accessKey,
+ "ACCESS_KEY: " + accessKey,
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule != "public_content_generic_credential" {
+ continue
+ }
+ count++
+ for _, forbidden := range []string{
+ accessKey,
+ } {
+ if strings.Contains(item.Excerpt, forbidden) {
+ t.Fatalf("access key finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
+ }
+ }
+ }
+ if count != 3 {
+ t.Fatalf("access key credential findings = %d, want 3: %#v", count, got)
+ }
+}
+
+func TestScanFileDetectsPrivateKeyAssignments(t *testing.T) {
+ privateKey := "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t"
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "PRIVATE_KEY: " + privateKey,
+ "SSH_PRIVATE_KEY: " + privateKey,
+ "JWT_PRIVATE_KEY: " + privateKey,
+ "SIGNING_PRIVATE_KEY: " + privateKey,
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule != "public_content_generic_credential" {
+ continue
+ }
+ count++
+ if strings.Contains(item.Excerpt, privateKey) {
+ t.Fatalf("private key finding leaked value in excerpt %q", item.Excerpt)
+ }
+ }
+ if count != 4 {
+ t.Fatalf("private key assignment findings = %d, want 4: %#v", count, got)
+ }
+}
+
+func TestScanFileDetectsWebhookURLs(t *testing.T) {
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "SLACK_WEBHOOK_URL=https://hooks." + "slack.com/services/T00000000/B00000000/abcdefghijklmnopqrstuvwx",
+ "DISCORD_WEBHOOK_URL=https://discord.com/api/" + "webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
+ "WEBHOOK_URL=https://example.invalid/hooks/secret-path-token-1234567890",
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule != "public_content_generic_credential" {
+ continue
+ }
+ count++
+ for _, forbidden := range []string{
+ "hooks." + "slack.com/services",
+ "discord.com/api/" + "webhooks",
+ "secret-path-token-1234567890",
+ } {
+ if strings.Contains(item.Excerpt, forbidden) {
+ t.Fatalf("webhook finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
+ }
+ }
+ }
+ if count != 3 {
+ t.Fatalf("webhook URL findings = %d, want 3: %#v", count, got)
+ }
+}
+
+func TestScanFileDetectsWebhookURLsWithHostPlaceholders(t *testing.T) {
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "WEBHOOK_URL=https:///hooks/real-secret-token-1234567890",
+ "SLACK_WEBHOOK_URL=https:///services/T00000000/B00000000/abcdefghijklmnopqrstuvwx",
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule != "public_content_generic_credential" {
+ continue
+ }
+ count++
+ }
+ if count != 2 {
+ t.Fatalf("host-placeholder webhook findings = %d, want 2: %#v", count, got)
+ }
+}
+
+func TestScanFileAllowsBenignWebhookFields(t *testing.T) {
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "webhook_count: 2",
+ "webhook_retries=3",
+ "webhook_endpoint=https://example.invalid/hooks/example",
+ "webhook_path=/hooks/example",
+ }, "\n")+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("benign webhook field should not be credential finding: %#v", got)
+ }
+ }
+}
+
+func TestScanFileDetectsCredentialURLWithEmptyUsername(t *testing.T) {
+ got := ScanFile("docs/config.yaml", []byte("REDIS_URL=redis://:password@example.invalid/0\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_credential_url" {
+ if strings.Contains(item.Excerpt, "password") {
+ t.Fatalf("credential URL finding leaked password in excerpt %q", item.Excerpt)
+ }
+ return
+ }
+ }
+ t.Fatalf("missing empty-username credential URL finding: %#v", got)
+}
+
+func TestScanFileAllowsPrivateKeyStateBooleans(t *testing.T) {
+ got := ScanFile("internal/qualitygate/publiccontent/collect.go", []byte(strings.Join([]string{
+ "inPrivateKey = true",
+ "inPrivateKey = false",
+ "hasPrivateKey: false",
+ }, "\n")+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("private key state boolean should not be credential finding: %#v", got)
+ }
+ }
+}
+
+func TestScanFileAllowsCredentialReferenceValues(t *testing.T) {
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "API_KEY=${API_KEY}",
+ "API_KEY=$API_KEY",
+ "API_KEY=process.env.API_KEY",
+ "API_KEY: ${{ secrets.API_KEY }}",
+ "TOKEN: ${{ env.TOKEN }}",
+ "GITHUB_TOKEN: ${{ github.token }}",
+ "TOKEN=$(vault kv get -field=token secret/path)",
+ }, "\n")+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("credential reference should not be generic credential finding: %#v", got)
+ }
+ }
+}
+
+func TestScanFileDetectsMalformedGithubExpressionCredentialValues(t *testing.T) {
+ stripeLike := "sk_" + "live_1234567890abcdef"
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "API_KEY=${{" + stripeLike + "}}",
+ "TOKEN=${{real-secret-token-value}}",
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ count++
+ }
+ }
+ if count != 2 {
+ t.Fatalf("malformed GitHub expression credential findings = %d, want 2: %#v", count, got)
+ }
+}
+
+func TestScanFileDetectsDollarPrefixedCredentialValues(t *testing.T) {
+ stripeLike := "sk_" + "live_1234567890abcdef"
+ patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "API_KEY=$" + stripeLike,
+ "GITHUB_TOKEN=$" + patLike,
+ "TOKEN=$(echo " + stripeLike + ")",
+ "API_KEY=process.env." + stripeLike,
+ "GITHUB_TOKEN=process.env." + patLike,
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ count++
+ }
+ }
+ if count != 5 {
+ t.Fatalf("reference-shaped credential findings = %d, want 5: %#v", count, got)
+ }
+}
+
+func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "DATABASE_URL=postgres://:@example.invalid/db",
+ "DATABASE_URL=postgres://user:%3Cpassword%3E@example.invalid/db",
+ "WEBHOOK_URL=https://example.invalid/hooks/",
+ }, "\n")+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_credential_url" {
+ t.Fatalf("credential URL placeholder should not be credential URL finding: %#v", got)
+ }
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("credential URL placeholder should not be generic credential finding: %#v", got)
+ }
+ }
+}
+
+func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
+ got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_credential_url" {
+ return
+ }
+ }
+ t.Fatalf("missing credential URL with redacted substring password: %#v", got)
+}
+
+func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *testing.T) {
+ stripeLike := "sk_" + "live_1234567890abcdef"
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "DATABASE_URL=postgres://:real-secret@example.invalid/db",
+ "DATABASE_URL=postgres://:" + stripeLike + "@example.invalid/db",
+ "URL=https://:real-secret@example.invalid/path",
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule != "public_content_credential_url" {
+ continue
+ }
+ count++
+ for _, forbidden := range []string{"real-secret", stripeLike} {
+ if strings.Contains(item.Excerpt, forbidden) {
+ t.Fatalf("credential URL finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
+ }
+ }
+ }
+ if count != 3 {
+ t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
+ }
+}
+
+func TestScanFileAllowsCommonAngleWrappedCredentialPlaceholders(t *testing.T) {
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "API_KEY=",
+ "CLIENT_SECRET=",
+ "ACCESS_TOKEN=",
+ "API_KEY=",
+ "SECRET_TOKEN=",
+ "CLIENT_SECRET=",
+ }, "\n")+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("common angle-wrapped placeholder should not be credential finding: %#v", got)
+ }
+ }
+}
+
+func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
+ got := ScanFile("docs/public.json", []byte(strings.Join([]string{
+ `{"tokenizer":"cl100k_base"}`,
+ `{"token_count": 42}`,
+ `{"page_token":"next"}`,
+ `{"next_page_token":"next"}`,
+ `{"file_token":"file-example"}`,
+ `{"doc_token":"doc-example"}`,
+ `{"node_token":"node-example"}`,
+ `{"wiki_token":"wikcn_public_doc_example"}`,
+ `{"folder_token":"folder-example"}`,
+ `{"obj_token":"obj-example"}`,
+ `{"spreadsheet_token":"sheet-example"}`,
+ `{"parent_node_token":"parent-example"}`,
+ `{"origin_node_token":"origin-example"}`,
+ `{"drive_route_token":"route-example"}`,
+ `{"token":""}`,
+ `{"token":"wiki_token"}`,
+ `{"token":"minute_1"}`,
+ `{"token":"minute_no_meta"}`,
+ `{"token_url":"https://example.com/oauth/token"}`,
+ `{"token_endpoint":"https://example.com/oauth/token"}`,
+ `{"token_format":"Bearer"}`,
+ `{"secret_name":"public-example-secret"}`,
+ `{"base_token":"base-example"}`,
+ `{"app_token":"app-example"}`,
+ `{"sync_token":"sync-example"}`,
+ `{"parent_token":"parent-example"}`,
+ `{"target_token":"target-example"}`,
+ `{"parent_file_token":"parent-file-example"}`,
+ `{"refresh_token_expires_in": 7200}`,
+ `{"access_token_expires_in": 7200}`,
+ `{"token_expires_in": 7200}`,
+ `{"token_status":"active"}`,
+ }, "\n")+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("benign JSON token field should not be credential finding: %#v", got)
+ }
+ }
+}
+
+func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
+ got := ScanFile("shortcuts/calendar/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("test fixture secret should not be credential finding: %#v", got)
+ }
+ }
+}
+
+func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
+ got := ScanFile("shortcuts/minutes/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("regexp token validator should not be credential finding: %#v", got)
+ }
+ }
+}
+
+func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) {
+ got := ScanFile("cmd/config/binder.go", []byte(strings.Join([]string{
+ "AppSecret: stored,",
+ "AccessToken: result.Token.AccessToken,",
+ `token := runtime.Str("token")`,
+ }, "\n")+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("source code credential expressions should not be credential findings: %#v", got)
+ }
+ }
+}
+
+func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
+ got := ScanFile("skills/lark-slides/scripts/iconpark_tool.py", []byte(strings.Join([]string{
+ "def normalize_token(value: str) -> str:",
+ " token = rest[index]",
+ " next_token = rest[index + 1] if index + 1 < len(rest) else None",
+ "def append_unique(target: list[str], token: str) -> None:",
+ ` fail(f"invalid range token: {trimmed}")`,
+ }, "\n")+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("python token variables should not be credential findings: %#v", got)
+ }
+ }
+}
+
+func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
+ got := ScanFile("skills/lark-doc/references/lark-doc-fetch.md", []byte(strings.Join([]string{
+ ` `,
+ ``,
+ }, "\n")+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("ellipsis placeholders should not be credential findings: %#v", got)
+ }
+ }
+}
+
+func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) {
+ got := ScanFile("skills/lark-mail/references/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_jwt_like_token" {
+ t.Fatalf("schema dotted identifier should not be jwt finding: %#v", got)
+ }
+ }
+}
+
+func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
+ got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{
+ `{"client_token":"1704067200"}`,
+ `{"client_token":"fe599b60-450f-46ff-b2ef-9f6675625b97"}`,
+ }, "\n")+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("client_token idempotency examples should not be credential findings: %#v", got)
+ }
+ }
+}
+
+func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
+ stripeLike := "sk_" + "live_1234567890abcdef"
+ got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{
+ `{"client_token":"` + stripeLike + `"}`,
+ `{"client_token":"real-client-secret-value"}`,
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ count++
+ }
+ }
+ if count != 2 {
+ t.Fatalf("credential-shaped client_token findings = %d, want 2: %#v", count, got)
+ }
+}
+
+func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
+ got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
+ `{ "block_token": "boardXXXX" }`,
+ `{ "resource_token": "doc_token_or_url" }`,
+ `{ "token": "canonical_token" }`,
+ `{ "target_parent_token": "wikcparent_xxx" }`,
+ `{ "mention_token": "" }`,
+ `{ "22-doc_token_xxx": { "objType": 22 } }`,
+ `{ "token": "12101..." }`,
+ `{ token: .token }`,
+ `retry_without_token = 0`,
+ }, "\n")+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("token-like placeholders should not be credential findings: %#v", got)
+ }
+ }
+}
+
+func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
+ stripeLike := "sk_" + "live_1234567890abcdef"
+ got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
+ `{ "resource_token": "` + stripeLike + `" }`,
+ `{ "block_token": "real-client-secret-value" }`,
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ count++
+ }
+ }
+ if count != 2 {
+ t.Fatalf("credential-shaped token-like placeholders findings = %d, want 2: %#v", count, got)
+ }
+}
+
+func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
+ got := ScanFile("shortcuts/minutes/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
+ if !findingRules(got)["public_content_generic_credential"] {
+ t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
+ }
+}
+
+func TestScanFileAllowsBenignUnquotedTokenFields(t *testing.T) {
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "tokens: 128",
+ "token_type: bearer",
+ "max_tokens: 2000",
+ "completion_tokens: 200",
+ "prompt_tokens: 100",
+ }, "\n")+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("benign unquoted token field should not be credential finding: %#v", got)
+ }
+ }
+}
+
+func TestSemanticCandidateRedactsColonAssignmentsWithEqualsInValue(t *testing.T) {
+ paddedSecretPrefix := "YWJj" + "ZGVmZ2g"
+ paddedSecret := base64PaddedFixture(paddedSecretPrefix)
+ text := "private launch plan for internal rollout on Friday\n" +
+ "api_" + "secret: " + paddedSecret + "\n" +
+ `{"access_` + `token":"` + paddedSecret + `"}` + "\n"
+
+ got := semanticCandidate("docs/public.md", "file", text, 1)
+ if len(got) != 1 {
+ t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got)
+ }
+ if strings.Contains(got[0].Excerpt, paddedSecret) || strings.Contains(got[0].Excerpt, paddedSecretPrefix) {
+ t.Fatalf("semantic candidate leaked colon assignment with padding: %#v", got[0])
+ }
+}
+
+func TestSemanticCandidateRedactsEscapedQuoteCredentialValues(t *testing.T) {
+ doubleQuotedValue := "abc\\\"def-secret"
+ singleQuotedValue := "abc\\'def-secret"
+ text := "private launch plan for internal rollout on Friday\n" +
+ `{"access_` + `token":"` + doubleQuotedValue + `"}` + "\n" +
+ `{'client_` + `secret': '` + singleQuotedValue + `'}` + "\n"
+
+ got := semanticCandidate("docs/public.md", "file", text, 1)
+ if len(got) != 1 {
+ t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got)
+ }
+ for _, forbidden := range []string{doubleQuotedValue, singleQuotedValue, "def-secret"} {
+ if strings.Contains(got[0].Excerpt, forbidden) {
+ t.Fatalf("semantic candidate leaked escaped-quote credential value %q: %#v", forbidden, got[0])
+ }
+ }
+}
+
+func TestScanFileDoesNotTreatEqualityComparisonAsCredential(t *testing.T) {
+ got := ScanFile("docs/example.md", []byte("if token == \"expected\" { return nil }\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("equality comparison should not be credential assignment: %#v", got)
+ }
+ }
+}
+
+func TestScanFileDetectsBearerHeaderFinding(t *testing.T) {
+ got := ScanFile("docs/auth.md", []byte("Authorization: Bearer abcdefghijklmnopqrstuvwxyz\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_bearer_header" {
+ if item.Action != "REJECT" || item.File != "docs/auth.md" || item.Line != 1 || item.Source != "file" {
+ t.Fatalf("bearer finding attribution = %#v", item)
+ }
+ return
+ }
+ }
+ t.Fatalf("missing bearer finding: %#v", got)
+}
+
+func TestScanFileDetectsJSONBearerHeaders(t *testing.T) {
+ token := "abcdefghijklmnopqrstuvwxyz"
+ got := ScanFile("docs/auth.json", []byte(strings.Join([]string{
+ `{"Authorization":"Bearer ` + token + `"}`,
+ `{"headers":{"Authorization":"Bearer ` + token + `"}}`,
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule != "public_content_bearer_header" {
+ continue
+ }
+ count++
+ if item.Action != "REJECT" || item.File != "docs/auth.json" || item.Source != "file" {
+ t.Fatalf("bearer finding attribution = %#v", item)
+ }
+ if strings.Contains(item.Excerpt, token) {
+ t.Fatalf("bearer finding leaked token: %#v", item)
+ }
+ }
+ if count != 2 {
+ t.Fatalf("JSON bearer findings = %d, want 2: %#v", count, got)
+ }
+}
+
+func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) {
+ token := "abcdefghijklmnopqrstuvwxyz"
+ text := "private launch plan for internal rollout on Friday\n" +
+ `{"headers":{"Authorization":"Bearer ` + token + `"}}` + "\n"
+
+ got := semanticCandidate("docs/public.md", "file", text, 1)
+ if len(got) != 1 {
+ t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got)
+ }
+ if strings.Contains(got[0].Excerpt, token) {
+ t.Fatalf("semantic candidate leaked JSON bearer token: %#v", got[0])
+ }
+ if !strings.Contains(got[0].Excerpt, "Authorization: Bearer ") {
+ t.Fatalf("semantic candidate should redact JSON bearer header, got %#v", got[0])
+ }
+}
+
+func TestScanFileDetectsCommonProvenanceMarkers(t *testing.T) {
+ text := strings.Join([]string{
+ "Generated with automated code assistant",
+ "Co-authored-by: automated-code-assistant ",
+ "🤖 generated by automation",
+ }, "\n")
+ got := ScanFile("docs/public.md", []byte(text))
+ var count int
+ for _, item := range got {
+ if item.Rule == "public_content_provenance_marker" {
+ count++
+ }
+ }
+ if count != 3 {
+ t.Fatalf("provenance marker count = %d, want 3: %#v", count, got)
+ }
+}
+
+func TestScanFileAllowsHumanCoAuthorTrailer(t *testing.T) {
+ got := ScanFile("docs/public.md", []byte(strings.Join([]string{
+ "Co-authored-by: Jane Doe ",
+ "Co-authored-by: Alice Abbot ",
+ }, "\n")))
+ for _, item := range got {
+ if item.Rule == "public_content_provenance_marker" {
+ t.Fatalf("human co-author trailer should not be blocked: %#v", got)
+ }
+ }
+}
+
+func TestScanFileAllowsPercentWrappedPlaceholder(t *testing.T) {
+ got := ScanFile("docs/config.md", []byte("client_secret=%CLIENT_SECRET%\n"))
+ if len(got) != 0 {
+ t.Fatalf("percent-wrapped placeholder produced findings: %#v", got)
+ }
+}
+
+func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
+ stripeLike := "sk_" + "live_1234567890abcdef"
+ patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
+ got := ScanFile("docs/config.md", []byte(strings.Join([]string{
+ "CLIENT_SECRET=%" + stripeLike + "%",
+ "GITHUB_TOKEN=%" + patLike + "%",
+ "TOKEN=%real-secret-token-value%",
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ count++
+ }
+ }
+ if count != 3 {
+ t.Fatalf("percent-wrapped credential findings = %d, want 3: %#v", count, got)
+ }
+}
+
+func findingRules(items []Finding) map[string]bool {
+ out := map[string]bool{}
+ for _, item := range items {
+ out[item.Rule] = true
+ }
+ return out
+}
+
+func jwtFixture(subject string) string {
+ return strings.Join([]string{
+ jwtHeaderFixture(),
+ "eyJzdWIiOiJ" + subject + "In0",
+ "signature" + "part",
+ }, ".")
+}
+
+func jwtHeaderFixture() string {
+ return "eyJhbGciOiJI" + "UzI1NiJ9"
+}
+
+func base64PaddedFixture(prefix string) string {
+ return prefix + "=="
+}
diff --git a/internal/qualitygate/publiccontent/types.go b/internal/qualitygate/publiccontent/types.go
new file mode 100644
index 00000000..cd933868
--- /dev/null
+++ b/internal/qualitygate/publiccontent/types.go
@@ -0,0 +1,30 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package publiccontent
+
+import "github.com/larksuite/cli/internal/qualitygate/report"
+
+type Options struct {
+ Repo string
+ ChangedFrom string
+ MetadataPath string
+ BranchName string
+}
+
+type Metadata struct {
+ Title string `json:"title"`
+ Body string `json:"body"`
+ Branch string `json:"branch"`
+}
+
+type Finding struct {
+ Rule string
+ Action report.Action
+ File string
+ Line int
+ Source string
+ Excerpt string
+ Message string
+ Suggestion string
+}
diff --git a/internal/qualitygate/rules/dryrun.go b/internal/qualitygate/rules/dryrun.go
index d1fb0033..f4ac3d56 100644
--- a/internal/qualitygate/rules/dryrun.go
+++ b/internal/qualitygate/rules/dryrun.go
@@ -174,8 +174,9 @@ type materializedExample struct {
}
type placeholderContext struct {
- FlagName string
- FlagUsage string
+ FlagName string
+ FlagUsage string
+ FlagDefault string
}
func materializePlaceholderExample(raw string, cmd manifest.Command) (materializedExample, bool) {
@@ -247,6 +248,7 @@ func placeholderContextForFlag(name string, flag *manifest.Flag) placeholderCont
ctx := placeholderContext{FlagName: name}
if flag != nil {
ctx.FlagUsage = flag.Usage
+ ctx.FlagDefault = flag.DefValue
}
return ctx
}
@@ -309,11 +311,17 @@ func fakeValueForPlaceholder(raw string, ctx placeholderContext) (string, bool)
if name == "" {
return "", false
}
+ if value, ok := fakeNumericValueForPlaceholder(name, ctx); ok {
+ return value, true
+ }
+ if value, ok := fakeContextualURLValueForPlaceholder(name, ctx); ok {
+ return value, true
+ }
if value, ok := fakeValueFromPlaceholderName(name); ok {
return value, true
}
if isGenericPlaceholderName(name) {
- return fakeValueFromUsageHint(ctx.FlagUsage)
+ return fakeValueFromContextHint(ctx)
}
return "", false
}
@@ -336,16 +344,26 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
return "file_test123", true
case hasPlaceholderToken(tokens, "file") && hasPlaceholderToken(tokens, "token"):
return "file_test123", true
+ case hasPlaceholderToken(tokens, "folder") && hasPlaceholderToken(tokens, "token"):
+ return "fld_test123", true
case hasPlaceholderToken(tokens, "image", "img"):
return "img_test123", true
case hasPlaceholderToken(tokens, "app"):
return "app_test123", true
+ case hasPlaceholderToken(tokens, "draft"):
+ return "draft_test123", true
+ case hasPlaceholderToken(tokens, "label"):
+ return "label_test123", true
+ case hasPlaceholderToken(tokens, "share"):
+ return "share_test123", true
case hasPlaceholderToken(tokens, "doc", "document"):
return "doc_test123", true
case hasPlaceholderToken(tokens, "sheet", "spreadsheet"):
return "shtcn_test123", true
case hasPlaceholderToken(tokens, "base"):
return "base_test123", true
+ case hasPlaceholderToken(tokens, "space"):
+ return "space_test123", true
case hasPlaceholderToken(tokens, "table"):
return "tbl_test123", true
case hasPlaceholderToken(tokens, "view"):
@@ -377,17 +395,98 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
}
}
-func fakeValueFromUsageHint(usage string) (string, bool) {
- match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(usage))
+func fakeValueFromContextHint(ctx placeholderContext) (string, bool) {
+ if value, ok := fakeNumericValueForPlaceholder("", ctx); ok {
+ return value, true
+ }
+ if value, ok := fakeContextualURLValueForPlaceholder("", ctx); ok {
+ return value, true
+ }
+ match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(ctx.FlagUsage))
if len(match) != 2 || !knownTokenPrefix(match[1]) {
return "", false
}
return match[1] + "_test123", true
}
+func fakeContextualURLValueForPlaceholder(name string, ctx placeholderContext) (string, bool) {
+ nameTokens := placeholderTokenSet(name)
+ flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_")
+ flagTokens := placeholderTokenSet(flagName)
+ if !hasPlaceholderToken(nameTokens, "url", "link") && !hasPlaceholderToken(flagTokens, "url", "link") {
+ return "", false
+ }
+ usage := strings.ToLower(ctx.FlagUsage)
+ if strings.Contains(usage, "lark") || strings.Contains(usage, "feishu") || strings.Contains(usage, "document url") {
+ return "https://example.feishu.cn/docx/doc_test123", true
+ }
+ return "", false
+}
+
+func fakeNumericValueForPlaceholder(name string, ctx placeholderContext) (string, bool) {
+ nameTokens := placeholderTokenSet(name)
+ flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_")
+ flagTokens := placeholderTokenSet(flagName)
+ usage := strings.ToLower(ctx.FlagUsage)
+
+ switch {
+ case placeholderTokenPair(nameTokens, "meeting", "id") || placeholderTokenPair(flagTokens, "meeting", "id"):
+ return "400000000001", true
+ case placeholderTokenPair(nameTokens, "meeting", "ids") || placeholderTokenPair(flagTokens, "meeting", "ids"):
+ return "400000000001", true
+ case placeholderTokenPair(nameTokens, "meeting", "no") || placeholderTokenPair(flagTokens, "meeting", "no"):
+ return "123456789", true
+ case placeholderTokenPair(nameTokens, "meeting", "number") || placeholderTokenPair(flagTokens, "meeting", "number"):
+ return "123456789", true
+ case hasPlaceholderToken(nameTokens, "timestamp") || hasPlaceholderToken(flagTokens, "timestamp") || strings.Contains(usage, "unix timestamp"):
+ return defaultPositiveInteger(ctx.FlagDefault, "1893456000"), true
+ case placeholderTokenPair(nameTokens, "page", "size") || placeholderTokenPair(flagTokens, "page", "size"):
+ return defaultPositiveInteger(ctx.FlagDefault, "20"), true
+ case placeholderTokenPair(nameTokens, "page", "limit") || placeholderTokenPair(flagTokens, "page", "limit"):
+ return defaultPositiveInteger(ctx.FlagDefault, "10"), true
+ case numericPlaceholderName(nameTokens) || numericPlaceholderName(flagTokens) || numericUsageHint(usage):
+ return defaultPositiveInteger(ctx.FlagDefault, "20"), true
+ default:
+ return "", false
+ }
+}
+
+func numericPlaceholderName(tokens map[string]bool) bool {
+ if len(tokens) == 0 || hasPlaceholderToken(tokens, "token", "format", "type", "status", "mode") {
+ return false
+ }
+ return hasPlaceholderToken(tokens,
+ "amount", "count", "depth", "height", "index", "length", "limit", "max",
+ "number", "revision", "size", "width",
+ )
+}
+
+func numericUsageHint(usage string) bool {
+ if usage == "" {
+ return false
+ }
+ return strings.Contains(usage, "positive integer") ||
+ strings.Contains(usage, "decimal integer") ||
+ strings.Contains(usage, "number of ") ||
+ strings.Contains(usage, "(number)")
+}
+
+func defaultPositiveInteger(raw, fallback string) string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || strings.HasPrefix(raw, "-") || raw == "0" {
+ return fallback
+ }
+ for _, r := range raw {
+ if r < '0' || r > '9' {
+ return fallback
+ }
+ }
+ return raw
+}
+
func knownTokenPrefix(prefix string) bool {
switch prefix {
- case "app", "base", "doc", "file", "fld", "img", "item", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "shtcn", "task", "tbl", "token", "viw", "wiki":
+ case "app", "base", "doc", "draft", "file", "fld", "img", "item", "label", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "share", "shtcn", "space", "task", "tbl", "token", "viw", "wiki":
return true
default:
return false
@@ -431,6 +530,10 @@ func hasPlaceholderToken(tokens map[string]bool, wants ...string) bool {
return false
}
+func placeholderTokenPair(tokens map[string]bool, first, second string) bool {
+ return tokens[first] && tokens[second]
+}
+
func hasUnresolvedDryRunPlaceholder(value string) bool {
if skillscan.HasPlaceholder(value) {
return true
@@ -623,6 +726,7 @@ func appendDryRunArg(raw string) ([]string, error) {
return nil, fmt.Errorf("not a lark-cli command")
}
argv = truncateShellTail(argv)
+ argv = forceDryRunJSONFormat(argv)
hasDryRunArg := false
dryRunEnabled := false
for _, arg := range argv[1:] {
@@ -642,6 +746,23 @@ func appendDryRunArg(raw string) ([]string, error) {
return append(argv[1:], "--dry-run"), nil
}
+func forceDryRunJSONFormat(argv []string) []string {
+ for i := 1; i < len(argv); i++ {
+ arg := argv[i]
+ if arg == "--format" {
+ if i+1 < len(argv) && argv[i+1] == "pretty" {
+ argv[i+1] = "json"
+ }
+ return argv
+ }
+ if arg == "--format=pretty" {
+ argv[i] = "--format=json"
+ return argv
+ }
+ }
+ return argv
+}
+
func truncateShellTail(argv []string) []string {
for i, arg := range argv {
if i == 0 {
diff --git a/internal/qualitygate/rules/dryrun_test.go b/internal/qualitygate/rules/dryrun_test.go
index 7082be3e..8c109ca4 100644
--- a/internal/qualitygate/rules/dryrun_test.go
+++ b/internal/qualitygate/rules/dryrun_test.go
@@ -305,6 +305,161 @@ func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) {
}
}
+func TestRunDryRunsMaterializesNumericPlaceholderFlagValues(t *testing.T) {
+ cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/vc/v1/bots/events","params":{"meeting_id":"400000000001","page_size":50}}]}`)
+ m := manifest.Manifest{Commands: []manifest.Command{{
+ Path: "vc +meeting-events",
+ Runnable: true,
+ Flags: []manifest.Flag{
+ {Name: "meeting-id", TakesValue: true, Usage: "meeting ID to query; must be a long positive integer, not a 9-digit meeting number"},
+ {Name: "page-size", TakesValue: true, Usage: "page size, 20-100 (default 50)", DefValue: "50"},
+ {Name: "dry-run"},
+ },
+ }}}
+ ex := skillscan.Example{
+ Raw: "lark-cli vc +meeting-events --meeting-id --page-size ",
+ SourceFile: "skills/lark-vc-agent/SKILL.md",
+ Line: 120,
+ HasPlaceholder: true,
+ }
+
+ diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
+ if len(diags) != 0 {
+ t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
+ }
+ if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
+ t.Fatalf("numeric placeholder example should be executable after materialization: %#v", facts)
+ }
+ wantArgs := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--page-size", "50", "--dry-run"}
+ if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
+ t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
+ }
+}
+
+func TestRunDryRunsMaterializesNumericPlaceholdersInsideJSONFlags(t *testing.T) {
+ cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/test","params":{"timestamp":"1893456000","count":"20"}}]}`)
+ m := manifest.Manifest{Commands: []manifest.Command{{
+ Path: "api GET",
+ Runnable: true,
+ Flags: []manifest.Flag{
+ {Name: "params", TakesValue: true},
+ {Name: "dry-run"},
+ },
+ }}}
+ ex := skillscan.Example{
+ Raw: `lark-cli api GET /open-apis/test --params '{"timestamp":"","count":""}'`,
+ SourceFile: "skills/lark-demo/SKILL.md",
+ Line: 20,
+ HasPlaceholder: true,
+ }
+
+ diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
+ if len(diags) != 0 {
+ t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
+ }
+ if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
+ t.Fatalf("JSON numeric placeholder example should be executable after materialization: %#v", facts)
+ }
+ wantArgs := []string{"api", "GET", "/open-apis/test", "--params", `{"timestamp":"1893456000","count":"20"}`, "--dry-run"}
+ if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
+ t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
+ }
+}
+
+func TestRunDryRunsMaterializesLarkDocumentURLPlaceholders(t *testing.T) {
+ cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/drive/v1/metas/batch_query"}]}`)
+ m := manifest.Manifest{Commands: []manifest.Command{{
+ Path: "drive +inspect",
+ Runnable: true,
+ Flags: []manifest.Flag{
+ {Name: "url", TakesValue: true, Usage: "Lark/Feishu document URL (docx, doc, sheet, bitable, wiki, file, folder, mindnote, slides)"},
+ {Name: "format", TakesValue: true},
+ {Name: "dry-run"},
+ },
+ }}}
+ ex := skillscan.Example{
+ Raw: "lark-cli drive +inspect --url '' --format json",
+ SourceFile: "skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md",
+ Line: 15,
+ HasPlaceholder: true,
+ }
+
+ diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
+ if len(diags) != 0 {
+ t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
+ }
+ if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
+ t.Fatalf("Lark URL placeholder example should be executable after materialization: %#v", facts)
+ }
+ wantArgs := []string{"drive", "+inspect", "--url", "https://example.feishu.cn/docx/doc_test123", "--format", "json", "--dry-run"}
+ if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
+ t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
+ }
+}
+
+func TestRunDryRunsMaterializesResourceIDPlaceholderFlagValues(t *testing.T) {
+ cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/wiki/v2/spaces/space_test123/nodes"}]}`)
+ m := manifest.Manifest{Commands: []manifest.Command{{
+ Path: "wiki +node-list",
+ Runnable: true,
+ Flags: []manifest.Flag{
+ {Name: "space-id", TakesValue: true, Usage: "wiki space ID"},
+ {Name: "page-token", TakesValue: true, Usage: "page token"},
+ {Name: "format", TakesValue: true},
+ {Name: "dry-run"},
+ },
+ }}}
+ ex := skillscan.Example{
+ Raw: "lark-cli wiki +node-list --space-id --page-token --format json",
+ SourceFile: "skills/lark-wiki/references/lark-wiki-node-list.md",
+ Line: 24,
+ HasPlaceholder: true,
+ }
+
+ diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
+ if len(diags) != 0 {
+ t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
+ }
+ if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
+ t.Fatalf("resource ID placeholder example should be executable after materialization: %#v", facts)
+ }
+ wantArgs := []string{"wiki", "+node-list", "--space-id", "space_test123", "--page-token", "page_test123", "--format", "json", "--dry-run"}
+ if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
+ t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
+ }
+}
+
+func TestRunDryRunsMaterializesResourcePlaceholdersInsideJSONFlags(t *testing.T) {
+ cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"POST","url":"/open-apis/mail/v1/user_mailboxes/me/drafts/draft_test123/send"}]}`)
+ m := manifest.Manifest{Commands: []manifest.Command{{
+ Path: "mail user_mailbox.drafts send",
+ Runnable: true,
+ Flags: []manifest.Flag{
+ {Name: "params", TakesValue: true},
+ {Name: "data", TakesValue: true},
+ {Name: "dry-run"},
+ },
+ }}}
+ ex := skillscan.Example{
+ Raw: `lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":""}' --data '{"send_time":""}'`,
+ SourceFile: "skills/lark-mail/references/lark-mail-send.md",
+ Line: 172,
+ HasPlaceholder: true,
+ }
+
+ diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
+ if len(diags) != 0 {
+ t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
+ }
+ if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
+ t.Fatalf("JSON resource placeholder example should be executable after materialization: %#v", facts)
+ }
+ wantArgs := []string{"mail", "user_mailbox.drafts", "send", "--params", `{"user_mailbox_id":"me","draft_id":"draft_test123"}`, "--data", `{"send_time":"1893456000"}`, "--dry-run"}
+ if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
+ t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
+ }
+}
+
func TestRunDryRunsSkipsUnknownFlagsBeforeDryRun(t *testing.T) {
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "im +chat-messages-list",
@@ -600,6 +755,51 @@ func TestAppendDryRunArgDoesNotDuplicate(t *testing.T) {
}
}
+func TestAppendDryRunArgForcesJSONFormat(t *testing.T) {
+ got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format pretty")
+ if err != nil {
+ t.Fatalf("appendDryRunArg() error = %v", err)
+ }
+ want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format", "json", "--dry-run"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
+ }
+}
+
+func TestAppendDryRunArgForcesInlineJSONFormat(t *testing.T) {
+ got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format=pretty --dry-run")
+ if err != nil {
+ t.Fatalf("appendDryRunArg() error = %v", err)
+ }
+ want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format=json", "--dry-run"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
+ }
+}
+
+func TestAppendDryRunArgPreservesNonPrettyFormat(t *testing.T) {
+ for _, raw := range []string{
+ "lark-cli mail +watch --format data --dry-run",
+ "lark-cli export +events --format=ndjson --dry-run",
+ "lark-cli docs +fetch --format table",
+ } {
+ got, err := appendDryRunArg(raw)
+ if err != nil {
+ t.Fatalf("appendDryRunArg(%q) error = %v", raw, err)
+ }
+ for _, arg := range got {
+ if arg == "--format=json" {
+ t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote inline format: %#v", raw, got)
+ }
+ }
+ for i, arg := range got {
+ if arg == "--format" && i+1 < len(got) && got[i+1] == "json" {
+ t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote split format: %#v", raw, got)
+ }
+ }
+ }
+}
+
func TestAppendDryRunArgForcesDryRunWhenExplicitlyDisabled(t *testing.T) {
got, err := appendDryRunArg("lark-cli docs +fetch --dry-run=false --doc abc")
if err != nil {
diff --git a/internal/qualitygate/rules/run.go b/internal/qualitygate/rules/run.go
index 6026671c..35acea1c 100644
--- a/internal/qualitygate/rules/run.go
+++ b/internal/qualitygate/rules/run.go
@@ -15,18 +15,20 @@ import (
manifestexamples "github.com/larksuite/cli/internal/qualitygate/examples"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/manifest"
+ "github.com/larksuite/cli/internal/qualitygate/publiccontent"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/qualitygate/skillscan"
"github.com/larksuite/cli/internal/vfs"
)
type Options struct {
- Repo string
- CLIBin string
- ChangedFrom string
- FactsOut string
- ManifestPath string
- CommandIndexPath string
+ Repo string
+ CLIBin string
+ ChangedFrom string
+ FactsOut string
+ ManifestPath string
+ CommandIndexPath string
+ PublicContentMetadataPath string
}
func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, error) {
@@ -98,9 +100,60 @@ func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, e
if opts.ChangedFrom != "" {
diags = append(diags, errorDiags...)
}
+ publicContent, err := publiccontent.Collect(ctx, publiccontent.Options{
+ Repo: opts.Repo,
+ ChangedFrom: opts.ChangedFrom,
+ MetadataPath: opts.PublicContentMetadataPath,
+ })
+ if err != nil {
+ return nil, facts.Facts{}, err
+ }
+ diags = append(diags, publicContentDiagnostics(publicContent)...)
diags = filterPRDiagnostics(opts.Repo, opts.ChangedFrom, scope, m, diags)
- return diags, facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files), nil
+ builtFacts := facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files)
+ return diags, facts.WithPublicContent(builtFacts, publicContentFacts(publicContent)), nil
+}
+
+func publicContentDiagnostics(items []publiccontent.Finding) []report.Diagnostic {
+ if len(items) == 0 {
+ return nil
+ }
+ out := make([]report.Diagnostic, 0, len(items))
+ for _, item := range items {
+ if item.Rule == "public_content_semantic_candidate" {
+ continue
+ }
+ out = append(out, report.Diagnostic{
+ Rule: item.Rule,
+ Action: item.Action,
+ File: item.File,
+ Line: item.Line,
+ Message: item.Message,
+ Suggestion: item.Suggestion,
+ })
+ }
+ return out
+}
+
+func publicContentFacts(items []publiccontent.Finding) []facts.PublicContentFact {
+ if len(items) == 0 {
+ return nil
+ }
+ out := make([]facts.PublicContentFact, 0, len(items))
+ for _, item := range items {
+ out = append(out, facts.PublicContentFact{
+ Rule: item.Rule,
+ Action: item.Action,
+ File: item.File,
+ Line: item.Line,
+ Source: item.Source,
+ Excerpt: item.Excerpt,
+ Message: item.Message,
+ Suggestion: item.Suggestion,
+ })
+ }
+ return out
}
func readManifestInput(path, kind, flag string) (manifest.Manifest, error) {
@@ -167,6 +220,9 @@ func filterPRDiagnostics(repo, changedFrom string, scope qdiff.Scope, m manifest
}
func prDiagnosticRelevant(repo string, changedFiles map[string]bool, commandScope diagnosticCommandScope, m manifest.Manifest, diag report.Diagnostic) bool {
+ if strings.HasPrefix(diag.Rule, "public_content_") {
+ return true
+ }
file := normalizeDiagnosticFile(repo, diag.File)
if file != "" && changedFiles[file] {
return true
diff --git a/internal/qualitygate/rules/run_test.go b/internal/qualitygate/rules/run_test.go
index 99188b44..b60a2c63 100644
--- a/internal/qualitygate/rules/run_test.go
+++ b/internal/qualitygate/rules/run_test.go
@@ -189,6 +189,99 @@ description: Manage Drive comments with service command references.
}
}
+func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
+ repo := t.TempDir()
+ runGit(t, repo, "init")
+ runGit(t, repo, "config", "user.email", "test@example.com")
+ runGit(t, repo, "config", "user.name", "Test User")
+ if err := vfs.WriteFile(filepath.Join(repo, "README.md"), []byte("# test\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, repo, "add", "README.md")
+ runGit(t, repo, "commit", "-m", "base")
+
+ if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ publicDoc := "api_" + "key = \"example-public-key\"\n" +
+ "Public docs describe a pri" + "vate request header and trust classification detail.\n"
+ if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, repo, "add", "docs/public.md")
+ runGit(t, repo, "commit", "-m", "add public doc")
+
+ metadataPath := filepath.Join(repo, "pr-metadata.json")
+ if err := vfs.WriteFile(metadataPath, []byte(`{"title":"public docs","body":"Change`+`-Id: I0123456789abcdef0123456789abcdef01234567"}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ manifestPath := filepath.Join(repo, "command-manifest.json")
+ indexPath := filepath.Join(repo, "command-index.json")
+ m := manifest.Manifest{SchemaVersion: 1, Commands: []manifest.Command{{
+ Path: "docs +fetch",
+ CanonicalPath: "docs +fetch",
+ Domain: "docs",
+ Source: manifest.SourceShortcut,
+ }}}
+ if err := manifest.WriteFile(manifestPath, manifest.KindCommandManifest, m); err != nil {
+ t.Fatal(err)
+ }
+ idx := manifest.Manifest{SchemaVersion: 1, Commands: append([]manifest.Command{}, m.Commands...)}
+ idx.Commands = append(idx.Commands, manifest.Command{
+ Path: "drive files get",
+ CanonicalPath: "drive files get",
+ Domain: "drive",
+ Source: manifest.SourceService,
+ Generated: true,
+ Runnable: true,
+ })
+ if err := manifest.WriteFile(indexPath, manifest.KindCommandIndex, idx); err != nil {
+ t.Fatal(err)
+ }
+
+ diags, gotFacts, err := Run(context.Background(), Options{
+ Repo: repo,
+ CLIBin: "./lark-cli",
+ ChangedFrom: "HEAD~1",
+ ManifestPath: manifestPath,
+ CommandIndexPath: indexPath,
+ PublicContentMetadataPath: metadataPath,
+ })
+ if err != nil {
+ t.Fatalf("Run() error = %v", err)
+ }
+ actions := map[string]report.Action{}
+ for _, diag := range diags {
+ actions[diag.Rule] = diag.Action
+ }
+ if actions["public_content_generic_credential"] != report.ActionReject {
+ t.Fatalf("generic credential diagnostic action = %q, diagnostics=%#v", actions["public_content_generic_credential"], diags)
+ }
+ if actions["public_content_change_id_trailer"] != report.ActionReject {
+ t.Fatalf("change-id diagnostic action = %q, diagnostics=%#v", actions["public_content_change_id_trailer"], diags)
+ }
+ if actions["public_content_semantic_candidate"] != "" {
+ t.Fatalf("semantic candidates should not become deterministic diagnostics: %#v", diags)
+ }
+ factRules := map[string]bool{}
+ for _, item := range gotFacts.PublicContent {
+ factRules[item.Rule] = true
+ }
+ for _, want := range []string{
+ "public_content_generic_credential",
+ "public_content_change_id_trailer",
+ "public_content_semantic_candidate",
+ } {
+ if !factRules[want] {
+ t.Fatalf("missing public content fact %s: %#v", want, gotFacts.PublicContent)
+ }
+ }
+ if len(gotFacts.PublicContent) < 3 {
+ t.Fatalf("public content facts = %#v", gotFacts.PublicContent)
+ }
+}
+
func TestLoadBaseReferenceManifestReadsCommandGolden(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
@@ -506,7 +599,7 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
- cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
+ cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2026-06-17T00:00:00Z", "GIT_COMMITTER_DATE=2026-06-17T00:00:00Z")
out, err := cmd.CombinedOutput()
if err != nil {
diff --git a/internal/qualitygate/semantic/client.go b/internal/qualitygate/semantic/client.go
index e66f2862..5a7c873f 100644
--- a/internal/qualitygate/semantic/client.go
+++ b/internal/qualitygate/semantic/client.go
@@ -339,7 +339,7 @@ func jsonSchemaResponseFormat() map[string]any {
"properties": map[string]any{
"category": map[string]any{
"type": "string",
- "enum": []string{"error_hint", "default_output", "naming", "skill_quality"},
+ "enum": []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
},
"severity": map[string]any{
"type": "string",
diff --git a/internal/qualitygate/semantic/gatekeeper.go b/internal/qualitygate/semantic/gatekeeper.go
index 23da39f4..57e0cbcb 100644
--- a/internal/qualitygate/semantic/gatekeeper.go
+++ b/internal/qualitygate/semantic/gatekeeper.go
@@ -10,9 +10,10 @@ import (
"strings"
"github.com/larksuite/cli/internal/qualitygate/facts"
+ "github.com/larksuite/cli/internal/qualitygate/report"
)
-var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs)\[(\d+)\]$`)
+var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs|public_content)\[(\d+)\]$`)
func Decide(f facts.Facts, r Review, p Policy) Decision {
return DecideWithWaivers(f, r, p, Waivers{})
@@ -172,6 +173,16 @@ func evidenceFingerprint(f facts.Facts, ev string) string {
"has_default_limit:" + strconv.FormatBool(out.HasDefaultLimit),
"has_decision_field:" + strconv.FormatBool(out.HasDecisionField),
}, ":")
+ case "public_content":
+ item := f.PublicContent[idx]
+ return strings.Join([]string{
+ "public_content",
+ "rule:" + item.Rule,
+ "action:" + string(item.Action),
+ "file:" + item.File,
+ "line:" + strconv.Itoa(item.Line),
+ "source:" + item.Source,
+ }, ":")
default:
return "ref:" + ev
}
@@ -201,7 +212,7 @@ func validFinding(f Finding) bool {
func allowedCategory(category string) bool {
switch category {
- case "error_hint", "default_output", "naming", "skill_quality":
+ case "error_hint", "default_output", "naming", "skill_quality", "public_content_leakage":
return true
default:
return false
@@ -247,6 +258,12 @@ func reproducibleEvidence(f facts.Facts, category, kind string, idx int) bool {
}
skill := f.Skills[idx]
return skill.ReferencesInvalidCommand
+ case "public_content_leakage":
+ if kind != "public_content" {
+ return false
+ }
+ item := f.PublicContent[idx]
+ return item.Action == report.ActionReject || item.Rule == "public_content_semantic_candidate"
default:
return false
}
@@ -277,6 +294,8 @@ func evidenceExists(f facts.Facts, kind string, idx int) bool {
return idx < len(f.Errors)
case "outputs":
return idx < len(f.Outputs)
+ case "public_content":
+ return idx < len(f.PublicContent)
default:
return false
}
diff --git a/internal/qualitygate/semantic/gatekeeper_test.go b/internal/qualitygate/semantic/gatekeeper_test.go
index c1d3e897..8e8ac676 100644
--- a/internal/qualitygate/semantic/gatekeeper_test.go
+++ b/internal/qualitygate/semantic/gatekeeper_test.go
@@ -242,6 +242,7 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
Outputs: []facts.OutputFact{{Command: "im messages list", IsList: true, HasDefaultLimit: false, HasDecisionField: false}},
Commands: []facts.CommandFact{{Path: "docs fetch", NameConflictsExisting: true}},
Skills: []facts.SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 3, ReferencesInvalidCommand: true}},
+ PublicContent: []facts.PublicContentFact{{Rule: "public_content_generic_credential", Action: "REJECT", File: "docs/public.md", Line: 4, Source: "metadata"}},
}
for _, tc := range []struct {
category string
@@ -251,6 +252,7 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
{"default_output", "facts.outputs[0]"},
{"naming", "facts.commands[0]"},
{"skill_quality", "facts.skills[0]"},
+ {"public_content_leakage", "facts.public_content[0]"},
} {
t.Run(tc.category, func(t *testing.T) {
r := Review{Findings: []Finding{{
@@ -268,6 +270,59 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
}
}
+func TestGatekeeperDoesNotPromotePublicContentWarningsToBlockers(t *testing.T) {
+ f := facts.Facts{
+ SchemaVersion: 1,
+ PublicContent: []facts.PublicContentFact{{
+ Rule: "public_content_" + "pri" + "vate_ipv4",
+ Action: "WARNING",
+ File: "docs/network.md",
+ Line: 1,
+ Source: "file",
+ }},
+ }
+ review := Review{Findings: []Finding{{
+ Category: "public_content_leakage",
+ Severity: "minor",
+ Evidence: []string{"facts.public_content[0]"},
+ Message: "pri" + "vate network address appears in public docs",
+ SuggestedAction: "confirm the public docs do not expose pri" + "vate deployment details",
+ }}}
+
+ got := Decide(f, review, DefaultPolicy())
+ if len(got.Blockers) != 0 || len(got.Warnings) != 1 {
+ t.Fatalf("public content warning should not become a blocker: %#v", got)
+ }
+ if got.Warnings[0].ReviewAction != ReviewActionObserve {
+ t.Fatalf("review action = %q, want %q", got.Warnings[0].ReviewAction, ReviewActionObserve)
+ }
+}
+
+func TestGatekeeperAllowsPublicContentSemanticCandidatesAsBlockers(t *testing.T) {
+ f := facts.Facts{
+ SchemaVersion: 1,
+ PublicContent: []facts.PublicContentFact{{
+ Rule: "public_content_semantic_candidate",
+ Action: "WARNING",
+ File: "docs/public.md",
+ Line: 1,
+ Source: "file",
+ }},
+ }
+ review := Review{Findings: []Finding{{
+ Category: "public_content_leakage",
+ Severity: "major",
+ Evidence: []string{"facts.public_content[0]"},
+ Message: "semantic review found pri" + "vate rollout detail",
+ SuggestedAction: "remove pri" + "vate rollout detail from public docs",
+ }}}
+
+ got := Decide(f, review, DefaultPolicy())
+ if len(got.Blockers) != 1 {
+ t.Fatalf("semantic candidate should remain blockable, got %#v", got)
+ }
+}
+
func TestGatekeeperSkillQualityOnlyBlocksInvalidCommandReferences(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,
diff --git a/internal/qualitygate/semantic/prompt.go b/internal/qualitygate/semantic/prompt.go
index 29c5d0e5..e2ae17f7 100644
--- a/internal/qualitygate/semantic/prompt.go
+++ b/internal/qualitygate/semantic/prompt.go
@@ -24,7 +24,7 @@ func BuildPrompt(f facts.Facts) []Message {
"Use only the provided JSON view.",
"The changed_summary may summarize broad changed surfaces; review only listed facts, not omitted summarized items.",
"Use fact_ref values exactly when writing finding evidence.",
- "Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.",
+ "Only facts.commands, facts.skills, facts.errors, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.",
"Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.",
"facts.examples and facts.skill_quality entries are context only.",
"Report an error_hint finding for any facts.errors item where boundary is true, required_hint is true, and hint_action_count is 0.",
@@ -38,6 +38,9 @@ func BuildPrompt(f facts.Facts) []Message {
"For naming findings, use category \"naming\" and evidence containing that facts.commands fact_ref.",
"Report a skill_quality finding for any facts.skills item where references_invalid_command is true.",
"For skill_quality findings, use category \"skill_quality\" and evidence containing that facts.skills fact_ref.",
+ "Review public content leakage findings and semantic candidates without private dictionaries.",
+ "Do not reveal internal rule lists when explaining public content leakage.",
+ "For public_content_leakage findings, preserve the deterministic finding source and excerpt.",
"Report each distinct issue as a separate finding.",
"The verdict value must be \"pass\" when findings is empty and \"warn\" when findings is non-empty; never use \"fail\".",
"Severity must be one of \"minor\", \"major\", or \"critical\"; never use \"error\", \"warning\", \"medium\", or \"high\".",
diff --git a/internal/qualitygate/semantic/prompt_contract_test.go b/internal/qualitygate/semantic/prompt_contract_test.go
index 98948383..fefa07ef 100644
--- a/internal/qualitygate/semantic/prompt_contract_test.go
+++ b/internal/qualitygate/semantic/prompt_contract_test.go
@@ -23,7 +23,10 @@ func TestBuildPromptContainsSemanticReviewContract(t *testing.T) {
"A facts.outputs item with is_list true, has_default_limit false, and has_decision_field true must still produce a default_output finding.",
"Report a naming finding for any facts.commands item where name_conflicts_existing is true or flag_alias_conflict is true.",
"Report a skill_quality finding for any facts.skills item where references_invalid_command is true.",
- "Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.",
+ "Review public content leakage findings and semantic candidates without private dictionaries.",
+ "Do not reveal internal rule lists when explaining public content leakage.",
+ "For public_content_leakage findings, preserve the deterministic finding source and excerpt.",
+ "Only facts.commands, facts.skills, facts.errors, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.",
"Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.",
"facts.examples and facts.skill_quality entries are context only.",
"Report each distinct issue as a separate finding.",
diff --git a/internal/qualitygate/semantic/schema.go b/internal/qualitygate/semantic/schema.go
index 2ab4572b..5097a870 100644
--- a/internal/qualitygate/semantic/schema.go
+++ b/internal/qualitygate/semantic/schema.go
@@ -78,11 +78,11 @@ func DefaultPolicy() Policy {
return Policy{
SchemaVersion: 1,
DefaultEnforcement: "observe",
- BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality"},
+ BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
RolloutGroups: []RolloutGroup{{
ID: "all",
Enforcement: "blocking",
- Categories: []string{"error_hint", "default_output", "naming", "skill_quality"},
+ Categories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
Owner: "test",
Reason: "default in-memory policy",
}},
diff --git a/internal/qualitygate/semantic/scope.go b/internal/qualitygate/semantic/scope.go
index 88a2cf93..d1738d17 100644
--- a/internal/qualitygate/semantic/scope.go
+++ b/internal/qualitygate/semantic/scope.go
@@ -82,6 +82,15 @@ func factScope(f facts.Facts, kind string, idx int) (FactScope, bool) {
Source: item.Source,
CommandPath: item.Command,
}, true
+ case "public_content":
+ item := f.PublicContent[idx]
+ return FactScope{
+ FactKind: "public_content",
+ Changed: true,
+ Source: item.Source,
+ SourceFile: item.File,
+ Line: item.Line,
+ }, true
default:
return FactScope{}, false
}
@@ -195,7 +204,7 @@ func containsString(values []string, want string) bool {
func allowedFactKind(kind string) bool {
switch kind {
- case "skill", "command", "error", "output":
+ case "skill", "command", "error", "output", "public_content":
return true
default:
return false
diff --git a/internal/qualitygate/semantic/scope_test.go b/internal/qualitygate/semantic/scope_test.go
index 863dbdbd..e5e50dc7 100644
--- a/internal/qualitygate/semantic/scope_test.go
+++ b/internal/qualitygate/semantic/scope_test.go
@@ -81,6 +81,30 @@ func TestGatekeeperSkillQualityUsesSkillEvidence(t *testing.T) {
}
}
+func TestGatekeeperUsesPublicContentEvidence(t *testing.T) {
+ f := facts.Facts{
+ SchemaVersion: 1,
+ PublicContent: []facts.PublicContentFact{{
+ Rule: "public_content_generic_credential",
+ Action: "REJECT",
+ File: "docs/public.md",
+ Line: 12,
+ Source: "metadata",
+ }},
+ }
+ review := Review{Findings: []Finding{{
+ Category: "public_content_leakage",
+ Severity: "critical",
+ Evidence: []string{"facts.public_content[0]"},
+ Message: "public content finding needs review",
+ SuggestedAction: "remove the sensitive public content",
+ }}}
+ got := Decide(f, review, DefaultPolicy())
+ if len(got.Blockers) != 1 || got.Blockers[0].RolloutGroups[0] != "all" {
+ t.Fatalf("expected public content blocker, got %#v", got)
+ }
+}
+
func TestGatekeeperAppliesSharedWaiverID(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,
diff --git a/internal/qualitygate/semantic/view.go b/internal/qualitygate/semantic/view.go
index a0e6f6dc..1bf61065 100644
--- a/internal/qualitygate/semantic/view.go
+++ b/internal/qualitygate/semantic/view.go
@@ -13,27 +13,29 @@ import (
)
type InputView struct {
- SchemaVersion int `json:"schema_version"`
- ChangedSummary ChangedSummary `json:"changed_summary"`
- RuleSummary []RuleSummaryItem `json:"rule_summary,omitempty"`
- Commands []CommandInput `json:"commands,omitempty"`
- Skills []SkillInput `json:"skills,omitempty"`
- SkillQuality []SkillQualityInput `json:"skill_quality,omitempty"`
- Errors []ErrorInput `json:"errors,omitempty"`
- Outputs []OutputInput `json:"outputs,omitempty"`
- Examples []ExampleInput `json:"examples,omitempty"`
- Diagnostics []facts.DiagnosticFact `json:"diagnostics,omitempty"`
+ SchemaVersion int `json:"schema_version"`
+ ChangedSummary ChangedSummary `json:"changed_summary"`
+ RuleSummary []RuleSummaryItem `json:"rule_summary,omitempty"`
+ Commands []CommandInput `json:"commands,omitempty"`
+ Skills []SkillInput `json:"skills,omitempty"`
+ SkillQuality []SkillQualityInput `json:"skill_quality,omitempty"`
+ Errors []ErrorInput `json:"errors,omitempty"`
+ Outputs []OutputInput `json:"outputs,omitempty"`
+ Examples []ExampleInput `json:"examples,omitempty"`
+ PublicContentLeakage []PublicContentInput `json:"public_content_leakage,omitempty"`
+ Diagnostics []facts.DiagnosticFact `json:"diagnostics,omitempty"`
}
type ChangedSummary struct {
- Commands int `json:"commands,omitempty"`
- Skills int `json:"skills,omitempty"`
- SkillQuality int `json:"skill_quality,omitempty"`
- Errors int `json:"errors,omitempty"`
- Outputs int `json:"outputs,omitempty"`
- Examples int `json:"examples,omitempty"`
- Domains []string `json:"domains,omitempty"`
- Sources []string `json:"sources,omitempty"`
+ Commands int `json:"commands,omitempty"`
+ Skills int `json:"skills,omitempty"`
+ SkillQuality int `json:"skill_quality,omitempty"`
+ Errors int `json:"errors,omitempty"`
+ Outputs int `json:"outputs,omitempty"`
+ Examples int `json:"examples,omitempty"`
+ PublicContent int `json:"public_content,omitempty"`
+ Domains []string `json:"domains,omitempty"`
+ Sources []string `json:"sources,omitempty"`
}
type RuleSummaryItem struct {
@@ -86,6 +88,22 @@ type ExampleInput struct {
facts.CommandExample
}
+type PublicContentInput struct {
+ FactRef string `json:"fact_ref"`
+ facts.PublicContentFact
+}
+
+func (v InputView) HasReviewableFacts() bool {
+ return len(v.Commands) > 0 ||
+ len(v.Skills) > 0 ||
+ len(v.SkillQuality) > 0 ||
+ len(v.Errors) > 0 ||
+ len(v.Outputs) > 0 ||
+ len(v.Examples) > 0 ||
+ len(v.PublicContentLeakage) > 0 ||
+ len(v.Diagnostics) > 0
+}
+
func BuildInputView(f facts.Facts) InputView {
selected := newInputSelection(f)
selected.addChangedReviewCandidates()
@@ -104,16 +122,17 @@ func BuildInputView(f facts.Facts) InputView {
}
return InputView{
- SchemaVersion: f.SchemaVersion,
- ChangedSummary: changedSummary(f),
- RuleSummary: ruleSummary(f.Diagnostics),
- Commands: selected.commandInputs(),
- Skills: selected.skillInputs(),
- SkillQuality: selected.skillQualityInputs(),
- Errors: selected.errorInputs(),
- Outputs: selected.outputInputs(),
- Examples: selected.exampleInputs(),
- Diagnostics: viewDiagnostics,
+ SchemaVersion: f.SchemaVersion,
+ ChangedSummary: changedSummary(f),
+ RuleSummary: ruleSummary(f.Diagnostics),
+ Commands: selected.commandInputs(),
+ Skills: selected.skillInputs(),
+ SkillQuality: selected.skillQualityInputs(),
+ Errors: selected.errorInputs(),
+ Outputs: selected.outputInputs(),
+ Examples: selected.exampleInputs(),
+ PublicContentLeakage: selected.publicContentInputs(),
+ Diagnostics: viewDiagnostics,
}
}
@@ -138,6 +157,11 @@ func (s *inputSelection) addChangedReviewCandidates() {
s.outputs[i] = true
}
}
+ for i, item := range s.f.PublicContent {
+ if publicContentReviewCandidate(item) {
+ s.publicContent[i] = true
+ }
+ }
}
func commandReviewCandidate(cmd facts.CommandFact) bool {
@@ -157,25 +181,31 @@ func outputReviewCandidate(_ facts.OutputFact) bool {
return false
}
+func publicContentReviewCandidate(item facts.PublicContentFact) bool {
+ return item.Rule == "public_content_semantic_candidate"
+}
+
type inputSelection struct {
- f facts.Facts
- commands []bool
- skills []bool
- skillQuality []bool
- errors []bool
- outputs []bool
- examples []bool
+ f facts.Facts
+ commands []bool
+ skills []bool
+ skillQuality []bool
+ errors []bool
+ outputs []bool
+ examples []bool
+ publicContent []bool
}
func newInputSelection(f facts.Facts) *inputSelection {
return &inputSelection{
- f: f,
- commands: make([]bool, len(f.Commands)),
- skills: make([]bool, len(f.Skills)),
- skillQuality: make([]bool, len(f.SkillQuality)),
- errors: make([]bool, len(f.Errors)),
- outputs: make([]bool, len(f.Outputs)),
- examples: make([]bool, len(f.Examples)),
+ f: f,
+ commands: make([]bool, len(f.Commands)),
+ skills: make([]bool, len(f.Skills)),
+ skillQuality: make([]bool, len(f.SkillQuality)),
+ errors: make([]bool, len(f.Errors)),
+ outputs: make([]bool, len(f.Outputs)),
+ examples: make([]bool, len(f.Examples)),
+ publicContent: make([]bool, len(f.PublicContent)),
}
}
@@ -194,6 +224,8 @@ func (s *inputSelection) diagnosticContext(diag facts.DiagnosticFact) *inputSele
s.addDiagnosticExamples(out, diag)
case diag.Rule == "no_bare_helper_error":
s.addDiagnosticErrors(out, diag)
+ case strings.HasPrefix(diag.Rule, "public_content_"):
+ s.addDiagnosticPublicContent(out, diag)
}
return out
}
@@ -256,6 +288,15 @@ func (s *inputSelection) addDiagnosticExamples(out *inputSelection, diag facts.D
}
}
+func (s *inputSelection) addDiagnosticPublicContent(out *inputSelection, diag facts.DiagnosticFact) {
+ for i, item := range s.f.PublicContent {
+ if diagnosticLocationMatches(diag.File, diag.Line, item.File, item.Line) ||
+ diag.Rule == item.Rule {
+ out.publicContent[i] = true
+ }
+ }
+}
+
func includeDiagnosticInView(diag facts.DiagnosticFact, selected, context *inputSelection) bool {
if diag.Action == report.ActionReject {
return true
@@ -270,6 +311,7 @@ func (s *inputSelection) merge(other *inputSelection) {
mergeSelections(s.errors, other.errors)
mergeSelections(s.outputs, other.outputs)
mergeSelections(s.examples, other.examples)
+ mergeSelections(s.publicContent, other.publicContent)
}
func (s *inputSelection) intersects(other *inputSelection) bool {
@@ -278,7 +320,8 @@ func (s *inputSelection) intersects(other *inputSelection) bool {
selectionsIntersect(s.skillQuality, other.skillQuality) ||
selectionsIntersect(s.errors, other.errors) ||
selectionsIntersect(s.outputs, other.outputs) ||
- selectionsIntersect(s.examples, other.examples)
+ selectionsIntersect(s.examples, other.examples) ||
+ selectionsIntersect(s.publicContent, other.publicContent)
}
func (s *inputSelection) commandInputs() []CommandInput {
@@ -351,6 +394,16 @@ func (s *inputSelection) exampleInputs() []ExampleInput {
return out
}
+func (s *inputSelection) publicContentInputs() []PublicContentInput {
+ out := make([]PublicContentInput, 0, countSelected(s.publicContent))
+ for i, ok := range s.publicContent {
+ if ok {
+ out = append(out, PublicContentInput{FactRef: factRef("public_content", i), PublicContentFact: s.f.PublicContent[i]})
+ }
+ }
+ return out
+}
+
func changedSummary(f facts.Facts) ChangedSummary {
domains := map[string]bool{}
sources := map[string]bool{}
@@ -402,6 +455,10 @@ func changedSummary(f facts.Facts) ChangedSummary {
addNonEmpty(domains, example.Domain)
addNonEmpty(sources, example.Source)
}
+ for _, item := range f.PublicContent {
+ out.PublicContent++
+ addNonEmpty(sources, item.Source)
+ }
out.Domains = sortedViewSetKeys(domains)
out.Sources = sortedViewSetKeys(sources)
return out
@@ -434,7 +491,8 @@ func semanticDiagnosticRule(rule string) bool {
strings.HasPrefix(rule, "default_output") ||
strings.HasPrefix(rule, "skill_") ||
strings.HasPrefix(rule, "example_dry_run") ||
- rule == "no_bare_helper_error"
+ rule == "no_bare_helper_error" ||
+ strings.HasPrefix(rule, "public_content_")
}
func diagnosticCommandMatches(diag facts.DiagnosticFact, values ...string) bool {
diff --git a/internal/qualitygate/semantic/view_test.go b/internal/qualitygate/semantic/view_test.go
index 05200933..0da1c11f 100644
--- a/internal/qualitygate/semantic/view_test.go
+++ b/internal/qualitygate/semantic/view_test.go
@@ -77,6 +77,122 @@ func TestInputViewKeepsChangedReviewCandidatesWithOriginalRefs(t *testing.T) {
}
}
+func TestInputViewIncludesPublicContentLeakage(t *testing.T) {
+ f := facts.Facts{
+ SchemaVersion: 1,
+ PublicContent: []facts.PublicContentFact{{
+ Rule: "public_content_generic_credential",
+ Action: report.ActionReject,
+ File: "docs/public.md",
+ Line: 4,
+ Excerpt: "api_key = ",
+ Message: "generic credential assignment",
+ }},
+ Diagnostics: []facts.DiagnosticFact{{
+ Rule: "public_content_generic_credential",
+ Action: report.ActionReject,
+ File: "docs/public.md",
+ Line: 4,
+ Message: "generic credential assignment",
+ }},
+ }
+
+ view := BuildInputView(f)
+ if len(view.PublicContentLeakage) != 1 {
+ t.Fatalf("public content leakage len = %d, want 1", len(view.PublicContentLeakage))
+ }
+ if got := view.PublicContentLeakage[0].FactRef; got != "facts.public_content[0]" {
+ t.Fatalf("public content fact ref = %q", got)
+ }
+ if len(view.Diagnostics) != 1 {
+ t.Fatalf("diagnostics len = %d, want 1", len(view.Diagnostics))
+ }
+}
+
+func TestInputViewIncludesPublicContentSemanticCandidatesWithoutDiagnostics(t *testing.T) {
+ f := facts.Facts{
+ SchemaVersion: 1,
+ PublicContent: []facts.PublicContentFact{{
+ Rule: "public_content_semantic_candidate",
+ Action: report.ActionWarning,
+ File: "docs/public.md",
+ Line: 1,
+ Source: "file",
+ Excerpt: "public prose that needs semantic review",
+ Message: "public contribution contains text for semantic public content review",
+ }},
+ }
+
+ view := BuildInputView(f)
+ if len(view.PublicContentLeakage) != 1 {
+ t.Fatalf("semantic candidate len = %d, want 1", len(view.PublicContentLeakage))
+ }
+ if got := view.PublicContentLeakage[0].FactRef; got != "facts.public_content[0]" {
+ t.Fatalf("semantic candidate fact ref = %q", got)
+ }
+ if len(view.Diagnostics) != 0 {
+ t.Fatalf("semantic candidate should not require diagnostics, got %#v", view.Diagnostics)
+ }
+}
+
+func TestPromptIncludesSanitizedPublicContentExcerpt(t *testing.T) {
+ scopeText := "pri" + "vate rollout"
+ f := facts.Facts{
+ SchemaVersion: 1,
+ PublicContent: []facts.PublicContentFact{{
+ Rule: "public_content_semantic_candidate",
+ Action: report.ActionWarning,
+ File: "docs/public.md",
+ Line: 1,
+ Source: "file",
+ Excerpt: `semantic signals: pri` + `vate_scope,roadmap_detail; excerpt: "` + scopeText + ` token="`,
+ Message: "public contribution contains text for semantic public content review",
+ }},
+ }
+
+ view := BuildInputView(f)
+ if len(view.PublicContentLeakage) != 1 {
+ t.Fatalf("semantic candidate len = %d, want 1", len(view.PublicContentLeakage))
+ }
+ if got := view.PublicContentLeakage[0].Excerpt; !strings.Contains(got, scopeText) || !strings.Contains(got, "token=") {
+ t.Fatalf("semantic candidate excerpt missing from view: %q", got)
+ }
+
+ messages := BuildPrompt(f)
+ if len(messages) != 2 {
+ t.Fatalf("messages len = %d, want 2", len(messages))
+ }
+ if !strings.Contains(messages[1].Content, scopeText) || !strings.Contains(messages[1].Content, "redacted") {
+ t.Fatalf("prompt missing sanitized public content excerpt: %s", messages[1].Content)
+ }
+ if strings.Contains(messages[1].Content, "real-"+"secret-value") {
+ t.Fatalf("prompt leaked raw sensitive value %q", messages[1].Content)
+ }
+}
+
+func TestInputViewExcludesPublicContentWarningsWithoutSemanticCandidate(t *testing.T) {
+ f := facts.Facts{
+ SchemaVersion: 1,
+ PublicContent: []facts.PublicContentFact{{
+ Rule: "public_content_" + "pri" + "vate_ipv4",
+ Action: report.ActionWarning,
+ File: "docs/network.md",
+ Line: 1,
+ Source: "file",
+ Excerpt: "192.168." + "0.10",
+ Message: "public contribution contains a pri" + "vate-network IP address",
+ }},
+ }
+
+ view := BuildInputView(f)
+ if len(view.PublicContentLeakage) != 0 {
+ t.Fatalf("warning-only public content should not enter semantic view: %#v", view.PublicContentLeakage)
+ }
+ if len(view.Diagnostics) != 0 {
+ t.Fatalf("warning-only public content should not add diagnostics: %#v", view.Diagnostics)
+ }
+}
+
func TestInputViewSummarizesBroadChangedCommandSurface(t *testing.T) {
f := broadChangedFacts(434, 44)
diff --git a/internal/qualitygate/semantic/waiver.go b/internal/qualitygate/semantic/waiver.go
index 971a8432..7d914d2c 100644
--- a/internal/qualitygate/semantic/waiver.go
+++ b/internal/qualitygate/semantic/waiver.go
@@ -138,6 +138,10 @@ func parseWaiver(parts []string, lineNo int) (Waiver, error) {
if item.SourceFile == "" || item.Line == 0 {
return Waiver{}, fmt.Errorf("%s:%d: %s waiver requires source_file and line", waiverPath, lineNo, item.FactKind)
}
+ case "public_content":
+ if item.SourceFile == "" || item.Line == 0 || item.CommandPath != "" {
+ return Waiver{}, fmt.Errorf("%s:%d: public_content waiver requires source_file and line only", waiverPath, lineNo)
+ }
case "command", "output":
if item.CommandPath == "" {
return Waiver{}, fmt.Errorf("%s:%d: %s waiver requires command_path", waiverPath, lineNo, item.FactKind)
diff --git a/internal/qualitygate/semantic/waiver_test.go b/internal/qualitygate/semantic/waiver_test.go
index 601315f5..9ac12066 100644
--- a/internal/qualitygate/semantic/waiver_test.go
+++ b/internal/qualitygate/semantic/waiver_test.go
@@ -21,24 +21,27 @@ func TestLoadWaivers(t *testing.T) {
writeSemanticFile(t, repo, "waivers.txt", "# waiver_id\tcategory\tfact_kind\tsource_file\tline\tcommand_path\towner\treason\tadded_at\texpires_at\n"+
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/SKILL.md\t30\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n"+
- "wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/references/move.md\t12\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n")
+ "wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/references/move.md\t12\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n"+
+ "public-doc-202606\tpublic_content_leakage\tpublic_content\tdocs/public.md\t4\t\tsecurity-owner\treviewed false positive\t2026-06-08\t2026-07-15\n")
w, diags, err = LoadWaivers(repo, now)
if err != nil {
t.Fatalf("LoadWaivers() error = %v", err)
}
- if len(diags) != 0 || len(w.Items) != 2 {
+ if len(diags) != 0 || len(w.Items) != 3 {
t.Fatalf("LoadWaivers() = %#v %#v", w, diags)
}
for name, body := range map[string]string{
- "bad columns": "one\ttoo-few\n",
- "bad id": "BAD\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
- "bad fact kind": "id1\terror_hint\tskill_quality\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
- "missing owner": "id1\terror_hint\terror\tcmd/root.go\t1\t\t\tr\t2026-06-08\t2026-07-15\n",
- "missing line": "id1\terror_hint\terror\tcmd/root.go\t\t\to\tr\t2026-06-08\t2026-07-15\n",
- "missing command": "id1\tdefault_output\toutput\t\t\t\to\tr\t2026-06-08\t2026-07-15\n",
- "bad source path": "id1\terror_hint\terror\t../cmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
- "bad date format": "id1\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t20260608\t2026-07-15\n",
+ "bad columns": "one\ttoo-few\n",
+ "bad id": "BAD\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
+ "bad fact kind": "id1\terror_hint\tskill_quality\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
+ "missing owner": "id1\terror_hint\terror\tcmd/root.go\t1\t\t\tr\t2026-06-08\t2026-07-15\n",
+ "missing line": "id1\terror_hint\terror\tcmd/root.go\t\t\to\tr\t2026-06-08\t2026-07-15\n",
+ "missing command": "id1\tdefault_output\toutput\t\t\t\to\tr\t2026-06-08\t2026-07-15\n",
+ "public content missing line": "id1\tpublic_content_leakage\tpublic_content\tdocs/public.md\t\t\to\tr\t2026-06-08\t2026-07-15\n",
+ "public content command selector": "id1\tpublic_content_leakage\tpublic_content\t\t\tcmd/foo\to\tr\t2026-06-08\t2026-07-15\n",
+ "bad source path": "id1\terror_hint\terror\t../cmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
+ "bad date format": "id1\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t20260608\t2026-07-15\n",
} {
t.Run(name, func(t *testing.T) {
writeSemanticFile(t, repo, "waivers.txt", body)
diff --git a/internal/registry/scope_priorities.json b/internal/registry/scope_priorities.json
index 99c3c660..916d81df 100644
--- a/internal/registry/scope_priorities.json
+++ b/internal/registry/scope_priorities.json
@@ -5609,6 +5609,21 @@
"final_score": "80.0587",
"recommend": "false"
},
+ {
+ "scope_name": "im:chat.nickname:read",
+ "final_score": "88.0587",
+ "recommend": "true"
+ },
+ {
+ "scope_name": "im:chat.nickname:write",
+ "final_score": "79.5982",
+ "recommend": "true"
+ },
+ {
+ "scope_name": "im:chat.user_setting:write",
+ "final_score": "83.6587",
+ "recommend": "true"
+ },
{
"scope_name": "im:chat.user_setting:read",
"final_score": "88.0587",
diff --git a/package.json b/package.json
index b76c50ad..61342a1a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
- "version": "1.0.57",
+ "version": "1.0.59",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"
diff --git a/scripts/ci-quality-summary-publish.js b/scripts/ci-quality-summary-publish.js
index 900fe0f3..07e8d8fb 100644
--- a/scripts/ci-quality-summary-publish.js
+++ b/scripts/ci-quality-summary-publish.js
@@ -45,6 +45,10 @@ async function publishTargetStillCurrent(github, context, core, target, phase =
repo: context.repo.repo,
pull_number: target.pr,
});
+ if (pr.state !== "open") {
+ core.notice(`PR quality summary skipped: PR is no longer open before ${phase}`);
+ return false;
+ }
if (pr.head.sha !== target.headSha) {
core.notice(`PR quality summary skipped: PR head changed before ${phase}`);
return false;
diff --git a/scripts/ci-quality-summary-publish.test.js b/scripts/ci-quality-summary-publish.test.js
index fb20fdb0..fb2c9b8c 100644
--- a/scripts/ci-quality-summary-publish.test.js
+++ b/scripts/ci-quality-summary-publish.test.js
@@ -152,6 +152,25 @@ describe("ci-quality-summary-publish", () => {
});
});
+ it("does not publish a summary when the PR closes before comment creation", async () => {
+ await withPublishTempDir(async ({ calls }) => {
+ await publish({
+ github: fakeGithub(calls, {
+ jobs: [{ name: "unit-test", conclusion: "failure", html_url: "https://github.example/jobs/1" }],
+ pullResponses: [
+ currentPullResponse(),
+ currentPullResponse({ state: "closed" }),
+ ],
+ }),
+ context: workflowRunContext({ conclusion: "failure" }),
+ core: silentCore(calls),
+ });
+
+ assert.equal(calls.comments.length, 0);
+ assert.match(calls.notices.join("\n"), /PR is no longer open/);
+ });
+ });
+
it("does not delete an existing summary when the PR base changes before cleanup", async () => {
await withPublishTempDir(async ({ calls }) => {
await publish({
@@ -338,6 +357,7 @@ function fakeGithub(calls, options = {}) {
function currentPullResponse(overrides = {}) {
return {
data: {
+ state: overrides.state || "open",
head: { sha: overrides.headSha || process.env.CI_QUALITY_SUMMARY_HEAD_SHA },
base: {
sha: overrides.baseSha || process.env.CI_QUALITY_SUMMARY_BASE_SHA,
diff --git a/scripts/ci-workflow.test.sh b/scripts/ci-workflow.test.sh
index d5bbc2a2..b8bdcd98 100644
--- a/scripts/ci-workflow.test.sh
+++ b/scripts/ci-workflow.test.sh
@@ -5,26 +5,42 @@
set -euo pipefail
workflow=".github/workflows/ci.yml"
+job_section() {
+ local job="$1"
+ awk -v job="$job" '
+ $0 == " " job ":" { in_job = 1; print; next }
+ in_job && /^ [A-Za-z0-9_-]+:/ { exit }
+ in_job { print }
+ ' "$workflow"
+}
workflow_permissions="$(awk '
/^permissions:/ { in_permissions = 1; print; next }
in_permissions && /^[^[:space:]]/ { exit }
in_permissions { print }
' "$workflow")"
+fast_gate_section="$(job_section fast-gate)"
+unit_test_section="$(job_section unit-test)"
lint_section="$(awk '
/^ lint:/ { in_job = 1 }
in_job { print }
- /^ deterministic-gate:/ { exit }
+ /^ script-test:/ { exit }
' "$workflow")"
+script_test_section="$(job_section script-test)"
deterministic_section="$(awk '
/^ deterministic-gate:/ { in_job = 1 }
in_job { print }
/^ coverage:/ { exit }
' "$workflow")"
+coverage_job_section="$(job_section coverage)"
+deadcode_section="$(job_section deadcode)"
+dry_run_section="$(job_section e2e-dry-run)"
section="$(awk '
/^ e2e-live:/ { in_job = 1 }
in_job { print }
/^ security:/ { exit }
' "$workflow")"
+security_section="$(job_section security)"
+license_header_section="$(job_section license-header)"
results_section="$(awk '
/^ results:/ { in_job = 1 }
in_job { print }
@@ -98,13 +114,94 @@ if ! grep -Fq "make quality-gate" <<<"$deterministic_section"; then
exit 1
fi
+if ! grep -Fq "Write public content metadata" <<<"$deterministic_section"; then
+ echo "deterministic-gate should write PR title/body metadata before quality-gate"
+ exit 1
+fi
+
+if ! grep -Fq "types: [opened, synchronize, reopened, edited]" "$workflow"; then
+ echo "CI pull_request trigger should include edited so PR title/body changes are rescanned"
+ exit 1
+fi
+
+if ! grep -Fq "script-test:" <<<"$script_test_section"; then
+ echo "CI should run make script-test so workflow and publisher contract tests are not local-only"
+ exit 1
+fi
+
+if ! grep -Fq "make script-test" <<<"$script_test_section"; then
+ echo "script-test job should invoke make script-test"
+ exit 1
+fi
+
+if ! grep -Fq "actions/setup-node" <<<"$script_test_section"; then
+ echo "script-test job should install Node for JavaScript workflow tests"
+ exit 1
+fi
+
+if grep -Fq '${{ secrets.' <<<"$script_test_section"; then
+ echo "script-test must not reference secrets"
+ exit 1
+fi
+
+if grep -Fq "metadata-gate:" "$workflow"; then
+ echo "metadata-gate should not run alongside deterministic-gate because both would upload the same facts artifact"
+ exit 1
+fi
+
+if grep -Fq "github.event.action != 'edited'" <<<"$fast_gate_section"; then
+ echo "fast-gate must run on pull_request edited events so title/body edits cannot replace failed CI with a light success"
+ exit 1
+fi
+
+for full_job in \
+ "$unit_test_section" \
+ "$lint_section" \
+ "$script_test_section" \
+ "$deterministic_section" \
+ "$coverage_job_section" \
+ "$dry_run_section" \
+ "$security_section"; do
+ if grep -Fq "github.event.action != 'edited'" <<<"$full_job"; then
+ echo "full CI jobs must run on pull_request edited events; do not skip title/body-only edits"
+ exit 1
+ fi
+done
+
+for pull_request_job in "$deadcode_section" "$license_header_section"; do
+ if grep -Fq "github.event.action != 'edited'" <<<"$pull_request_job"; then
+ echo "pull_request-only CI jobs must run on edited events"
+ exit 1
+ fi
+done
+
+if grep -Fq '${{ secrets.' <<<"$deterministic_section"; then
+ echo "deterministic-gate must not reference secrets"
+ exit 1
+fi
+
+if ! grep -Fq "PUBLIC_CONTENT_METADATA=" <<<"$deterministic_section"; then
+ echo "deterministic-gate should pass public content metadata into make quality-gate"
+ exit 1
+fi
+
+if ! grep -Fq "PR_BRANCH:" <<<"$deterministic_section"; then
+ echo "deterministic-gate should pass the pull request branch into public content metadata"
+ exit 1
+fi
+
if ! grep -Fq "name: quality-gate-facts-\${{ github.event.pull_request.base.sha }}-\${{ github.event.pull_request.head.sha }}" <<<"$deterministic_section"; then
echo "deterministic-gate should upload base/head-bound quality-gate-facts for semantic review"
exit 1
fi
-if ! grep -Fq "needs: [unit-test, lint, deterministic-gate]" "$workflow"; then
- echo "E2E jobs should wait for deterministic-gate"
+if ! grep -Fq "needs: [unit-test, lint, script-test, deterministic-gate]" "$workflow"; then
+ echo "E2E jobs should wait for script-test and deterministic-gate"
+ exit 1
+fi
+
+if ! grep -Fq "script-test" <<<"$results_section"; then
+ echo "results job should include script-test"
exit 1
fi
@@ -210,6 +307,11 @@ if ! grep -Fq "go run ./internal/qualitygate/cmd/manifest-export" <<<"$make_outp
exit 1
fi
+if ! grep -Fq -- "--public-content-metadata .tmp/quality-gate/public-content-metadata.json" <<<"$make_output"; then
+ echo "quality-gate check should consume public content metadata"
+ exit 1
+fi
+
if ! grep -Fq -- "--manifest .tmp/quality-gate/command-manifest.json" <<<"$make_output" ||
! grep -Fq -- "--command-index .tmp/quality-gate/command-index.json" <<<"$make_output"; then
echo "quality-gate check should consume both exported command snapshots"
diff --git a/scripts/semantic-review-publish.js b/scripts/semantic-review-publish.js
index 61a57592..afae3132 100644
--- a/scripts/semantic-review-publish.js
+++ b/scripts/semantic-review-publish.js
@@ -175,7 +175,7 @@ function inlineCode(value) {
}
function parseEvidenceRef(ref) {
- const match = /^facts\.(commands|skills|errors|outputs)\[(\d+)\]$/.exec(String(ref || ""));
+ const match = /^facts\.(commands|skills|errors|outputs|public_content)\[(\d+)\]$/.exec(String(ref || ""));
if (!match) {
return null;
}
@@ -230,6 +230,20 @@ function evidenceLocation(facts, ref) {
return { kind: parsed.kind, command: item.path, label: item.path };
}
return null;
+ case "public_content":
+ if (item.file && Number.isInteger(item.line) && item.line > 0) {
+ const label = `${item.file}:${item.line}`;
+ if (item.file === "branch" || item.file === "pull_request_metadata" || String(item.file).startsWith("commit:")) {
+ return { kind: parsed.kind, label };
+ }
+ return {
+ kind: parsed.kind,
+ path: item.file,
+ line: item.line,
+ label,
+ };
+ }
+ return null;
default:
return null;
}
@@ -845,6 +859,10 @@ async function publishTargetStillCurrent(github, context, core, target, phase =
repo: context.repo.repo,
pull_number: target.pr,
});
+ if (pr.state !== "open") {
+ core.notice(`semantic review skipped: PR is no longer open before ${phase}`);
+ return false;
+ }
if (pr.head.sha !== target.headSha) {
core.notice(`semantic review skipped: PR head changed before ${phase}`);
return false;
diff --git a/scripts/semantic-review-publish.test.js b/scripts/semantic-review-publish.test.js
index d0a9b635..987cc9ac 100644
--- a/scripts/semantic-review-publish.test.js
+++ b/scripts/semantic-review-publish.test.js
@@ -202,6 +202,100 @@ describe("semantic-review-publish", () => {
assert.equal(selectInlineTarget({ evidence: ["facts.errors[0]"] }, facts, changedLineIndex), null);
});
+ it("maps public content evidence to changed files but not virtual metadata", () => {
+ const restrictedScope = "pri" + "vate";
+ const facts = {
+ public_content: [
+ {
+ rule: "public_content_semantic_candidate",
+ action: "WARNING",
+ file: "docs/public-roadmap.md",
+ line: 4,
+ source: "file",
+ },
+ {
+ rule: "public_content_semantic_candidate",
+ action: "WARNING",
+ file: "pull_request_metadata",
+ line: 1,
+ source: "metadata",
+ },
+ {
+ rule: "public_content_automation_branch",
+ action: "WARNING",
+ file: "branch",
+ line: 1,
+ source: "branch",
+ },
+ {
+ rule: "public_content_change_id_trailer",
+ action: "REJECT",
+ file: "commit:1234abc",
+ line: 3,
+ source: "commit",
+ },
+ ],
+ };
+ const changedLineIndex = buildChangedLineIndex([{
+ filename: "docs/public-roadmap.md",
+ patch: [
+ "@@ -3,2 +3,3 @@",
+ " context",
+ "+Specific " + restrictedScope + " roadmap detail",
+ ].join("\n"),
+ }]);
+
+ assert.deepEqual(
+ selectInlineTarget({ evidence: ["facts.public_content[0]"] }, facts, changedLineIndex),
+ { path: "docs/public-roadmap.md", line: 4 },
+ );
+ assert.equal(selectInlineTarget({ evidence: ["facts.public_content[1]"] }, facts, changedLineIndex), null);
+ assert.equal(selectInlineTarget({ evidence: ["facts.public_content[2]"] }, facts, changedLineIndex), null);
+ assert.equal(selectInlineTarget({ evidence: ["facts.public_content[3]"] }, facts, changedLineIndex), null);
+
+ const markdown = buildSummaryMarkdown({
+ block_mode: true,
+ blockers: [{
+ category: "public_content_leakage",
+ severity: "major",
+ review_action: "must_fix",
+ evidence: ["facts.public_content[1]"],
+ fingerprint: "public-content-metadata",
+ message: "PR metadata contains " + restrictedScope + " rollout detail",
+ suggested_action: "Move " + restrictedScope + " detail to an internal channel.",
+ }],
+ warnings: [],
+ }, facts);
+ assert.match(markdown, /pull_request_metadata:1/);
+
+ const virtualMarkdown = buildSummaryMarkdown({
+ block_mode: true,
+ blockers: [
+ {
+ category: "public_content_leakage",
+ severity: "major",
+ review_action: "must_fix",
+ evidence: ["facts.public_content[2]"],
+ fingerprint: "public-content-branch",
+ message: "Branch name looks automation-owned.",
+ suggested_action: "Use a maintainer-owned public branch name.",
+ },
+ {
+ category: "public_content_leakage",
+ severity: "major",
+ review_action: "must_fix",
+ evidence: ["facts.public_content[3]"],
+ fingerprint: "public-content-commit",
+ message: "Commit trailer contains " + restrictedScope + " review metadata.",
+ suggested_action: "Remove " + restrictedScope + " review metadata from commits.",
+ },
+ ],
+ warnings: [],
+ }, facts);
+ assert.match(virtualMarkdown, /branch:1/);
+ assert.match(virtualMarkdown, /commit:1234abc:3/);
+ });
+
it("builds finding markers from stable fingerprints and evidence identity", () => {
const factsA = {
skills: [{
@@ -615,6 +709,35 @@ describe("semantic-review-publish", () => {
});
});
+ it("skips publishing when the PR closes after verification", async () => {
+ await withPublishTempDir(async ({ calls }) => {
+ fs.writeFileSync("decision.json", JSON.stringify({
+ block_mode: true,
+ blockers: [],
+ warnings: [],
+ }), "utf8");
+
+ await publish({
+ github: fakeGithub(calls, {
+ currentPullRequest: {
+ state: "closed",
+ head: { sha: "0123456789abcdef0123456789abcdef01234567" },
+ base: {
+ sha: "fedcba9876543210fedcba9876543210fedcba98",
+ repo: { id: 123 },
+ },
+ },
+ }),
+ context: workflowRunContext(),
+ core: silentCore(calls),
+ });
+
+ assert.equal(calls.checks.length, 0);
+ assert.equal(calls.comments.length, 0);
+ assert.match(calls.notices[0], /PR is no longer open before publishing/);
+ });
+ });
+
it("rejects publishing when the PR base repo changed after verification", async () => {
await withPublishTempDir(async ({ calls }) => {
fs.writeFileSync("decision.json", JSON.stringify({
@@ -2223,8 +2346,8 @@ function fakeGithub(calls, options = {}) {
},
},
pulls: {
- get: async () => ({
- data: Array.isArray(options.currentPullRequests)
+ get: async () => {
+ const pull = Array.isArray(options.currentPullRequests)
? options.currentPullRequests[Math.min(pullGetCount++, options.currentPullRequests.length - 1)]
: options.currentPullRequest || {
head: { sha: process.env.SEMANTIC_REVIEW_HEAD_SHA },
@@ -2232,8 +2355,9 @@ function fakeGithub(calls, options = {}) {
sha: process.env.SEMANTIC_REVIEW_BASE_SHA,
repo: { id: 123 },
},
- },
- }),
+ };
+ return { data: { state: "open", ...pull } };
+ },
listFiles() {},
listReviewComments() {},
createReviewComment: async (args) => {
diff --git a/scripts/semantic-review-verify-artifact.js b/scripts/semantic-review-verify-artifact.js
index d8831a0c..309c812d 100644
--- a/scripts/semantic-review-verify-artifact.js
+++ b/scripts/semantic-review-verify-artifact.js
@@ -229,6 +229,36 @@ function requireSafePath(value, path) {
return file;
}
+function requirePublicContentFile(value, path) {
+ const file = requireString(value, path);
+ if (file === "branch" || file === "pull_request_metadata" || /^commit:[0-9a-f]{7,40}$/.test(file)) {
+ return file;
+ }
+ if (file.startsWith("commit:")) {
+ throw new Error(`facts JSON ${path} must be a valid public content location`);
+ }
+ requireSafePath(file, path);
+ if (
+ file === "" ||
+ file === "." ||
+ file.startsWith("./") ||
+ file.includes("\\") ||
+ file.includes("\0") ||
+ file.split("/").includes(".git") ||
+ /^[A-Za-z][A-Za-z0-9+.-]*:/.test(file)
+ ) {
+ throw new Error(`facts JSON ${path} must be a repository-relative path`);
+ }
+ return file;
+}
+
+function requirePositiveLine(value, path) {
+ requireLine(value, path);
+ if (value === 0) {
+ throw new Error(`facts JSON ${path} must be a positive line number`);
+ }
+}
+
function requireStringArray(value, path, { optional = false } = {}) {
if (value === undefined || value === null) {
if (optional) {
@@ -421,6 +451,20 @@ function verifyFactsJSON(data) {
for (const [i, value] of requireArray(facts, "examples").entries()) {
verifyCommandExample(value, `examples[${i}]`);
}
+ for (const [i, value] of requireArray(facts, "public_content").entries()) {
+ const item = requireObject(value, `public_content[${i}]`);
+ requireString(item.rule, `public_content[${i}].rule`);
+ const action = requireString(item.action, `public_content[${i}].action`);
+ if (!VALID_ACTIONS.has(action)) {
+ throw new Error(`facts JSON public_content[${i}].action is invalid`);
+ }
+ requirePublicContentFile(item.file, `public_content[${i}].file`);
+ requirePositiveLine(item.line, `public_content[${i}].line`);
+ requireString(item.source, `public_content[${i}].source`, { optional: true });
+ requireString(item.excerpt, `public_content[${i}].excerpt`, { optional: true });
+ requireString(item.message, `public_content[${i}].message`, { optional: true });
+ requireString(item.suggestion, `public_content[${i}].suggestion`, { optional: true });
+ }
for (const [i, value] of requireArray(facts, "diagnostics").entries()) {
const item = requireObject(value, `diagnostics[${i}]`);
requireString(item.rule, `diagnostics[${i}].rule`);
diff --git a/scripts/semantic-review-verify-artifact.test.js b/scripts/semantic-review-verify-artifact.test.js
index 91e6413a..87edcf2e 100644
--- a/scripts/semantic-review-verify-artifact.test.js
+++ b/scripts/semantic-review-verify-artifact.test.js
@@ -67,7 +67,43 @@ describe("verifyZipEntries", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-review-zip-"));
const zipPath = path.join(dir, "facts.zip");
const outPath = path.join(dir, "facts.json");
- const facts = Buffer.from('{"schema_version":1}\n');
+ const restrictedScope = "pri" + "vate";
+ const facts = Buffer.from(JSON.stringify({
+ schema_version: 1,
+ public_content: [
+ {
+ rule: "public_content_semantic_candidate",
+ action: "WARNING",
+ file: "pull_request_metadata",
+ line: 1,
+ source: "metadata",
+ excerpt: "public release notes mention an internal rollout plan",
+ message: "public contribution may contain sensitive implementation detail",
+ suggestion: "move internal detail to " + restrictedScope + " discussion",
+ },
+ {
+ rule: "public_content_change_id_trailer",
+ action: "REJECT",
+ file: "commit:1234abc",
+ line: 3,
+ source: "commit",
+ },
+ {
+ rule: "public_content_automation_branch",
+ action: "WARNING",
+ file: "branch",
+ line: 1,
+ source: "branch",
+ },
+ {
+ rule: "public_content_" + "pri" + "vate_ipv4",
+ action: "WARNING",
+ file: "docs/public-network.md",
+ line: 7,
+ source: "file",
+ },
+ ],
+ }) + "\n");
const zip = makeZip([{ fileName: "facts.json", data: facts, mode: 0o100644 }]);
fs.writeFileSync(zipPath, zip);
@@ -103,6 +139,19 @@ describe("verifyZipEntries", () => {
["bad-error-path", Buffer.from('{"schema_version":1,"errors":[{"file":"../x.go","line":1,"boundary":true,"uses_structured_error":false,"has_hint":false,"hint_action_count":0,"required_hint":true,"retryable":false}]}'), /errors\[0\]\.file/],
["bad-example-dry-run", Buffer.from('{"schema_version":1,"examples":[{"raw":"lark-cli docs +fetch","source_file":"skills/lark-doc/SKILL.md","line":3,"executable":true,"dry_run":{"method":"GET","url":"/open-apis/docx","query":{"page_size":["20",1]}}}]}'), /examples\[0\]\.dry_run\.query\.page_size\[1\]/],
["bad-output-field", Buffer.from(JSON.stringify({ schema_version: 1, outputs: [{ command: "drive files list", fields: ["ok", "x".repeat(9000)] }] })), /outputs\[0\]\.fields\[1\]/],
+ ["non-array-public-content", Buffer.from('{"schema_version":1,"public_content":{}}'), /public_content must be an array/],
+ ["bad-public-content-item", Buffer.from('{"schema_version":1,"public_content":["not-object"]}'), /public_content\[0\]/],
+ ["bad-public-content-action", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"BLOCK","file":"pull_request_metadata","line":1}]}'), /public_content\[0\]\.action/],
+ ["bad-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"../x","line":1}]}'), /public_content\[0\]\.file/],
+ ["dot-slash-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"./foo","line":1}]}'), /public_content\[0\]\.file/],
+ ["empty-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"","line":1}]}'), /public_content\[0\]\.file/],
+ ["dot-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".","line":1}]}'), /public_content\[0\]\.file/],
+ ["url-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"https://example.invalid/x","line":1}]}'), /public_content\[0\]\.file/],
+ ["dotgit-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".git/config","line":1}]}'), /public_content\[0\]\.file/],
+ ["windows-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"C:\\\\tmp\\\\x","line":1}]}'), /public_content\[0\]\.file/],
+ ["bad-public-content-commit-ref", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_change_id_trailer","action":"REJECT","file":"commit:notasha","line":1}]}'), /public_content\[0\]\.file/],
+ ["bad-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":"1"}]}'), /public_content\[0\]\.line/],
+ ["zero-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":0}]}'), /public_content\[0\]\.line/],
["bad-diagnostic-action", Buffer.from('{"schema_version":1,"diagnostics":[{"rule":"r","action":"BLOCK","file":"x.go","line":1,"message":"m"}]}'), /diagnostics.*action/],
["long-message", Buffer.from(JSON.stringify({ schema_version: 1, diagnostics: [{ rule: "r", action: "REJECT", file: "x.go", line: 1, message: "x".repeat(9000) }] })), /too long/],
]) {
diff --git a/scripts/semantic-review-workflow.test.sh b/scripts/semantic-review-workflow.test.sh
index 0ce4cc45..9e664ce6 100644
--- a/scripts/semantic-review-workflow.test.sh
+++ b/scripts/semantic-review-workflow.test.sh
@@ -184,6 +184,10 @@ require_in_step "$summary_verify_step" 'eventHeadSha && eventHeadSha.toLowerCase
require_in_step "$summary_verify_step" 'factsArtifactPattern' "PR quality summary should use the base-bound facts artifact name when available"
require_in_step "$summary_verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "PR quality summary must prefer the CI-time artifact base SHA"
require_in_step "$summary_verify_step" 'core.setOutput("artifact_error"' "PR quality summary must expose artifact binding failures"
+require_in_step "$summary_verify_step" 'state: "all"' "PR quality summary fallback must inspect closed PRs before failing"
+require_in_step "$summary_verify_step" 'candidate.state === "open"' "PR quality summary fallback must still prefer open PRs"
+require_in_step "$summary_verify_step" 'workflow_run target PR is no longer open' "PR quality summary must skip stale workflow_run events after PR closure"
+require_in_step "$summary_verify_step" 'pr.state !== "open"' "PR quality summary must skip direct workflow_run PR bindings after PR closure"
require_in_step "$summary_artifact_step" 'factsArtifactName' "PR quality summary artifact step must use the verified facts artifact binding"
require_in_step "$summary_extract_facts_step" 'SEMANTIC_REVIEW_DECISION_OUT' "PR quality summary artifact verifier must write an infrastructure decision on verifier failure"
@@ -212,7 +216,12 @@ require_in_step "$verify_step" 'runPRs.length > 1' "semantic-review must fail cl
require_in_step "$verify_step" 'listPullRequestsAssociatedWithCommit' "semantic-review must resolve fork workflow_run PRs when pull_requests is empty"
require_in_step "$verify_step" 'commit_sha: targetHeadSha' "semantic-review fallback must resolve PRs by the workflow_run PR head SHA"
require_in_step "$verify_step" 'github.rest.pulls.list' "semantic-review must have a pull-list fallback when commit association is empty"
-require_in_step "$verify_step" 'candidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
+require_in_step "$verify_step" 'openCandidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
+require_in_step "$verify_step" 'state: "all"' "semantic-review fallback must inspect closed PRs before failing"
+require_in_step "$verify_step" 'candidate.state === "open"' "semantic-review fallback must still prefer open PRs"
+require_in_step "$verify_step" 'workflow_run target PR is no longer open' "semantic-review must skip stale workflow_run events after PR closure"
+require_in_step "$verify_step" 'pr.state !== "open"' "semantic-review must skip direct workflow_run PR bindings after PR closure"
+require_in_step "$verify_step" '!pr.head.repo' "semantic-review must skip unavailable PR head repositories before reading owner/repo"
require_in_step "$verify_step" 'pr.head.sha !== targetHeadSha' "semantic-review must skip stale PR heads"
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR base metadata"
require_in_step "$verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "semantic-review must prefer the CI-time artifact base SHA"
@@ -260,6 +269,7 @@ require_in_step "$semantic_step" 'args+=(--waivers-file' "same-repo PR head waiv
require_in_step "$precheckout_step" 'SEMANTIC_REVIEW_BASE_SHA' "pre-checkout failure publisher must receive verified base SHA"
require_in_step "$precheckout_step" 'SEMANTIC_REVIEW_RUN_ID' "pre-checkout failure publisher must receive verified run id"
require_in_step "$precheckout_step" 'github.rest.pulls.get' "pre-checkout failure publisher must recheck PR target before writing"
+require_in_step "$precheckout_step" 'pull.state !== "open"' "pre-checkout failure publisher must skip closed PRs before writing"
require_in_step "$precheckout_step" 'pull.head.sha !== headSha' "pre-checkout failure publisher must skip stale PR heads"
require_in_step "$precheckout_step" 'pull.base.sha !== baseSha' "pre-checkout failure publisher must skip stale PR bases"
diff --git a/shortcuts/calendar/calendar_meeting.go b/shortcuts/calendar/calendar_meeting.go
new file mode 100644
index 00000000..e5e76ed8
--- /dev/null
+++ b/shortcuts/calendar/calendar_meeting.go
@@ -0,0 +1,215 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+//
+// calendar +meeting — get meeting info for calendar events via mget_instance_relation_info
+
+package calendar
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/output"
+ "github.com/larksuite/cli/internal/validate"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+const meetingLogPrefix = "[calendar +meeting]"
+
+// mgetInstanceRelationRequestBody is the request body for mget_instance_relation_info API.
+type mgetInstanceRelationRequestBody struct {
+ InstanceIDs []string `json:"instance_ids"`
+ NeedMeetingInstanceIDs bool `json:"need_meeting_instance_ids"`
+ NeedMeetingNotes bool `json:"need_meeting_notes"`
+ NeedAIMeetingNotes bool `json:"need_ai_meeting_notes"`
+}
+
+// meetingInfoItem represents a single event's meeting info in the output.
+type meetingInfoItem struct {
+ EventID string `json:"event_id"`
+ MeetingID string `json:"meeting_id,omitempty"`
+ MeetingNote string `json:"meeting_note,omitempty"`
+ Error string `json:"error,omitempty"`
+ Hint string `json:"hint,omitempty"`
+}
+
+// translateFailMsg converts API fail_msg to a user-friendly error message.
+func translateFailMsg(failMsg string) string {
+ switch failMsg {
+ case "No Permission":
+ return "no read permission for this calendar event (not a participant of the event)"
+ case "Not Found":
+ return "event not found on the specified calendar (event ID may be incorrect or does not belong to this calendar)"
+ default:
+ return failMsg
+ }
+}
+
+// fetchEventMeetingInfo queries mget_instance_relation_info for a single event instance.
+func fetchEventMeetingInfo(ctx context.Context, runtime *common.RuntimeContext, instanceID, calendarID string) *meetingInfoItem {
+ body := &mgetInstanceRelationRequestBody{
+ InstanceIDs: []string{instanceID},
+ NeedMeetingInstanceIDs: true,
+ NeedMeetingNotes: true,
+ NeedAIMeetingNotes: false,
+ }
+
+ data, err := runtime.CallAPITyped("POST",
+ fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", validate.EncodePathSegment(calendarID)),
+ nil, body)
+ if err != nil {
+ msg := unwrapCalendarAPIError(err)
+ if msg == "" {
+ msg = err.Error()
+ }
+ return &meetingInfoItem{EventID: instanceID, Error: msg}
+ }
+
+ // Check for failed instance IDs first
+ if failedIDs, _ := data["failed_instance_ids"].([]any); len(failedIDs) > 0 {
+ for _, raw := range failedIDs {
+ if failInfo, ok := raw.(map[string]any); ok {
+ if failID, _ := failInfo["instance_id"].(string); failID == instanceID {
+ failMsg, _ := failInfo["fail_msg"].(string)
+ return &meetingInfoItem{EventID: instanceID, Error: translateFailMsg(failMsg)}
+ }
+ }
+ }
+ }
+
+ infos, _ := data["instance_relation_infos"].([]any)
+ if len(infos) == 0 {
+ return &meetingInfoItem{EventID: instanceID, Error: "no event relation info found"}
+ }
+
+ info, _ := infos[0].(map[string]any)
+ result := &meetingInfoItem{EventID: instanceID}
+
+ // Extract meeting_id (return first if multiple) — API returns string
+ if rawIDs, _ := info["meeting_instance_ids"].([]any); len(rawIDs) > 0 {
+ if id, ok := rawIDs[0].(string); ok && id != "" {
+ result.MeetingID = id
+ }
+ }
+
+ // Extract meeting_note (return first if multiple)
+ if notes, _ := info["meeting_notes"].([]any); len(notes) > 0 {
+ if note, ok := notes[0].(string); ok && note != "" {
+ result.MeetingNote = note
+ }
+ }
+
+ // Add hints for empty resources (independent checks)
+ var emptyFields []string
+ if result.MeetingID == "" {
+ emptyFields = append(emptyFields, "meeting_id")
+ }
+ if result.MeetingNote == "" {
+ emptyFields = append(emptyFields, "meeting_note")
+ }
+ if len(emptyFields) > 0 {
+ result.Hint = fmt.Sprintf("%s not found for this event", strings.Join(emptyFields, ", "))
+ }
+
+ return result
+}
+
+// CalendarMeeting gets meeting info for calendar events.
+var CalendarMeeting = common.Shortcut{
+ Service: "calendar",
+ Command: "+meeting",
+ Description: "Get meeting info for calendar events (meeting_id, meeting_note)",
+ Risk: "read",
+ Scopes: []string{"calendar:calendar.event:read"},
+ AuthTypes: []string{"user"},
+ HasFormat: true,
+ Flags: []common.Flag{
+ {Name: "event-ids", Desc: "calendar event instance IDs, comma-separated for batch", Required: true},
+ {Name: "calendar-id", Desc: "calendar ID (default: primary)"},
+ },
+ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ if err := rejectCalendarAutoBotFallback(runtime); err != nil {
+ return err
+ }
+ ids := common.SplitCSV(runtime.Str("event-ids"))
+ const maxBatchSize = 50
+ if len(ids) > maxBatchSize {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--event-ids: too many IDs (%d), maximum is %d", len(ids), maxBatchSize).WithParam("--event-ids")
+ }
+ return nil
+ },
+ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
+ calendarID := runtime.Str("calendar-id")
+ if calendarID == "" {
+ calendarID = ""
+ }
+ return common.NewDryRunAPI().
+ POST(fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID)).
+ Set("event_ids", common.SplitCSV(runtime.Str("event-ids"))).
+ Set("calendar_id", calendarID)
+ },
+ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ errOut := runtime.IO().ErrOut
+ instanceIDs := common.SplitCSV(runtime.Str("event-ids"))
+ calendarID := strings.TrimSpace(runtime.Str("calendar-id"))
+ if calendarID == "" {
+ calendarID = PrimaryCalendarIDStr
+ }
+
+ results := make([]*meetingInfoItem, 0, len(instanceIDs))
+ fmt.Fprintf(errOut, "%s querying %d event_id(s)\n", meetingLogPrefix, len(instanceIDs))
+ for _, id := range instanceIDs {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ fmt.Fprintf(errOut, "%s querying event_id=%s ...\n", meetingLogPrefix, id)
+ results = append(results, fetchEventMeetingInfo(ctx, runtime, id, calendarID))
+ }
+
+ successCount := 0
+ for _, r := range results {
+ if r.Error == "" {
+ successCount++
+ }
+ }
+ fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", meetingLogPrefix, len(results), successCount, len(results)-successCount)
+
+ if successCount == 0 && len(results) > 0 {
+ return runtime.OutPartialFailure(map[string]any{"meetings": results}, &output.Meta{Count: len(results)})
+ }
+
+ outData := map[string]any{"meetings": results}
+ runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
+ if len(results) == 0 {
+ fmt.Fprintln(w, "No events.")
+ return
+ }
+ var rows []map[string]interface{}
+ for _, r := range results {
+ row := map[string]interface{}{"event_id": r.EventID}
+ if r.Error != "" {
+ row["status"] = "FAIL"
+ row["error"] = r.Error
+ } else {
+ row["status"] = "OK"
+ if r.MeetingID != "" {
+ row["meeting_id"] = r.MeetingID
+ }
+ if r.MeetingNote != "" {
+ row["meeting_note"] = r.MeetingNote
+ }
+ if r.Hint != "" {
+ row["hint"] = r.Hint
+ }
+ }
+ rows = append(rows, row)
+ }
+ output.PrintTable(w, rows)
+ fmt.Fprintf(w, "\n%d event(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
+ })
+ return nil
+ },
+}
diff --git a/shortcuts/calendar/calendar_meeting_test.go b/shortcuts/calendar/calendar_meeting_test.go
new file mode 100644
index 00000000..653c1a5c
--- /dev/null
+++ b/shortcuts/calendar/calendar_meeting_test.go
@@ -0,0 +1,484 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package calendar
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/spf13/cobra"
+
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/core"
+ "github.com/larksuite/cli/internal/httpmock"
+ "github.com/larksuite/cli/internal/output"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+// ---------------------------------------------------------------------------
+// helpers
+// ---------------------------------------------------------------------------
+
+var calWarmOnce sync.Once
+
+func calWarmTokenCache(t *testing.T) {
+ t.Helper()
+ calWarmOnce.Do(func() {
+ f, _, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
+ reg.Register(&httpmock.Stub{
+ URL: "/open-apis/test/v1/warm",
+ Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
+ })
+ s := common.Shortcut{
+ Service: "test",
+ Command: "+warm",
+ AuthTypes: []string{"bot"},
+ Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
+ _, err := rctx.CallAPITyped("GET", "/open-apis/test/v1/warm", nil, nil)
+ return err
+ },
+ }
+ parent := &cobra.Command{Use: "test"}
+ s.Mount(parent, f)
+ parent.SetArgs([]string{"+warm"})
+ parent.SilenceErrors = true
+ parent.SilenceUsage = true
+ parent.Execute()
+ })
+}
+
+func calDefaultConfig() *core.CliConfig {
+ return &core.CliConfig{
+ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
+ UserOpenId: "ou_testuser",
+ }
+}
+
+func calMountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
+ t.Helper()
+ calWarmTokenCache(t)
+ parent := &cobra.Command{Use: "calendar"}
+ s.Mount(parent, f)
+ parent.SetArgs(args)
+ parent.SilenceErrors = true
+ parent.SilenceUsage = true
+ if stdout != nil {
+ stdout.Reset()
+ }
+ return parent.Execute()
+}
+
+// ---------------------------------------------------------------------------
+// calendar +meeting tests
+// ---------------------------------------------------------------------------
+
+func mgetInstanceRelationStub(calendarID, instanceID string, meetingIDs []string, meetingNotes []string, aiMeetingNotes []string) *httpmock.Stub {
+ infos := map[string]interface{}{
+ "instance_id": instanceID,
+ }
+ mIDs := make([]interface{}, len(meetingIDs))
+ for i, id := range meetingIDs {
+ mIDs[i] = id
+ }
+ infos["meeting_instance_ids"] = mIDs
+ if len(meetingNotes) > 0 {
+ notes := make([]interface{}, len(meetingNotes))
+ for i, n := range meetingNotes {
+ notes[i] = n
+ }
+ infos["meeting_notes"] = notes
+ }
+ if len(aiMeetingNotes) > 0 {
+ notes := make([]interface{}, len(aiMeetingNotes))
+ for i, n := range aiMeetingNotes {
+ notes[i] = n
+ }
+ infos["ai_meeting_notes"] = notes
+ }
+ return &httpmock.Stub{
+ Method: "POST",
+ URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID),
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{
+ "instance_relation_infos": []interface{}{infos},
+ },
+ },
+ }
+}
+
+func mgetInstanceRelationFailedStub(calendarID, instanceID, failMsg string) *httpmock.Stub {
+ return &httpmock.Stub{
+ Method: "POST",
+ URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID),
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{
+ "instance_relation_infos": []interface{}{},
+ "failed_instance_ids": []interface{}{
+ map[string]interface{}{
+ "instance_id": instanceID,
+ "fail_msg": failMsg,
+ },
+ },
+ },
+ },
+ }
+}
+
+func TestMeeting_Validation_MissingEventIDs(t *testing.T) {
+ f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
+ err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--as", "user"}, f, nil)
+ if err == nil {
+ t.Fatal("expected validation error for missing --event-ids")
+ }
+}
+
+func TestMeeting_Validation_BatchLimit(t *testing.T) {
+ f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
+ ids := make([]string, 51)
+ for i := range ids {
+ ids[i] = fmt.Sprintf("evt%d", i)
+ }
+ err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
+ if err == nil {
+ t.Fatal("expected batch limit error")
+ }
+ if !strings.Contains(err.Error(), "too many IDs") {
+ t.Errorf("expected 'too many IDs' error, got: %v", err)
+ }
+}
+
+func TestMeeting_DryRun(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
+ err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt001", "--dry-run", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(stdout.String(), "mget_instance_relation_info") {
+ t.Errorf("dry-run should show mget API path, got: %s", stdout.String())
+ }
+}
+
+func TestMeeting_Execute_Success(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
+ reg.Register(mgetInstanceRelationStub("primary", "evt_m1", []string{"123456"}, []string{"doc_note1"}, []string{"doc_ai1"}))
+
+ err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_m1", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var resp map[string]any
+ if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse output: %v", err)
+ }
+ data, _ := resp["data"].(map[string]any)
+ meetings, _ := data["meetings"].([]any)
+ if len(meetings) != 1 {
+ t.Fatalf("expected 1 meeting, got %d", len(meetings))
+ }
+ m, _ := meetings[0].(map[string]any)
+ if m["meeting_id"] != "123456" {
+ t.Errorf("meeting_id = %v, want 123456", m["meeting_id"])
+ }
+ if m["meeting_note"] != "doc_note1" {
+ t.Errorf("meeting_note = %v, want doc_note1", m["meeting_note"])
+ }
+ if _, hasAI := m["ai_meeting_note"]; hasAI {
+ t.Error("ai_meeting_note should not be present in output")
+ }
+}
+
+func TestMeeting_Execute_FailedInstance(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
+ reg.Register(mgetInstanceRelationFailedStub("primary", "evt_fail", "No Permission"))
+
+ err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_fail", "--as", "user"}, f, stdout)
+ if err == nil {
+ t.Fatal("expected partial failure error")
+ }
+ var pfErr *output.PartialFailureError
+ if !errors.As(err, &pfErr) {
+ t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
+ }
+ // Verify translated fail_msg appears in output
+ var resp map[string]any
+ if err := json.Unmarshal(stdout.Bytes(), &resp); err == nil {
+ data, _ := resp["data"].(map[string]any)
+ meetings, _ := data["meetings"].([]any)
+ if len(meetings) > 0 {
+ m, _ := meetings[0].(map[string]any)
+ if errMsg, _ := m["error"].(string); !strings.Contains(errMsg, "no read permission") {
+ t.Errorf("expected translated fail_msg, got: %v", errMsg)
+ }
+ }
+ }
+}
+
+func TestMeeting_Execute_NoMeeting(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
+ reg.Register(mgetInstanceRelationStub("primary", "evt_nomeet", []string{}, nil, nil))
+
+ err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_nomeet", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var resp map[string]any
+ if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse output: %v", err)
+ }
+ data, _ := resp["data"].(map[string]any)
+ meetings, _ := data["meetings"].([]any)
+ if len(meetings) != 1 {
+ t.Fatalf("expected 1 meeting, got %d", len(meetings))
+ }
+ m, _ := meetings[0].(map[string]any)
+ if hint, _ := m["hint"].(string); !strings.Contains(hint, "meeting_id") {
+ t.Errorf("expected hint about meeting_id, got: %v", hint)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// calendar +search-event tests
+// ---------------------------------------------------------------------------
+
+func TestSearchEvent_Validation_InvalidTimeRange(t *testing.T) {
+ f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
+ err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--start", "bad-format", "--end", "2026-04-27", "--as", "user"}, f, nil)
+ if err == nil {
+ t.Fatal("expected validation error for invalid --start")
+ }
+ if !strings.Contains(err.Error(), "--start") {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+func TestSearchEvent_Validation_TimeRangeStartAfterEnd(t *testing.T) {
+ f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
+ err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--start", "2026-04-27", "--end", "2026-04-20", "--as", "user"}, f, nil)
+ if err == nil {
+ t.Fatal("expected validation error for start after end")
+ }
+}
+
+func TestSearchEvent_DryRun(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
+ err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "周会", "--dry-run", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(stdout.String(), "search_event") {
+ t.Errorf("dry-run should show search_event API path, got: %s", stdout.String())
+ }
+}
+
+func TestSearchEvent_Execute_Success(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/calendars/primary/events/search_event",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{
+ "items": []interface{}{
+ map[string]interface{}{
+ "display_info": "Q2 周会\n2026-04-23 15:00-16:00",
+ "meta_data": map[string]interface{}{
+ "event_id": "evt_search1",
+ "summary": "Q2 周会",
+ "start": map[string]interface{}{
+ "date_time": "2026-04-23T15:00:00+08:00",
+ "timezone": "Asia/Shanghai",
+ },
+ "end": map[string]interface{}{
+ "date_time": "2026-04-23T16:00:00+08:00",
+ "timezone": "Asia/Shanghai",
+ },
+ "is_all_day": false,
+ "app_link": "https://applink.feishu.cn/...",
+ },
+ },
+ },
+ "has_more": false,
+ "page_token": "",
+ },
+ },
+ })
+
+ err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "周会", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var resp map[string]any
+ if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse output: %v", err)
+ }
+ data, _ := resp["data"].(map[string]any)
+ if data["calendar_id"] != "primary" {
+ t.Errorf("calendar_id = %v, want primary", data["calendar_id"])
+ }
+ items, _ := data["items"].([]any)
+ if len(items) != 1 {
+ t.Fatalf("expected 1 item, got %d", len(items))
+ }
+ item, _ := items[0].(map[string]any)
+ if item["event_id"] != "evt_search1" {
+ t.Errorf("event_id = %v, want evt_search1", item["event_id"])
+ }
+ if item["summary"] != "Q2 周会" {
+ t.Errorf("summary = %v, want 'Q2 周会'", item["summary"])
+ }
+}
+
+func TestSearchEvent_Execute_Empty(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/calendars/primary/events/search_event",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{
+ "items": []interface{}{},
+ "has_more": false,
+ },
+ },
+ })
+
+ err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "nonexistent", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Pure function tests
+// ---------------------------------------------------------------------------
+
+func TestParseSearchEventTimeRange(t *testing.T) {
+ tests := []struct {
+ name string
+ start string
+ end string
+ wantErr bool
+ }{
+ {"empty", "", "", false},
+ {"valid", "2026-04-20", "2026-04-27", false},
+ {"start only defaults end", "2026-04-20", "", false},
+ {"end only defaults start", "", "2026-04-27", false},
+ {"invalid start format", "not-a-date", "2026-04-27", true},
+ {"start after end", "2026-04-27", "2026-04-20", true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cmd := &cobra.Command{Use: "test"}
+ cmd.Flags().String("start", "", "")
+ cmd.Flags().String("end", "", "")
+ if tt.start != "" {
+ _ = cmd.Flags().Set("start", tt.start)
+ }
+ if tt.end != "" {
+ _ = cmd.Flags().Set("end", tt.end)
+ }
+ runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
+ _, _, err := parseSearchEventTimeRange(runtime)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("parseSearchEventTimeRange() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+
+ t.Run("start only fills end with end-of-day", func(t *testing.T) {
+ cmd := &cobra.Command{Use: "test"}
+ cmd.Flags().String("start", "", "")
+ cmd.Flags().String("end", "", "")
+ _ = cmd.Flags().Set("start", "2026-04-20")
+ runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
+ startRFC, endRFC, err := parseSearchEventTimeRange(runtime)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.HasPrefix(startRFC, "2026-04-20T00:00:00") {
+ t.Errorf("start = %s, want 2026-04-20T00:00:00...", startRFC)
+ }
+ if !strings.HasPrefix(endRFC, "2026-04-20T23:59:59") {
+ t.Errorf("end = %s, want 2026-04-20T23:59:59...", endRFC)
+ }
+ })
+
+ t.Run("end only fills start with start-of-day", func(t *testing.T) {
+ cmd := &cobra.Command{Use: "test"}
+ cmd.Flags().String("start", "", "")
+ cmd.Flags().String("end", "", "")
+ _ = cmd.Flags().Set("end", "2026-04-27")
+ runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
+ startRFC, endRFC, err := parseSearchEventTimeRange(runtime)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.HasPrefix(startRFC, "2026-04-27T00:00:00") {
+ t.Errorf("start = %s, want 2026-04-27T00:00:00...", startRFC)
+ }
+ if !strings.HasPrefix(endRFC, "2026-04-27T23:59:59") {
+ t.Errorf("end = %s, want 2026-04-27T23:59:59...", endRFC)
+ }
+ })
+}
+
+func TestBuildSearchEventFilter(t *testing.T) {
+ cmd := &cobra.Command{Use: "test"}
+ cmd.Flags().String("attendee-ids", "", "")
+ _ = cmd.Flags().Set("attendee-ids", "ou_user1,oc_chat1,omm_room1")
+ runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
+
+ filter := buildSearchEventFilter(runtime, "", "")
+ if filter == nil {
+ t.Fatal("expected filter to be non-nil")
+ }
+ if len(filter.AttendeeUserIDs) != 1 || filter.AttendeeUserIDs[0] != "ou_user1" {
+ t.Errorf("attendee_user_ids = %v, want [ou_user1]", filter.AttendeeUserIDs)
+ }
+ if len(filter.AttendeeChatIDs) != 1 || filter.AttendeeChatIDs[0] != "oc_chat1" {
+ t.Errorf("attendee_chat_ids = %v, want [oc_chat1]", filter.AttendeeChatIDs)
+ }
+ if len(filter.MeetingRoomIDs) != 1 || filter.MeetingRoomIDs[0] != "omm_room1" {
+ t.Errorf("meeting_room_ids = %v, want [omm_room1]", filter.MeetingRoomIDs)
+ }
+}
+
+func TestBuildSearchEventFilter_Empty(t *testing.T) {
+ cmd := &cobra.Command{Use: "test"}
+ cmd.Flags().String("attendee-ids", "", "")
+ runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
+
+ filter := buildSearchEventFilter(runtime, "", "")
+ if filter != nil {
+ t.Errorf("expected nil for empty filter, got %v", filter)
+ }
+}
+
+func TestBuildSearchEventFilter_TimeRange(t *testing.T) {
+ cmd := &cobra.Command{Use: "test"}
+ cmd.Flags().String("attendee-ids", "", "")
+ runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
+
+ filter := buildSearchEventFilter(runtime, "2026-04-20T00:00:00+08:00", "2026-04-27T23:59:59+08:00")
+ if filter == nil {
+ t.Fatal("expected filter to be non-nil")
+ }
+ if filter.TimeRange == nil {
+ t.Fatal("expected time_range in filter")
+ }
+ if filter.TimeRange.StartTime != "2026-04-20T00:00:00+08:00" {
+ t.Errorf("start_time = %v, want 2026-04-20T00:00:00+08:00", filter.TimeRange.StartTime)
+ }
+}
diff --git a/shortcuts/calendar/calendar_room_find.go b/shortcuts/calendar/calendar_room_find.go
index b7cd8f20..7b447f52 100644
--- a/shortcuts/calendar/calendar_room_find.go
+++ b/shortcuts/calendar/calendar_room_find.go
@@ -66,7 +66,8 @@ type roomFindSlot struct {
type roomFindTimeSlot struct {
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
- MeetingRooms []*roomFindSuggestion `json:"meeting_rooms,omitempty"`
+ MeetingRooms []*roomFindSuggestion `json:"meeting_rooms"`
+ Hint string `json:"hint,omitempty"`
}
type roomFindOutput struct {
@@ -103,11 +104,18 @@ func collectRoomFindResults(slots []roomFindSlot, limit int, fetch func(roomFind
}
return
}
- out.TimeSlots = append(out.TimeSlots, &roomFindTimeSlot{
+ if suggestions == nil {
+ suggestions = []*roomFindSuggestion{}
+ }
+ ts := &roomFindTimeSlot{
Start: slot.Start,
End: slot.End,
MeetingRooms: suggestions,
- })
+ }
+ if len(suggestions) == 0 {
+ ts.Hint = "no meeting room matches the current filters for this slot"
+ }
+ out.TimeSlots = append(out.TimeSlots, ts)
}(slot)
}
wg.Wait()
@@ -374,6 +382,10 @@ var CalendarRoomFind = common.Shortcut{
}
for _, slot := range out.TimeSlots {
fmt.Fprintf(w, "%s - %s\n", slot.Start, slot.End)
+ if len(slot.MeetingRooms) == 0 {
+ fmt.Fprintf(w, "0 meeting room(s) found: %s\n", slot.Hint)
+ continue
+ }
var rows []map[string]interface{}
for _, room := range slot.MeetingRooms {
rows = append(rows, map[string]interface{}{
@@ -384,6 +396,7 @@ var CalendarRoomFind = common.Shortcut{
})
}
output.PrintTable(w, rows)
+ fmt.Fprintf(w, "%d meeting room(s) found\n", len(slot.MeetingRooms))
fmt.Fprintln(w)
}
})
diff --git a/shortcuts/calendar/calendar_room_find_test.go b/shortcuts/calendar/calendar_room_find_test.go
index 681509cb..82d54633 100644
--- a/shortcuts/calendar/calendar_room_find_test.go
+++ b/shortcuts/calendar/calendar_room_find_test.go
@@ -4,6 +4,8 @@
package calendar
import (
+ "encoding/json"
+ "strings"
"testing"
"time"
)
@@ -82,3 +84,60 @@ func TestCollectRoomFindResults_LimitsConcurrency(t *testing.T) {
t.Fatalf("expected %d time slots, got %d", len(slots), len(out.TimeSlots))
}
}
+
+func TestCollectRoomFindResults_EmptySlotEmitsHintAndArray(t *testing.T) {
+ slots := []roomFindSlot{
+ {Start: "2026-03-27T14:00:00+08:00", End: "2026-03-27T15:00:00+08:00"},
+ {Start: "2026-03-27T15:00:00+08:00", End: "2026-03-27T16:00:00+08:00"},
+ }
+
+ out, err := collectRoomFindResults(slots, 2, func(slot roomFindSlot) ([]*roomFindSuggestion, error) {
+ if strings.HasPrefix(slot.Start, "2026-03-27T14") {
+ return []*roomFindSuggestion{{RoomID: "rm_1", RoomName: "Room A"}}, nil
+ }
+ return nil, nil
+ })
+ if err != nil {
+ t.Fatalf("collectRoomFindResults returned error: %v", err)
+ }
+ if len(out.TimeSlots) != 2 {
+ t.Fatalf("expected 2 time slots, got %d", len(out.TimeSlots))
+ }
+
+ for _, ts := range out.TimeSlots {
+ if ts.MeetingRooms == nil {
+ t.Fatalf("meeting_rooms should be non-nil for slot %s", ts.Start)
+ }
+ switch {
+ case strings.HasPrefix(ts.Start, "2026-03-27T14"):
+ if len(ts.MeetingRooms) != 1 {
+ t.Fatalf("expected 1 room for first slot, got %d", len(ts.MeetingRooms))
+ }
+ if ts.Hint != "" {
+ t.Fatalf("non-empty slot should not carry hint, got %q", ts.Hint)
+ }
+ case strings.HasPrefix(ts.Start, "2026-03-27T15"):
+ if len(ts.MeetingRooms) != 0 {
+ t.Fatalf("expected 0 rooms for empty slot, got %d", len(ts.MeetingRooms))
+ }
+ if ts.Hint == "" {
+ t.Fatal("empty slot should carry a hint explaining the filters")
+ }
+ }
+ }
+
+ emptySlot := out.TimeSlots[0]
+ if !strings.HasPrefix(emptySlot.Start, "2026-03-27T15") {
+ emptySlot = out.TimeSlots[1]
+ }
+ raw, err := json.Marshal(emptySlot)
+ if err != nil {
+ t.Fatalf("marshal empty slot: %v", err)
+ }
+ if !strings.Contains(string(raw), `"meeting_rooms":[]`) {
+ t.Fatalf("expected meeting_rooms:[] in JSON, got %s", raw)
+ }
+ if !strings.Contains(string(raw), `"hint"`) {
+ t.Fatalf("expected hint field in JSON, got %s", raw)
+ }
+}
diff --git a/shortcuts/calendar/calendar_search_event.go b/shortcuts/calendar/calendar_search_event.go
new file mode 100644
index 00000000..1200230a
--- /dev/null
+++ b/shortcuts/calendar/calendar_search_event.go
@@ -0,0 +1,331 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+//
+// calendar +search-event — search calendar events by keyword, time range, and attendees
+
+package calendar
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/output"
+ "github.com/larksuite/cli/internal/validate"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+const (
+ defaultSearchEventPageSize = 20
+ maxSearchEventPageSize = 30
+)
+
+// searchEventTimeRange represents the time range filter for search_event API.
+type searchEventTimeRange struct {
+ StartTime string `json:"start_time,omitempty"`
+ EndTime string `json:"end_time,omitempty"`
+}
+
+// searchEventFilter represents the filter object for the search_event API request.
+type searchEventFilter struct {
+ AttendeeUserIDs []string `json:"attendee_user_ids,omitempty"`
+ AttendeeChatIDs []string `json:"attendee_chat_ids,omitempty"`
+ MeetingRoomIDs []string `json:"meeting_room_ids,omitempty"`
+ TimeRange *searchEventTimeRange `json:"time_range,omitempty"`
+}
+
+// searchEventRequestBody is the request body for the search_event API.
+type searchEventRequestBody struct {
+ Query string `json:"query"`
+ Filter *searchEventFilter `json:"filter,omitempty"`
+}
+
+// searchEventTimeInfo represents start/end time info in the search result.
+type searchEventTimeInfo struct {
+ Date string `json:"date,omitempty"`
+ DateTime string `json:"date_time,omitempty"`
+ Timezone string `json:"timezone,omitempty"`
+}
+
+// searchEventItem represents a single event in the search result output.
+type searchEventItem struct {
+ EventID string `json:"event_id"`
+ Summary string `json:"summary"`
+ Start *searchEventTimeInfo `json:"start,omitempty"`
+ End *searchEventTimeInfo `json:"end,omitempty"`
+ IsAllDay bool `json:"is_all_day,omitempty"`
+ AppLink string `json:"app_link,omitempty"`
+}
+
+// searchEventOutput is the structured output for +search-event.
+type searchEventOutput struct {
+ CalendarID string `json:"calendar_id"`
+ Items []searchEventItem `json:"items"`
+ HasMore bool `json:"has_more"`
+ PageToken string `json:"page_token"`
+}
+
+// parseSearchEventTimeRange parses --start / --end into RFC3339 strings.
+// When only one side is provided, the other defaults to the same day's
+// boundary (start → end-of-day, end → start-of-day).
+func parseSearchEventTimeRange(runtime *common.RuntimeContext) (string, string, error) {
+ startInput := strings.TrimSpace(runtime.Str("start"))
+ endInput := strings.TrimSpace(runtime.Str("end"))
+ if startInput == "" && endInput == "" {
+ return "", "", nil
+ }
+
+ var startSec, endSec int64
+
+ if startInput != "" {
+ ts, err := common.ParseTime(startInput)
+ if err != nil {
+ return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
+ }
+ startSec, _ = strconv.ParseInt(ts, 10, 64)
+ }
+ if endInput != "" {
+ ts, err := common.ParseTime(endInput, "end")
+ if err != nil {
+ return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
+ }
+ endSec, _ = strconv.ParseInt(ts, 10, 64)
+ }
+
+ if startInput == "" {
+ t := time.Unix(endSec, 0).In(time.Local)
+ startSec = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).Unix()
+ }
+ if endInput == "" {
+ t := time.Unix(startSec, 0).In(time.Local)
+ endSec = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location()).Unix()
+ }
+
+ if startSec > endSec {
+ return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start must be before --end").WithParam("--start")
+ }
+
+ return time.Unix(startSec, 0).Format(time.RFC3339), time.Unix(endSec, 0).Format(time.RFC3339), nil
+}
+
+// buildSearchEventFilter builds the filter object for the search_event API.
+func buildSearchEventFilter(runtime *common.RuntimeContext, startTime, endTime string) *searchEventFilter {
+ attendeeIDs := common.SplitCSV(runtime.Str("attendee-ids"))
+
+ var userIDs, chatIDs, roomIDs []string
+ for _, id := range attendeeIDs {
+ switch {
+ case strings.HasPrefix(id, "ou_"):
+ userIDs = append(userIDs, id)
+ case strings.HasPrefix(id, "oc_"):
+ chatIDs = append(chatIDs, id)
+ case strings.HasPrefix(id, "omm_"):
+ roomIDs = append(roomIDs, id)
+ default:
+ userIDs = append(userIDs, id)
+ }
+ }
+
+ var tr *searchEventTimeRange
+ if startTime != "" || endTime != "" {
+ tr = &searchEventTimeRange{StartTime: startTime, EndTime: endTime}
+ }
+
+ if len(userIDs) == 0 && len(chatIDs) == 0 && len(roomIDs) == 0 && tr == nil {
+ return nil
+ }
+ return &searchEventFilter{
+ AttendeeUserIDs: userIDs,
+ AttendeeChatIDs: chatIDs,
+ MeetingRoomIDs: roomIDs,
+ TimeRange: tr,
+ }
+}
+
+// extractTimeInfo extracts time info from a meta_data start/end map.
+func extractTimeInfo(m map[string]any) *searchEventTimeInfo {
+ if m == nil {
+ return nil
+ }
+ info := &searchEventTimeInfo{}
+ if v, ok := m["date"].(string); ok && v != "" {
+ info.Date = v
+ }
+ if v, ok := m["date_time"].(string); ok && v != "" {
+ info.DateTime = v
+ }
+ if v, ok := m["timezone"].(string); ok && v != "" {
+ info.Timezone = v
+ }
+ if info.Date == "" && info.DateTime == "" {
+ return nil
+ }
+ return info
+}
+
+// CalendarSearchEvent searches calendar events by keyword, time range, and attendees.
+var CalendarSearchEvent = common.Shortcut{
+ Service: "calendar",
+ Command: "+search-event",
+ Description: "Search calendar events by keyword, time range, and attendees",
+ Risk: "read",
+ Scopes: []string{"calendar:calendar.event:read"},
+ AuthTypes: []string{"user"},
+ HasFormat: true,
+ Flags: []common.Flag{
+ {Name: "calendar-id", Desc: "calendar ID (default: primary)"},
+ {Name: "query", Desc: "search keyword"},
+ {Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
+ {Name: "start", Desc: "search time range start (ISO 8601 or YYYY-MM-DD)"},
+ {Name: "end", Desc: "search time range end (ISO 8601 or YYYY-MM-DD)"},
+ {Name: "page-token", Desc: "page token for next page"},
+ {Name: "page-size", Default: "20", Desc: "page size, 1-30 (default 20)"},
+ },
+ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ if err := rejectCalendarAutoBotFallback(runtime); err != nil {
+ return err
+ }
+ if _, _, err := parseSearchEventTimeRange(runtime); err != nil {
+ return err
+ }
+ if _, err := common.ValidatePageSizeTyped(runtime, "page-size", defaultSearchEventPageSize, 1, maxSearchEventPageSize); err != nil {
+ return err
+ }
+ return nil
+ },
+ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
+ calendarID := runtime.Str("calendar-id")
+ if calendarID == "" {
+ calendarID = ""
+ }
+ return common.NewDryRunAPI().
+ POST(fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/search_event", calendarID)).
+ Set("calendar_id", calendarID)
+ },
+ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ calendarID := strings.TrimSpace(runtime.Str("calendar-id"))
+ if calendarID == "" {
+ calendarID = PrimaryCalendarIDStr
+ }
+
+ startTime, endTime, err := parseSearchEventTimeRange(runtime)
+ if err != nil {
+ return err
+ }
+
+ // Build request body — always send query (even if empty)
+ body := &searchEventRequestBody{
+ Query: strings.TrimSpace(runtime.Str("query")),
+ }
+ if filter := buildSearchEventFilter(runtime, startTime, endTime); filter != nil {
+ body.Filter = filter
+ }
+
+ // Build query params
+ params := map[string]any{}
+ pageSize, _ := strconv.Atoi(strings.TrimSpace(runtime.Str("page-size")))
+ if pageSize <= 0 {
+ pageSize = defaultSearchEventPageSize
+ }
+ params["page_size"] = strconv.Itoa(pageSize)
+ if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
+ params["page_token"] = pt
+ }
+
+ data, err := runtime.CallAPITyped("POST",
+ fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/search_event", validate.EncodePathSegment(calendarID)),
+ params, body)
+ if err != nil {
+ return err
+ }
+ if data == nil {
+ data = map[string]any{}
+ }
+
+ items := common.GetSlice(data, "items")
+ hasMore, _ := data["has_more"].(bool)
+ pageToken, _ := data["page_token"].(string)
+
+ // Transform items to structured output
+ outItems := make([]searchEventItem, 0, len(items))
+ for _, raw := range items {
+ item, _ := raw.(map[string]any)
+ if item == nil {
+ continue
+ }
+ meta, _ := item["meta_data"].(map[string]any)
+ out := searchEventItem{}
+ if meta != nil {
+ if v, ok := meta["event_id"].(string); ok {
+ out.EventID = v
+ }
+ if v, ok := meta["summary"].(string); ok {
+ out.Summary = v
+ }
+ if v, ok := meta["is_all_day"].(bool); ok {
+ out.IsAllDay = v
+ }
+ if v, ok := meta["app_link"].(string); ok {
+ out.AppLink = v
+ }
+ if start, ok := meta["start"].(map[string]any); ok {
+ out.Start = extractTimeInfo(start)
+ }
+ if end, ok := meta["end"].(map[string]any); ok {
+ out.End = extractTimeInfo(end)
+ }
+ }
+ outItems = append(outItems, out)
+ }
+
+ outData := searchEventOutput{
+ CalendarID: calendarID,
+ Items: outItems,
+ HasMore: hasMore,
+ PageToken: pageToken,
+ }
+
+ runtime.OutFormat(outData, &output.Meta{Count: len(outItems)}, func(w io.Writer) {
+ if len(outItems) == 0 {
+ fmt.Fprintln(w, "No events found.")
+ return
+ }
+ var rows []map[string]interface{}
+ for _, item := range outItems {
+ row := map[string]interface{}{
+ "event_id": item.EventID,
+ "summary": common.TruncateStr(item.Summary, 40),
+ }
+ if item.Start != nil {
+ if item.Start.DateTime != "" {
+ row["start"] = item.Start.DateTime
+ } else if item.Start.Date != "" {
+ row["start"] = item.Start.Date
+ }
+ }
+ if item.End != nil {
+ if item.End.DateTime != "" {
+ row["end"] = item.End.DateTime
+ } else if item.End.Date != "" {
+ row["end"] = item.End.Date
+ }
+ }
+ if item.IsAllDay {
+ row["is_all_day"] = true
+ }
+ rows = append(rows, row)
+ }
+ output.PrintTable(w, rows)
+ fmt.Fprintf(w, "\n%d event(s) found\n", len(outItems))
+ })
+
+ if hasMore && runtime.Format != "json" && runtime.Format != "" {
+ fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
+ }
+ return nil
+ },
+}
diff --git a/shortcuts/calendar/calendar_test.go b/shortcuts/calendar/calendar_test.go
index 67c386e6..970a141e 100644
--- a/shortcuts/calendar/calendar_test.go
+++ b/shortcuts/calendar/calendar_test.go
@@ -2234,10 +2234,10 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
// Shortcuts() registration test
// ---------------------------------------------------------------------------
-func TestShortcuts_Returns7(t *testing.T) {
+func TestShortcuts_Returns9(t *testing.T) {
shortcuts := Shortcuts()
- if len(shortcuts) != 7 {
- t.Fatalf("expected 7 shortcuts, got %d", len(shortcuts))
+ if len(shortcuts) != 9 {
+ t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts))
}
names := map[string]bool{}
diff --git a/shortcuts/calendar/errors.go b/shortcuts/calendar/errors.go
index ff260f19..9abf3d4a 100644
--- a/shortcuts/calendar/errors.go
+++ b/shortcuts/calendar/errors.go
@@ -42,3 +42,30 @@ func withParam(err error, flag string) error {
}
return err
}
+
+// unwrapCalendarAPIError returns a user-facing message extracted from a
+// calendar business-domain *errs.APIError, or "" when the error is not an
+// APIError or its Code is not specialized here. Callers should fall back to
+// err.Error() on "".
+//
+// Today it handles:
+// - 190014 (invalid_parameters): returns Problem.Hint, which carries the
+// server-supplied field-level detail (e.g. "end_time should be later
+// than start_time") lifted by errclass.BuildAPIError.
+//
+// Add additional 19xxxx codes here as they become worth surfacing — keep this
+// the single switch site so call sites stay readable.
+func unwrapCalendarAPIError(err error) string {
+ if err == nil {
+ return ""
+ }
+ var ae *errs.APIError
+ if !errors.As(err, &ae) {
+ return ""
+ }
+ switch ae.Code {
+ case 190014:
+ return ae.Hint
+ }
+ return ""
+}
diff --git a/shortcuts/calendar/errors_attribution_test.go b/shortcuts/calendar/errors_attribution_test.go
index f45d7a00..18cd0f8c 100644
--- a/shortcuts/calendar/errors_attribution_test.go
+++ b/shortcuts/calendar/errors_attribution_test.go
@@ -240,3 +240,62 @@ func TestParseCalendarAttendeeIDs_Valid(t *testing.T) {
t.Errorf("dedup/trim failed: got %v", ids)
}
}
+
+// ---------------------------------------------------------------------------
+// unwrapCalendarAPIError helper
+// ---------------------------------------------------------------------------
+
+func TestUnwrapCalendarAPIError_NilReturnsEmpty(t *testing.T) {
+ if got := unwrapCalendarAPIError(nil); got != "" {
+ t.Errorf("nil err should return empty string, got %q", got)
+ }
+}
+
+func TestUnwrapCalendarAPIError_NonAPIErrorReturnsEmpty(t *testing.T) {
+ // Validation, internal, and plain errors are not calendar API business
+ // errors; the helper must signal "no specialization" so callers fall back.
+ cases := []error{
+ errs.NewValidationError(errs.SubtypeInvalidArgument, "bad input"),
+ errs.NewInternalError(errs.SubtypeSDKError, "io failure"),
+ errors.New("plain error"),
+ }
+ for _, e := range cases {
+ if got := unwrapCalendarAPIError(e); got != "" {
+ t.Errorf("unwrapCalendarAPIError(%T) = %q, want empty", e, got)
+ }
+ }
+}
+
+func TestUnwrapCalendarAPIError_Code190014_ReturnsHint(t *testing.T) {
+ ae := errs.NewAPIError(errs.SubtypeInvalidParameters, "invalid params").
+ WithCode(190014).
+ WithHint("end_time should be later than start_time")
+ got := unwrapCalendarAPIError(ae)
+ if got != "end_time should be later than start_time" {
+ t.Errorf("expected lifted hint, got %q", got)
+ }
+}
+
+func TestUnwrapCalendarAPIError_Code190014_WrappedStillResolves(t *testing.T) {
+ // withStepContext wraps the typed error but errors.As must still find it.
+ inner := errs.NewAPIError(errs.SubtypeInvalidParameters, "invalid params").
+ WithCode(190014).
+ WithHint("calendar_id is required")
+ wrapped := withStepContext(inner, "while fetching meeting info for %s", "evt_x")
+ got := unwrapCalendarAPIError(wrapped)
+ if !strings.Contains(got, "calendar_id is required") {
+ t.Errorf("expected wrapped 190014 to surface hint, got %q", got)
+ }
+}
+
+func TestUnwrapCalendarAPIError_UnhandledCodeReturnsEmpty(t *testing.T) {
+ // An APIError carrying a code that isn't specialized here should return
+ // "" so callers fall back to err.Error() — keeps the helper conservative
+ // while we add 19xxxx codes incrementally.
+ ae := errs.NewAPIError(errs.SubtypeInvalidParameters, "some other error").
+ WithCode(190099).
+ WithHint("ignore me")
+ if got := unwrapCalendarAPIError(ae); got != "" {
+ t.Errorf("unhandled code should return empty, got %q", got)
+ }
+}
diff --git a/shortcuts/calendar/shortcuts.go b/shortcuts/calendar/shortcuts.go
index f959068b..b927bf78 100644
--- a/shortcuts/calendar/shortcuts.go
+++ b/shortcuts/calendar/shortcuts.go
@@ -15,5 +15,7 @@ func Shortcuts() []common.Shortcut {
CalendarRoomFind,
CalendarRsvp,
CalendarSuggestion,
+ CalendarMeeting,
+ CalendarSearchEvent,
}
}
diff --git a/shortcuts/common/artifact_path.go b/shortcuts/common/artifact_path.go
index 42ba3beb..de68770e 100644
--- a/shortcuts/common/artifact_path.go
+++ b/shortcuts/common/artifact_path.go
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: MIT
// This file defines artifact-path conventions shared between
-// `minutes +download` and `vc +notes`. Callers outside those two shortcuts
+// `minutes +download` and `minutes +detail`. Callers outside those two shortcuts
// should not take a dependency on these symbols.
package common
diff --git a/shortcuts/doc/doc_errors_test.go b/shortcuts/doc/doc_errors_test.go
index 56871a87..62ace658 100644
--- a/shortcuts/doc/doc_errors_test.go
+++ b/shortcuts/doc/doc_errors_test.go
@@ -203,6 +203,13 @@ func TestValidateCreateV2Contract(t *testing.T) {
}
}
+func TestValidateCreateV2AllowsTitleWithoutContent(t *testing.T) {
+ rt := docValidateRuntime(t, map[string]string{"title": "Only Title"}, nil, nil)
+ if err := validateCreateV2(context.Background(), rt); err != nil {
+ t.Fatalf("validateCreateV2() error = %v, want nil", err)
+ }
+}
+
func TestValidateFetchV2Contract(t *testing.T) {
cases := []struct {
name string
diff --git a/shortcuts/doc/docs_create_test.go b/shortcuts/doc/docs_create_test.go
index a701aa6a..089a0c3f 100644
--- a/shortcuts/doc/docs_create_test.go
+++ b/shortcuts/doc/docs_create_test.go
@@ -90,7 +90,6 @@ func TestDocsCreateV2BotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
- "--api-version", "v2",
"--content", "内容 正文
",
"--as", "bot",
})
@@ -125,7 +124,6 @@ func TestDocsCreateV2UserSkipsPermissionGrantAugmentation(t *testing.T) {
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
- "--api-version", "v2",
"--content", "内容 正文
",
"--as", "user",
})
@@ -163,7 +161,6 @@ func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
- "--api-version", "v2",
"--content", "内容 正文
",
"--as", "bot",
})
@@ -201,7 +198,6 @@ func TestDocsCreateV2FallbackURLWhenBackendOmitsIt(t *testing.T) {
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
- "--api-version", "v2",
"--content", "内容 正文
",
"--as", "user",
})
@@ -233,7 +229,6 @@ func TestDocsCreateV2PreservesBackendURL(t *testing.T) {
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
- "--api-version", "v2",
"--content", "内容 正文
",
"--as", "user",
})
@@ -248,7 +243,7 @@ func TestDocsCreateV2PreservesBackendURL(t *testing.T) {
}
}
-func TestDocsCreateAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
+func TestDocsCreateAPIVersionCompatFlagIsIgnored(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
@@ -262,7 +257,7 @@ func TestDocsCreateAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
- "--api-version", "v1",
+ "--api-version", "legacy",
"--content", "项目计划 ",
"--as", "user",
})
@@ -282,7 +277,6 @@ func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
- "--title", "项目计划",
"--markdown", "## 目标",
"--as", "user",
})
@@ -292,8 +286,7 @@ func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
for _, want := range []string{
"docs +create is v2-only",
"the old v1 interface has been shut down",
- "legacy v1 flag(s) --title, --markdown are no longer supported",
- "--title -> put the title in --content",
+ "legacy v1 flag(s) --markdown are no longer supported",
"--markdown -> use --content with --doc-format markdown",
"lark-cli skills read lark-doc references/lark-doc-create.md",
"lark-cli skills read lark-doc references/lark-doc-xml.md",
diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go
index 6f0e1471..ffa48745 100644
--- a/shortcuts/doc/docs_create_v2.go
+++ b/shortcuts/doc/docs_create_v2.go
@@ -4,7 +4,9 @@
package doc
import (
+ "bytes"
"context"
+ "encoding/xml"
"strings"
"github.com/larksuite/cli/errs"
@@ -14,6 +16,7 @@ import (
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
func v2CreateFlags() []common.Flag {
return []common.Flag{
+ {Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as ... so the title wins over later content titles"},
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
@@ -25,8 +28,12 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
if err := validateDocsV2Only(runtime, "+create", docsCreateLegacyFlags()); err != nil {
return err
}
- if runtime.Str("content") == "" {
- return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
+ title := strings.TrimSpace(runtime.Str("title"))
+ if runtime.Changed("title") && title == "" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title must not be empty").WithParam("--title")
+ }
+ if runtime.Str("content") == "" && title == "" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
}
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams(
@@ -66,7 +73,7 @@ func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"format": runtime.Str("doc-format"),
- "content": runtime.Str("content"),
+ "content": buildCreateContent(runtime),
}
if v := runtime.Str("parent-token"); v != "" {
body["parent_token"] = v
@@ -78,6 +85,26 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
return body
}
+func buildCreateContent(runtime *common.RuntimeContext) string {
+ content := runtime.Str("content")
+ title := strings.TrimSpace(runtime.Str("title"))
+ if title == "" {
+ return content
+ }
+
+ titleTag := "" + escapeDocTitleText(title) + " "
+ if content == "" {
+ return titleTag
+ }
+ return titleTag + "\n" + content
+}
+
+func escapeDocTitleText(title string) string {
+ var buf bytes.Buffer
+ _ = xml.EscapeText(&buf, []byte(title))
+ return buf.String()
+}
+
// augmentDocsCreatePermission grants full_access to the current CLI user when
// the document was created with bot identity.
func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string]interface{}) {
diff --git a/shortcuts/doc/docs_fetch_im_markdown.go b/shortcuts/doc/docs_fetch_im_markdown.go
new file mode 100644
index 00000000..13b37002
--- /dev/null
+++ b/shortcuts/doc/docs_fetch_im_markdown.go
@@ -0,0 +1,861 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package doc
+
+import (
+ "fmt"
+ "html"
+ "net/url"
+ "regexp"
+ "strings"
+ "unicode/utf8"
+)
+
+type imMarkdownContext struct {
+ baseURL string
+ blockquoteDepth int
+}
+
+type imMarkdownHandleFunc func(segment, inner string, attrs map[string]string, imCtx imMarkdownContext) string
+
+type imMarkdownTagHandler struct {
+ closeRE *regexp.Regexp
+ handle imMarkdownHandleFunc
+}
+
+func registerIMMarkdownHandler(tag string, handle imMarkdownHandleFunc) {
+ imMarkdownHandlers[tag] = imMarkdownTagHandler{
+ closeRE: regexp.MustCompile(`(?is)<(/?)` + regexp.QuoteMeta(tag) + `(?:\s[^<>]*?)?\s*/?>`),
+ handle: handle,
+ }
+}
+
+var (
+ imMarkdownTagStartRE = regexp.MustCompile(`(?s)<([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?\s*/?>`)
+ imMarkdownAttrRE = regexp.MustCompile(`([A-Za-z_:][A-Za-z0-9_:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')`)
+ imMarkdownRowTagRE = regexp.MustCompile(`(?is)<(/?)tr\b[^>]*?\s*/?>`)
+ imMarkdownCellTagRE = regexp.MustCompile(`(?is)<(/?)t[dh]\b[^>]*?\s*/?>`)
+ imMarkdownCellBreakRE = regexp.MustCompile(`(?i) `)
+ imMarkdownAnyTagRE = regexp.MustCompile(`(?s)?([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?>`)
+ imMarkdownLinkRE = regexp.MustCompile(`(?is)]*\bhref=(?:"([^"]*)"|'([^']*)')[^>]*>(.*?) `)
+ imMarkdownCodeBlockRE = regexp.MustCompile(`(?is)^\s*]*?)?>(.*?)\s*$`)
+ imMarkdownLiOpenRE = regexp.MustCompile(`(?is)]*?)?>`)
+ imMarkdownLiCloseRE = regexp.MustCompile(`(?is)<(/?)li(?:\s[^<>]*?)?\s*/?>`)
+)
+
+var imMarkdownHandlers = map[string]imMarkdownTagHandler{}
+
+func init() {
+ registerIMMarkdownHandler("title", handleIMMarkdownTitle)
+ for level := 1; level <= 9; level++ {
+ registerIMMarkdownHandler(fmt.Sprintf("h%d", level), handleIMMarkdownHeading(level))
+ }
+ registerIMMarkdownHandler("p", handleIMMarkdownParagraph)
+ registerIMMarkdownHandler("ul", handleIMMarkdownUnorderedList)
+ registerIMMarkdownHandler("ol", handleIMMarkdownOrderedList)
+ registerIMMarkdownHandler("li", handleIMMarkdownListItem)
+ registerIMMarkdownHandler("callout", handleIMMarkdownCallout)
+ registerIMMarkdownHandler("blockquote", handleIMMarkdownBlockquote)
+ registerIMMarkdownHandler("grid", handleIMMarkdownPassthroughContainer)
+ registerIMMarkdownHandler("column", handleIMMarkdownColumn)
+ registerIMMarkdownHandler("table", handleIMMarkdownTable)
+ registerIMMarkdownHandler("colgroup", handleIMMarkdownDiscard)
+ registerIMMarkdownHandler("col", handleIMMarkdownDiscard)
+ registerIMMarkdownHandler("pre", handleIMMarkdownPre)
+ registerIMMarkdownHandler("code", handleIMMarkdownCode)
+ registerIMMarkdownHandler("latex", handleIMMarkdownLatex)
+ registerIMMarkdownHandler("hr", handleIMMarkdownHorizontalRule)
+ registerIMMarkdownHandler("img", handleIMMarkdownImage)
+ registerIMMarkdownHandler("figure", handleIMMarkdownDiscard)
+ registerIMMarkdownHandler("source", handleIMMarkdownSource)
+ registerIMMarkdownHandler("button", handleIMMarkdownDiscard)
+ registerIMMarkdownHandler("time", handleIMMarkdownDiscard)
+ registerIMMarkdownHandler("whiteboard", handleIMMarkdownInlineCode)
+ registerIMMarkdownHandler("sheet", handleIMMarkdownSheet)
+ registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("任务", "task-id", "guid", "token", "id"))
+ registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("群聊卡片", "chat-id", "chat_id", "id"))
+ registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("多维表格"))
+ registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("多维表格"))
+ registerIMMarkdownHandler("okr", handleIMMarkdownResourceLabel("OKR"))
+ registerIMMarkdownHandler("poll", handleIMMarkdownDiscard)
+ registerIMMarkdownHandler("agenda", handleIMMarkdownDiscard)
+ registerIMMarkdownHandler("folder_manager", handleIMMarkdownDiscard)
+ registerIMMarkdownHandler("wiki_catalog", handleIMMarkdownDiscard)
+ registerIMMarkdownHandler("wiki_recent_update", handleIMMarkdownDiscard)
+ registerIMMarkdownHandler("chart_refer_host_perm", handleIMMarkdownDiscard)
+ registerIMMarkdownHandler("synced_reference", handleIMMarkdownDiscard)
+ registerIMMarkdownHandler("synced-source", handleIMMarkdownDiscard)
+ registerIMMarkdownHandler("mindnote", handleIMMarkdownDiscard)
+ registerIMMarkdownHandler("bookmark", handleIMMarkdownBookmark)
+ registerIMMarkdownHandler("cite", handleIMMarkdownCite)
+ registerIMMarkdownHandler("b", handleIMMarkdownStrong)
+ registerIMMarkdownHandler("em", handleIMMarkdownEmphasis)
+ registerIMMarkdownHandler("del", handleIMMarkdownDelete)
+ registerIMMarkdownHandler("u", handleIMMarkdownPlainInline)
+ registerIMMarkdownHandler("span", handleIMMarkdownPlainInline)
+ registerIMMarkdownHandler("a", handleIMMarkdownAnchor)
+}
+
+func isIMMarkdownFetch(runtime interface{ Str(string) string }) bool {
+ return strings.TrimSpace(runtime.Str("doc-format")) == "im-markdown"
+}
+
+func applyFetchIMMarkdown(data map[string]interface{}, docInput string) {
+ doc, ok := data["document"].(map[string]interface{})
+ if !ok {
+ return
+ }
+ content, ok := doc["content"].(string)
+ if !ok {
+ return
+ }
+ doc["content"] = convertToIMMarkdown(content, newIMMarkdownContext(docInput))
+}
+
+func newIMMarkdownContext(docInput string) imMarkdownContext {
+ base := "https://larkoffice.com"
+ raw := strings.TrimSpace(docInput)
+ if extracted, ok := imMarkdownBaseURLFromInput(raw); ok {
+ base = extracted
+ }
+ return imMarkdownContext{baseURL: base}
+}
+
+func (c imMarkdownContext) withBlockquote() imMarkdownContext {
+ c.blockquoteDepth++
+ return c
+}
+
+func (c imMarkdownContext) inBlockquote() bool {
+ return c.blockquoteDepth > 0
+}
+
+// imMarkdownBaseURLFromInput keeps the tenant host from --doc when it is a URL
+// so generated doc/sheet links point back to the same tenant. parseDocumentRef
+// intentionally strips host information, so it cannot serve this formatting path.
+func imMarkdownBaseURLFromInput(raw string) (string, bool) {
+ if raw == "" {
+ return "", false
+ }
+ if u, err := url.Parse(raw); err == nil && u.Scheme != "" && u.Host != "" {
+ return u.Scheme + "://" + u.Host, true
+ }
+ for _, marker := range []string{"/docx/", "/wiki/", "/doc/"} {
+ idx := strings.Index(raw, marker)
+ if idx <= 0 {
+ continue
+ }
+ candidate := strings.Trim(raw[:idx], "/")
+ if candidate == "" {
+ continue
+ }
+ if u, err := url.Parse(candidate); err == nil && u.Scheme != "" && u.Host != "" {
+ return u.Scheme + "://" + u.Host, true
+ }
+ if u, err := url.Parse("https://" + candidate); err == nil && u.Host != "" && strings.Contains(u.Host, ".") {
+ return "https://" + u.Host, true
+ }
+ }
+ return "", false
+}
+
+func convertToIMMarkdown(content string, imCtx imMarkdownContext) string {
+ var out strings.Builder
+ for offset := 0; offset < len(content); {
+ // Scan only to the next XML-like opening tag. Plain Markdown text between
+ // registered tags is copied unchanged, so ordinary Markdown is not re-parsed.
+ loc := imMarkdownTagStartRE.FindStringSubmatchIndex(content[offset:])
+ if loc == nil {
+ out.WriteString(content[offset:])
+ break
+ }
+ start := offset + loc[0]
+ openEnd := offset + loc[1]
+ tag := strings.ToLower(content[offset+loc[2] : offset+loc[3]])
+ handler, ok := imMarkdownHandlers[tag]
+ if !ok {
+ // Unknown tags are left intact. im-markdown only downgrades tags with
+ // explicit handlers so future server output does not get guessed at.
+ out.WriteString(content[offset:openEnd])
+ offset = openEnd
+ continue
+ }
+
+ out.WriteString(content[offset:start])
+ opening := content[start:openEnd]
+ attrs := parseIMMarkdownAttrs(opening)
+ if isSelfClosingIMMarkdownTag(opening) {
+ out.WriteString(handler.handle(opening, "", attrs, imCtx))
+ offset = openEnd
+ continue
+ }
+
+ // Use the handler's precompiled close regexp to find the matching end tag.
+ // Depth tracking keeps nested same-name containers paired correctly.
+ closeStart, closeEnd, found := findIMMarkdownClosingTag(content, openEnd, handler)
+ if !found {
+ // Malformed or truncated fragments are preserved as-is from the opening
+ // tag onward; do not drop content when the XML-ish structure is incomplete.
+ out.WriteString(content[start:])
+ break
+ }
+ segment := content[start:closeEnd]
+ inner := content[openEnd:closeStart]
+ out.WriteString(handler.handle(segment, inner, attrs, imCtx))
+ offset = closeEnd
+ }
+ return out.String()
+}
+
+func findIMMarkdownClosingTag(content string, from int, handler imMarkdownTagHandler) (int, int, bool) {
+ depth := 1
+ for _, loc := range handler.closeRE.FindAllStringSubmatchIndex(content[from:], -1) {
+ start := from + loc[0]
+ end := from + loc[1]
+ token := content[start:end]
+ if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
+ depth--
+ if depth == 0 {
+ return start, end, true
+ }
+ continue
+ }
+ if !isSelfClosingIMMarkdownTag(token) {
+ depth++
+ }
+ }
+ return 0, 0, false
+}
+
+func parseIMMarkdownAttrs(opening string) map[string]string {
+ attrs := map[string]string{}
+ for _, match := range imMarkdownAttrRE.FindAllStringSubmatch(opening, -1) {
+ value := match[2]
+ if value == "" {
+ value = match[3]
+ }
+ attrs[strings.ToLower(match[1])] = html.UnescapeString(value)
+ }
+ return attrs
+}
+
+func isSelfClosingIMMarkdownTag(tag string) bool {
+ return strings.HasSuffix(strings.TrimSpace(tag), "/>")
+}
+
+func handleIMMarkdownTitle(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
+ text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
+ if text == "" {
+ return ""
+ }
+ return "# " + text
+}
+
+func handleIMMarkdownHeading(level int) imMarkdownHandleFunc {
+ return func(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
+ text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
+ if text == "" {
+ return ""
+ }
+ markdownLevel := level
+ if markdownLevel > 6 {
+ markdownLevel = 6
+ }
+ return strings.Repeat("#", markdownLevel) + " " + text
+ }
+}
+
+func handleIMMarkdownParagraph(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
+ body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
+ if body == "" {
+ return ""
+ }
+ if imCtx.inBlockquote() {
+ return body + "\n"
+ }
+ return body
+}
+
+func handleIMMarkdownUnorderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
+ return convertIMMarkdownListItems(inner, false, imCtx)
+}
+
+func handleIMMarkdownOrderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
+ return convertIMMarkdownListItems(inner, true, imCtx)
+}
+
+func handleIMMarkdownListItem(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
+ prefix := "-"
+ if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" {
+ prefix = strings.TrimSuffix(seq, ".") + "."
+ }
+ body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
+ if body == "" {
+ return ""
+ }
+ return prefix + " " + indentIMMarkdownListContinuation(body) + "\n"
+}
+
+func handleIMMarkdownCallout(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
+ body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
+ emoji := strings.TrimSpace(attrs["emoji"])
+ if emoji != "" {
+ if body == "" {
+ body = emoji
+ } else {
+ body = emoji + " " + body
+ }
+ }
+ if body == "" {
+ return "---\n---"
+ }
+ return fmt.Sprintf("---\n%s\n---", body)
+}
+
+func handleIMMarkdownBlockquote(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
+ body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx.withBlockquote()))
+ if body == "" {
+ return ""
+ }
+ lines := strings.Split(body, "\n")
+ for i, line := range lines {
+ if strings.TrimSpace(line) == "" {
+ lines[i] = ">"
+ continue
+ }
+ lines[i] = "> " + line
+ }
+ return strings.Join(lines, "\n")
+}
+
+func handleIMMarkdownPassthroughContainer(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
+ return strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
+}
+
+func handleIMMarkdownColumn(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
+ body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
+ if body == "" {
+ return ""
+ }
+ return body + "\n"
+}
+
+func handleIMMarkdownDiscard(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
+ return ""
+}
+
+func handleIMMarkdownInlineCode(segment string, _ string, _ map[string]string, _ imMarkdownContext) string {
+ return imMarkdownInlineCode(segment)
+}
+
+func handleIMMarkdownPre(_ string, inner string, attrs map[string]string, _ imMarkdownContext) string {
+ lang := strings.TrimSpace(attrs["lang"])
+ code := strings.TrimSpace(inner)
+ if match := imMarkdownCodeBlockRE.FindStringSubmatch(code); match != nil {
+ code = match[1]
+ }
+ return imMarkdownFencedCode(html.UnescapeString(code), lang)
+}
+
+func handleIMMarkdownCode(_ string, inner string, _ map[string]string, _ imMarkdownContext) string {
+ return imMarkdownInlineCode(markdownPlainText(inner))
+}
+
+func handleIMMarkdownLatex(_ string, inner string, _ map[string]string, _ imMarkdownContext) string {
+ expr := strings.TrimSpace(markdownPlainText(inner))
+ if expr == "" {
+ return ""
+ }
+ return "$" + strings.ReplaceAll(expr, "$", `\$`) + "$"
+}
+
+func handleIMMarkdownHorizontalRule(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
+ return "---"
+}
+
+func handleIMMarkdownImage(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
+ href := firstNonEmpty(attrs["href"], attrs["src"], attrs["url"])
+ if href == "" {
+ return ""
+ }
+ alt := firstNonEmpty(attrs["alt"], attrs["name"], attrs["title"])
+ return fmt.Sprintf("", escapeMarkdownLinkText(alt), escapeMarkdownLinkDestination(href))
+}
+
+func handleIMMarkdownSource(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
+ name := strings.TrimSpace(attrs["name"])
+ if name == "" {
+ return ""
+ }
+ return imMarkdownInlineCode(name)
+}
+
+func handleIMMarkdownResourceLabel(label string) imMarkdownHandleFunc {
+ return func(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
+ return imMarkdownInlineCode(label)
+ }
+}
+
+func handleIMMarkdownConditionalResourceLabel(label string, attrNames ...string) imMarkdownHandleFunc {
+ return func(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
+ for _, attrName := range attrNames {
+ if strings.TrimSpace(attrs[attrName]) != "" {
+ return imMarkdownInlineCode(label)
+ }
+ }
+ return ""
+ }
+}
+
+func handleIMMarkdownSheet(segment string, _ string, attrs map[string]string, imCtx imMarkdownContext) string {
+ token := strings.TrimSpace(attrs["token"])
+ if token == "" {
+ return imMarkdownInlineCode(segment)
+ }
+ label := "sheet"
+ if sheetID := strings.TrimSpace(attrs["sheet-id"]); sheetID != "" {
+ label = "sheet " + sheetID
+ }
+ return markdownLink(label, strings.TrimRight(imCtx.baseURL, "/")+"/sheets/"+token)
+}
+
+func handleIMMarkdownBookmark(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
+ href := strings.TrimSpace(attrs["href"])
+ name := firstNonEmpty(attrs["name"], attrs["title"], markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), href)
+ if href == "" {
+ return name
+ }
+ return markdownLink(name, href)
+}
+
+func handleIMMarkdownStrong(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
+ body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
+ if body == "" {
+ return ""
+ }
+ return "**" + body + "**"
+}
+
+func handleIMMarkdownEmphasis(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
+ body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
+ if body == "" {
+ return ""
+ }
+ return "*" + body + "*"
+}
+
+func handleIMMarkdownDelete(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
+ body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
+ if body == "" {
+ return ""
+ }
+ return "~~" + body + "~~"
+}
+
+func handleIMMarkdownPlainInline(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
+ return strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
+}
+
+func handleIMMarkdownAnchor(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
+ href := strings.TrimSpace(attrs["href"])
+ text := firstNonEmpty(markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), attrs["name"], attrs["title"], href)
+ if href == "" {
+ return text
+ }
+ return markdownLink(text, href)
+}
+
+func handleIMMarkdownCite(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
+ switch strings.ToLower(strings.TrimSpace(attrs["type"])) {
+ case "user":
+ userID := firstNonEmpty(attrs["user-id"], attrs["open-id"], attrs["id"])
+ name := firstNonEmpty(attrs["user-name"], attrs["name"], markdownPlainText(inner), userID)
+ if userID == "" {
+ return name
+ }
+ return fmt.Sprintf(`%s `, html.EscapeString(userID), html.EscapeString(name))
+ case "doc":
+ title := firstNonEmpty(attrs["title"], attrs["name"], attrs["doc-id"], "document")
+ if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" {
+ return markdownLink(title, href)
+ }
+ docID := firstNonEmpty(attrs["doc-id"], attrs["token"])
+ if docID == "" {
+ return imMarkdownInlineCode(segment)
+ }
+ fileType := strings.Trim(strings.ToLower(firstNonEmpty(attrs["file-type"], "docx")), "/")
+ return markdownLink(title, strings.TrimRight(imCtx.baseURL, "/")+"/"+fileType+"/"+docID)
+ case "citation":
+ if text, href, ok := extractIMMarkdownInnerLink(inner); ok {
+ return markdownLink(text, href)
+ }
+ if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" {
+ return markdownLink(firstNonEmpty(attrs["title"], attrs["name"], href), href)
+ }
+ return markdownPlainText(convertToIMMarkdown(inner, imCtx))
+ default:
+ return imMarkdownInlineCode(segment)
+ }
+}
+
+func handleIMMarkdownTable(segment string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
+ // Rows and cells are matched with tag-depth tracking instead of non-greedy
+ // regex captures. A table nested inside a cell can contain its own and
+ // ; treating those as the outer row/cell boundary corrupts the table.
+ rowBodies := extractIMMarkdownElementBodies(inner, imMarkdownRowTagRE)
+ if len(rowBodies) == 0 {
+ return imMarkdownInlineCode(segment)
+ }
+
+ rows := make([][]string, 0, len(rowBodies))
+ for _, rowBody := range rowBodies {
+ cellBodies := extractIMMarkdownElementBodies(rowBody, imMarkdownCellTagRE)
+ if len(cellBodies) == 0 {
+ continue
+ }
+ row := make([]string, 0, len(cellBodies))
+ for _, cellBody := range cellBodies {
+ row = append(row, normalizeIMMarkdownTableCell(convertToIMMarkdown(cellBody, imCtx)))
+ }
+ rows = append(rows, row)
+ }
+ if len(rows) == 0 {
+ return imMarkdownInlineCode(segment)
+ }
+
+ cols := 0
+ for _, row := range rows {
+ if len(row) > cols {
+ cols = len(row)
+ }
+ }
+ var out strings.Builder
+ writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(rows[0], cols))
+ separator := make([]string, cols)
+ for i := range separator {
+ separator[i] = "-"
+ }
+ writeIMMarkdownTableRow(&out, separator)
+ for _, row := range rows[1:] {
+ writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(row, cols))
+ }
+ return strings.TrimRight(out.String(), "\n")
+}
+
+// extractIMMarkdownElementBodies returns the inner content of each top-level
+// element matched by tagRE. tagRE must expose the optional closing slash as its
+// first capture group, matching the row/cell regexes above.
+func extractIMMarkdownElementBodies(content string, tagRE *regexp.Regexp) []string {
+ var bodies []string
+ for offset := 0; offset < len(content); {
+ loc := tagRE.FindStringSubmatchIndex(content[offset:])
+ if loc == nil {
+ break
+ }
+ openStart := offset + loc[0]
+ openEnd := offset + loc[1]
+ opening := content[openStart:openEnd]
+ if loc[2] >= 0 && content[offset+loc[2]:offset+loc[3]] == "/" {
+ offset = openEnd
+ continue
+ }
+ if isSelfClosingIMMarkdownTag(opening) {
+ bodies = append(bodies, "")
+ offset = openEnd
+ continue
+ }
+ closeStart, closeEnd, found := findIMMarkdownElementClosingTag(content, openEnd, tagRE)
+ if !found {
+ break
+ }
+ bodies = append(bodies, content[openEnd:closeStart])
+ offset = closeEnd
+ }
+ return bodies
+}
+
+func findIMMarkdownElementClosingTag(content string, from int, tagRE *regexp.Regexp) (int, int, bool) {
+ depth := 1
+ for _, loc := range tagRE.FindAllStringSubmatchIndex(content[from:], -1) {
+ start := from + loc[0]
+ end := from + loc[1]
+ token := content[start:end]
+ if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
+ depth--
+ if depth == 0 {
+ return start, end, true
+ }
+ continue
+ }
+ if !isSelfClosingIMMarkdownTag(token) {
+ depth++
+ }
+ }
+ return 0, 0, false
+}
+
+func normalizeIMMarkdownTableCell(cell string) string {
+ const brPlaceholder = "\x00BR\x00"
+ cell = imMarkdownCellBreakRE.ReplaceAllString(cell, brPlaceholder)
+ cell = imMarkdownAnyTagRE.ReplaceAllStringFunc(cell, func(tag string) string {
+ name := strings.ToLower(strings.TrimPrefix(imMarkdownAnyTagRE.FindStringSubmatch(tag)[1], "/"))
+ if name == "at" {
+ return tag
+ }
+ return ""
+ })
+ cell = html.UnescapeString(cell)
+ cell = strings.ReplaceAll(cell, brPlaceholder, " ")
+ cell = strings.ReplaceAll(cell, " \n", " ")
+ cell = strings.ReplaceAll(cell, "\n", " ")
+ cell = strings.ReplaceAll(cell, "|", `\|`)
+ lines := strings.Fields(cell)
+ if len(lines) == 0 {
+ return ""
+ }
+ return strings.Join(lines, " ")
+}
+
+func writeIMMarkdownTableRow(out *strings.Builder, row []string) {
+ out.WriteString("| ")
+ out.WriteString(strings.Join(row, " | "))
+ out.WriteString(" |\n")
+}
+
+func padIMMarkdownTableRow(row []string, cols int) []string {
+ if len(row) >= cols {
+ return row
+ }
+ padded := make([]string, cols)
+ copy(padded, row)
+ return padded
+}
+
+func convertIMMarkdownListItems(inner string, ordered bool, imCtx imMarkdownContext) string {
+ var out strings.Builder
+ for offset, index := 0, 1; offset < len(inner); {
+ loc := imMarkdownLiOpenRE.FindStringIndex(inner[offset:])
+ if loc == nil {
+ break
+ }
+ openStart := offset + loc[0]
+ openEnd := offset + loc[1]
+ opening := inner[openStart:openEnd]
+ closeStart, closeEnd, found := findIMMarkdownListItemClosingTag(inner, openEnd)
+ if !found {
+ break
+ }
+ body := strings.TrimSpace(convertToIMMarkdown(inner[openEnd:closeStart], imCtx))
+ if body != "" {
+ prefix := "-"
+ if ordered {
+ attrs := parseIMMarkdownAttrs(opening)
+ if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" {
+ prefix = strings.TrimSuffix(seq, ".") + "."
+ } else {
+ prefix = fmt.Sprintf("%d.", index)
+ }
+ index++
+ }
+ out.WriteString(prefix)
+ out.WriteString(" ")
+ out.WriteString(indentIMMarkdownListContinuation(body))
+ out.WriteString("\n")
+ }
+ offset = closeEnd
+ }
+ return strings.TrimRight(out.String(), "\n")
+}
+
+func findIMMarkdownListItemClosingTag(content string, from int) (int, int, bool) {
+ depth := 1
+ for _, loc := range imMarkdownLiCloseRE.FindAllStringSubmatchIndex(content[from:], -1) {
+ start := from + loc[0]
+ end := from + loc[1]
+ token := content[start:end]
+ if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
+ depth--
+ if depth == 0 {
+ return start, end, true
+ }
+ continue
+ }
+ if !isSelfClosingIMMarkdownTag(token) {
+ depth++
+ }
+ }
+ return 0, 0, false
+}
+
+func indentIMMarkdownListContinuation(body string) string {
+ return strings.ReplaceAll(body, "\n", "\n ")
+}
+
+func extractIMMarkdownInnerLink(inner string) (string, string, bool) {
+ match := imMarkdownLinkRE.FindStringSubmatch(inner)
+ if match == nil {
+ return "", "", false
+ }
+ href := match[1]
+ if href == "" {
+ href = match[2]
+ }
+ text := strings.TrimSpace(markdownPlainText(match[3]))
+ if text == "" {
+ text = href
+ }
+ return text, html.UnescapeString(href), true
+}
+
+func markdownPlainText(s string) string {
+ s = imMarkdownCellBreakRE.ReplaceAllString(s, "\n")
+ s = imMarkdownAnyTagRE.ReplaceAllString(s, "")
+ return strings.TrimSpace(html.UnescapeString(s))
+}
+
+func markdownLinkLabelText(s string) string {
+ text := markdownPlainText(s)
+ if !strings.Contains(text, "---") {
+ return text
+ }
+ lines := strings.Split(text, "\n")
+ kept := lines[:0]
+ for _, line := range lines {
+ if strings.TrimSpace(line) == "---" {
+ continue
+ }
+ kept = append(kept, line)
+ }
+ return strings.TrimSpace(strings.Join(kept, "\n"))
+}
+
+func markdownLink(text, href string) string {
+ cleanHref := strings.TrimSpace(href)
+ return fmt.Sprintf("[%s](%s)", escapeMarkdownLinkText(firstNonEmpty(text, cleanHref)), escapeMarkdownLinkDestination(cleanHref))
+}
+
+func escapeMarkdownLinkText(text string) string {
+ text = strings.ReplaceAll(text, `\`, `\\`)
+ text = strings.ReplaceAll(text, `[`, `\[`)
+ text = strings.ReplaceAll(text, `]`, `\]`)
+ return text
+}
+
+func escapeMarkdownLinkDestination(href string) string {
+ // Lark/Feishu IM Markdown does not reliably parse raw spaces or parentheses
+ // inside (...). Keep URL delimiters like :/?#&= intact, but percent-encode
+ // characters that can terminate or split the Markdown link destination.
+ var out strings.Builder
+ out.Grow(len(href))
+ for i := 0; i < len(href); {
+ if href[i] == '%' {
+ if i+2 < len(href) && isHexDigit(href[i+1]) && isHexDigit(href[i+2]) {
+ out.WriteString(href[i : i+3])
+ i += 3
+ } else {
+ writePercentEncodedByte(&out, href[i])
+ i++
+ }
+ continue
+ }
+ if href[i] < utf8.RuneSelf {
+ if shouldPercentEncodeIMMarkdownURLByte(href[i]) {
+ writePercentEncodedByte(&out, href[i])
+ } else {
+ out.WriteByte(href[i])
+ }
+ i++
+ continue
+ }
+ r, size := utf8.DecodeRuneInString(href[i:])
+ if r == utf8.RuneError && size == 1 {
+ writePercentEncodedByte(&out, href[i])
+ i++
+ continue
+ }
+ for _, b := range []byte(href[i : i+size]) {
+ writePercentEncodedByte(&out, b)
+ }
+ i += size
+ }
+ return out.String()
+}
+
+func shouldPercentEncodeIMMarkdownURLByte(b byte) bool {
+ if b <= ' ' || b >= 0x7f {
+ return true
+ }
+ switch b {
+ case '(', ')', '<', '>', '"', '\\', '^', '`', '{', '|', '}':
+ return true
+ default:
+ return false
+ }
+}
+
+func writePercentEncodedByte(out *strings.Builder, b byte) {
+ const hex = "0123456789ABCDEF"
+ out.WriteByte('%')
+ out.WriteByte(hex[b>>4])
+ out.WriteByte(hex[b&0x0f])
+}
+
+func isHexDigit(b byte) bool {
+ return ('0' <= b && b <= '9') || ('a' <= b && b <= 'f') || ('A' <= b && b <= 'F')
+}
+
+func imMarkdownInlineCode(s string) string {
+ maxRun := 0
+ run := 0
+ for _, r := range s {
+ if r == '`' {
+ run++
+ if run > maxRun {
+ maxRun = run
+ }
+ continue
+ }
+ run = 0
+ }
+ fence := strings.Repeat("`", maxRun+1)
+ if strings.HasPrefix(s, "`") || strings.HasSuffix(s, "`") {
+ return fence + " " + s + " " + fence
+ }
+ return fence + s + fence
+}
+
+func imMarkdownFencedCode(code, lang string) string {
+ maxRun := 0
+ for _, line := range strings.Split(code, "\n") {
+ if run := leadingBacktickRun(line); run > maxRun {
+ maxRun = run
+ }
+ }
+ fenceLen := maxRun + 1
+ if fenceLen < 3 {
+ fenceLen = 3
+ }
+ fence := strings.Repeat("`", fenceLen)
+ return fence + strings.TrimSpace(lang) + "\n" + strings.Trim(code, "\n") + "\n" + fence
+}
+
+func leadingBacktickRun(s string) int {
+ run := 0
+ for _, r := range s {
+ if r != '`' {
+ break
+ }
+ run++
+ }
+ return run
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
diff --git a/shortcuts/doc/docs_fetch_im_markdown_test.go b/shortcuts/doc/docs_fetch_im_markdown_test.go
new file mode 100644
index 00000000..971b4878
--- /dev/null
+++ b/shortcuts/doc/docs_fetch_im_markdown_test.go
@@ -0,0 +1,1305 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package doc
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestApplyFetchIMMarkdown(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ data map[string]interface{}
+ docInput string
+ want map[string]interface{}
+ }{
+ {
+ name: "missing document leaves data unchanged",
+ data: map[string]interface{}{
+ "content": `Roadmap `,
+ },
+ docInput: "https://tenant.example.com/docx/doc_token",
+ want: map[string]interface{}{
+ "content": `Roadmap `,
+ },
+ },
+ {
+ name: "non string content leaves data unchanged",
+ data: map[string]interface{}{
+ "document": map[string]interface{}{
+ "content": 123,
+ },
+ },
+ docInput: "https://tenant.example.com/docx/doc_token",
+ want: map[string]interface{}{
+ "document": map[string]interface{}{
+ "content": 123,
+ },
+ },
+ },
+ {
+ name: "converts content with tenant base url",
+ data: map[string]interface{}{
+ "document": map[string]interface{}{
+ "content": `Roadmap ` + "\n" + ` `,
+ },
+ },
+ docInput: "https://tenant.example.com/docx/doc_token",
+ want: map[string]interface{}{
+ "document": map[string]interface{}{
+ "content": "# Roadmap\n[sheet S1](https://tenant.example.com/sheets/sht_token)",
+ },
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ applyFetchIMMarkdown(tt.data, tt.docInput)
+ if !reflect.DeepEqual(tt.data, tt.want) {
+ t.Fatalf("data = %#v, want %#v", tt.data, tt.want)
+ }
+ })
+ }
+}
+
+func TestConvertToIMMarkdownTitle(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCases(t, []imMarkdownCase{
+ {
+ name: "plain title",
+ input: `Roadmap `,
+ want: "# Roadmap",
+ },
+ {
+ name: "trim title whitespace",
+ input: "\n Roadmap \n ",
+ want: "# Roadmap",
+ },
+ {
+ name: "convert title inner markup",
+ input: `Bold Title `,
+ want: "# **Bold** Title",
+ },
+ {
+ name: "empty title",
+ input: ` `,
+ want: "",
+ },
+ {
+ name: "title followed by text",
+ input: `Roadmap tail`,
+ want: "# Roadmaptail",
+ },
+ {
+ name: "uppercase title is handled case-insensitively",
+ input: `Roadmap `,
+ want: "# Roadmap",
+ },
+ {
+ name: "missing closing title is preserved",
+ input: `beforeRoadmap`,
+ want: `beforeRoadmap`,
+ },
+ })
+}
+
+func TestConvertToIMMarkdownCallout(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCases(t, []imMarkdownCase{
+ {
+ name: "emoji and body",
+ input: `Read **this**. `,
+ want: "---\n💡 Read **this**.\n---",
+ },
+ {
+ name: "body without emoji",
+ input: `Plain body `,
+ want: "---\nPlain body\n---",
+ },
+ {
+ name: "emoji only",
+ input: ` `,
+ want: "---\n✅\n---",
+ },
+ {
+ name: "empty callout",
+ input: ` `,
+ want: "---\n---",
+ },
+ {
+ name: "nested callout",
+ input: `Outer Inner `,
+ want: "---\n✅ Outer ---\n💡 Inner\n---\n---",
+ },
+ {
+ name: "callout contains registered tags",
+ input: ` `,
+ want: "---\n📝 [Spec](https://example.com)\n---",
+ },
+ {
+ name: "callout contains grid and cite",
+ input: ` `,
+ want: "---\n📣 Alice \n[Spec](https://example.com)\n---",
+ },
+ {
+ name: "same-name nested callout with trailing text",
+ input: `ab c d`,
+ want: "---\n1 a---\n2 b\n---c\n---d",
+ },
+ {
+ name: "missing closing callout is preserved",
+ input: `beforebody`,
+ want: `beforebody`,
+ },
+ })
+}
+
+func TestConvertToIMMarkdownBlockquote(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCases(t, []imMarkdownCase{
+ {
+ name: "single paragraph",
+ input: `quote link
`,
+ want: "> quote [link](https://example.com)",
+ },
+ {
+ name: "multiple paragraphs keep line breaks",
+ input: `first
second
`,
+ want: "> first\n> **second**",
+ },
+ {
+ name: "nested blockquote keeps nested markers",
+ input: `outer
inner
`,
+ want: "> outer\n> > inner",
+ },
+ {
+ name: "blank line keeps quote marker",
+ input: "first\n\nsecond ",
+ want: "> first\n>\n> second",
+ },
+ {
+ name: "empty blockquote",
+ input: ` `,
+ want: "",
+ },
+ {
+ name: "plain adjacent paragraphs outside blockquote stay compact",
+ input: `first
second
`,
+ want: "firstsecond",
+ },
+ })
+}
+
+func TestConvertToIMMarkdownParagraphHeadingAndListItemEdges(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCases(t, []imMarkdownCase{
+ {
+ name: "empty heading",
+ input: ` `,
+ want: "",
+ },
+ {
+ name: "empty paragraph",
+ input: `
`,
+ want: "",
+ },
+ {
+ name: "top level list item uses seq",
+ input: "first\nsecond ",
+ want: "7. first\n second\n",
+ },
+ {
+ name: "top level empty list item",
+ input: ` `,
+ want: "",
+ },
+ {
+ name: "unordered list skips non item text and empty items",
+ input: ``,
+ want: "- first\n- second",
+ },
+ {
+ name: "unclosed list item stops list scan",
+ input: ``,
+ want: "- first",
+ },
+ })
+}
+
+func TestConvertToIMMarkdownGridAndColumn(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCases(t, []imMarkdownCase{
+ {
+ name: "two columns",
+ input: `Left Right `,
+ want: "Left\nRight",
+ },
+ {
+ name: "column converts nested registered tags",
+ input: ` `,
+ want: "[Spec](https://example.com)\n",
+ },
+ {
+ name: "empty column",
+ input: ` `,
+ want: "",
+ },
+ {
+ name: "nested grid",
+ input: `A B C `,
+ want: "A\nB\nC",
+ },
+ {
+ name: "grid inside callout",
+ input: `A B `,
+ want: "---\n📌 A\nB\n---",
+ },
+ {
+ name: "adjacent grids do not merge",
+ input: `A B `,
+ want: "AB",
+ },
+ {
+ name: "column with nested callout keeps recursive output",
+ input: `Tip `,
+ want: "---\n💡 Tip\n---\n",
+ },
+ {
+ name: "missing closing grid is preserved",
+ input: `A `,
+ want: `A `,
+ },
+ })
+}
+
+func TestConvertToIMMarkdownTable(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCases(t, []imMarkdownCase{
+ {
+ name: "basic table",
+ input: ``,
+ want: "| A | B |\n| - | - |\n| 1 | 2 |",
+ },
+ {
+ name: "table strips attrs and preserves cell line break",
+ input: ``,
+ want: "| A | B |\n| - | - |\n| 1 | **two** lines |",
+ },
+ {
+ name: "table escapes pipe",
+ input: ``,
+ want: "| A\\|B |\n| - |\n| x\\|y |",
+ },
+ {
+ name: "table pads ragged rows",
+ input: ``,
+ want: "| A | B |\n| - | - |\n| 1 | |",
+ },
+ {
+ name: "table converts nested cite",
+ input: ``,
+ want: "| User |\n| - |\n| Alice |",
+ },
+ {
+ name: "table converts nested bookmark and sheet",
+ input: ``,
+ want: "| Link | Sheet |\n| - | - |\n| [Spec](https://example.com) | [sheet S1](https://larkoffice.com/sheets/sht_1) |",
+ },
+ {
+ name: "table strips nested unknown html but preserves text",
+ input: ``,
+ want: "| A |\n| - |\n| red under |",
+ },
+ {
+ name: "table normalizes markdown hard breaks",
+ input: "",
+ want: "| A |\n| - |\n| line1 line2 |",
+ },
+ {
+ name: "table cell keeps nested table whole",
+ input: ``,
+ want: "| Outer |\n| - |\n| before \\| Inner \\| \\| - \\| \\| x \\| after |",
+ },
+ {
+ name: "table with only data row treats first row as header",
+ input: ``,
+ want: "| A | B |\n| - | - |",
+ },
+ {
+ name: "table without rows falls back to inline code",
+ input: ``,
+ want: "``",
+ },
+ {
+ name: "table row without cells falls back to inline code",
+ input: ``,
+ want: "``",
+ },
+ {
+ name: "table self closing row falls back to inline code",
+ input: ``,
+ want: "``",
+ },
+ {
+ name: "table empty cell stays empty",
+ input: ``,
+ want: "| |\n| - |",
+ },
+ {
+ name: "missing closing table is preserved",
+ input: `beforeA `,
+ want: `beforeA `,
+ },
+ })
+}
+
+func TestIMMarkdownElementExtractionEdges(t *testing.T) {
+ t.Parallel()
+
+ bodies := extractIMMarkdownElementBodies(` x open`, imMarkdownRowTagRE)
+ if want := []string{"", "x "}; !reflect.DeepEqual(bodies, want) {
+ t.Fatalf("extractIMMarkdownElementBodies() = %#v, want %#v", bodies, want)
+ }
+
+ if _, _, ok := findIMMarkdownElementClosingTag(` x`, len(" "), imMarkdownRowTagRE); ok {
+ t.Fatal("findIMMarkdownElementClosingTag() found closing tag, want false")
+ }
+
+ start, end, ok := findIMMarkdownListItemClosingTag(`outer tail`, len(""))
+ if !ok {
+ t.Fatal("findIMMarkdownListItemClosingTag() did not find closing tag")
+ }
+ if got, want := ` outer tail`[start:end], ""; got != want {
+ t.Fatalf("closing tag = %q, want %q", got, want)
+ }
+
+ if _, _, ok := findIMMarkdownListItemClosingTag(`open`, len(" ")); ok {
+ t.Fatal("findIMMarkdownListItemClosingTag() found closing tag, want false")
+ }
+
+ start, end, ok = findIMMarkdownListItemClosingTag(` outer inner tail`, len(""))
+ if !ok {
+ t.Fatal("findIMMarkdownListItemClosingTag() did not find nested closing tag")
+ }
+ if got, want := ` outer inner tail`[start:end], ""; got != want {
+ t.Fatalf("nested closing tag = %q, want %q", got, want)
+ }
+
+ if got := convertIMMarkdownListItems("plain text", false, imMarkdownContext{}); got != "" {
+ t.Fatalf("convertIMMarkdownListItems() = %q, want empty", got)
+ }
+}
+
+func TestNormalizeIMMarkdownTableCellStripsUnknownTags(t *testing.T) {
+ t.Parallel()
+
+ got := normalizeIMMarkdownTableCell(`red `)
+ if want := "red"; got != want {
+ t.Fatalf("normalizeIMMarkdownTableCell() = %q, want %q", got, want)
+ }
+}
+
+func TestConvertToIMMarkdownDiscardTags(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCases(t, []imMarkdownCase{
+ {
+ name: "figure discarded",
+ input: `beforehidden after`,
+ want: "beforeafter",
+ },
+ {
+ name: "figure with source discarded",
+ input: ` `,
+ want: "",
+ },
+ {
+ name: "self-closing source discarded",
+ input: `a b`,
+ want: "ab",
+ },
+ {
+ name: "source name becomes inline code",
+ input: "ab",
+ want: "a``report`v1`.pdf``b",
+ },
+ {
+ name: "button discarded",
+ input: `aClick b`,
+ want: "ab",
+ },
+ {
+ name: "time discarded",
+ input: `a b`,
+ want: "ab",
+ },
+ {
+ name: "colgroup discarded",
+ input: `a b`,
+ want: "ab",
+ },
+ {
+ name: "col discarded",
+ input: `a b`,
+ want: "ab",
+ },
+ {
+ name: "self-closing button discarded",
+ input: `a b`,
+ want: "ab",
+ },
+ {
+ name: "missing closing discard tag is preserved",
+ input: `ahidden`,
+ want: `ahidden`,
+ },
+ })
+}
+
+func TestConvertToIMMarkdownWhiteboard(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCases(t, []imMarkdownCase{
+ {
+ name: "paired whiteboard",
+ input: ` `,
+ want: "` `",
+ },
+ {
+ name: "self-closing whiteboard",
+ input: ` `,
+ want: "``",
+ },
+ {
+ name: "whiteboard with backticks",
+ input: " ",
+ want: "`` ``",
+ },
+ {
+ name: "whiteboard preserves inner text as opaque",
+ input: `not exported `,
+ want: "`not exported `",
+ },
+ {
+ name: "missing closing whiteboard is preserved",
+ input: ``,
+ want: ``,
+ },
+ })
+}
+
+func TestConvertToIMMarkdownSheet(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCasesWithContext(t, imMarkdownContext{baseURL: "https://bytedance.larkoffice.com"}, []imMarkdownCase{
+ {
+ name: "sheet with sheet id",
+ input: ` `,
+ want: "[sheet S1](https://bytedance.larkoffice.com/sheets/sht_token)",
+ },
+ {
+ name: "sheet without sheet id",
+ input: ` `,
+ want: "[sheet](https://bytedance.larkoffice.com/sheets/sht_token)",
+ },
+ {
+ name: "sheet without token falls back to inline code",
+ input: ` `,
+ want: "` `",
+ },
+ {
+ name: "self-closing sheet",
+ input: ` `,
+ want: "[sheet S1](https://bytedance.larkoffice.com/sheets/sht_token)",
+ },
+ {
+ name: "sheet token is trimmed",
+ input: ` `,
+ want: "[sheet S1](https://bytedance.larkoffice.com/sheets/sht_token)",
+ },
+ {
+ name: "sheet inside text",
+ input: `before after`,
+ want: "before [sheet](https://bytedance.larkoffice.com/sheets/sht_token) after",
+ },
+ })
+}
+
+func TestConvertToIMMarkdownBookmark(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCases(t, []imMarkdownCase{
+ {
+ name: "name and href",
+ input: ` `,
+ want: "[Example](https://example.com)",
+ },
+ {
+ name: "title fallback",
+ input: ` `,
+ want: "[Example](https://example.com)",
+ },
+ {
+ name: "inner text fallback",
+ input: `Example `,
+ want: "[Example](https://example.com)",
+ },
+ {
+ name: "missing href returns label",
+ input: ` `,
+ want: "Example",
+ },
+ {
+ name: "escaped link label",
+ input: ` `,
+ want: "[A \\[B\\]](https://example.com)",
+ },
+ {
+ name: "href is percent encoded",
+ input: ` `,
+ want: "[Spec](https://example.com/wiki/A%20B%20%28draft%29?q=x%20y#frag%281%29)",
+ },
+ {
+ name: "href keeps existing percent escapes",
+ input: ` `,
+ want: "[Spec](https://example.com/wiki/A%20B)",
+ },
+ {
+ name: "href escapes invalid percent and unicode",
+ input: ` `,
+ want: "[Spec](https://example.com/wiki/%E7%A0%94%E5%8F%91%25zz?x=1%25)",
+ },
+ {
+ name: "href escapes markdown delimiter bytes",
+ input: " ",
+ want: "[Spec](https://example.com/a%3Cb%3E%7Cc%60d)",
+ },
+ {
+ name: "inner registered tag fallback",
+ input: ` `,
+ want: "[Alice](https://example.com)",
+ },
+ {
+ name: "href fallback as label",
+ input: ` `,
+ want: "[https://example.com](https://example.com)",
+ },
+ {
+ name: "self-closing bookmark without href",
+ input: ` `,
+ want: "Example",
+ },
+ })
+}
+
+func TestConvertToIMMarkdownInlineEdges(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCases(t, []imMarkdownCase{
+ {
+ name: "empty strong emphasis and delete",
+ input: ` `,
+ want: "",
+ },
+ {
+ name: "anchor without href returns text",
+ input: `plain text `,
+ want: "plain **text**",
+ },
+ {
+ name: "anchor without text falls back to href",
+ input: ` `,
+ want: "[https://example.com/a b](https://example.com/a%20b)",
+ },
+ {
+ name: "latex escapes dollars",
+ input: `price=$5 `,
+ want: "$price=\\$5$",
+ },
+ {
+ name: "empty latex",
+ input: ` `,
+ want: "",
+ },
+ {
+ name: "image missing href",
+ input: ` `,
+ want: "",
+ },
+ {
+ name: "image uses src and title fallback",
+ input: ` `,
+ want: "![A \\[img\\]](https://example.com/i%201.png)",
+ },
+ {
+ name: "plain fenced code",
+ input: `plain `,
+ want: "```\nplain\n```",
+ },
+ {
+ name: "code inline trims nested markup",
+ input: `x `,
+ want: "`x`",
+ },
+ })
+}
+
+func TestConvertToIMMarkdownCiteUser(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCases(t, []imMarkdownCase{
+ {
+ name: "user id and name",
+ input: ` `,
+ want: `Alice `,
+ },
+ {
+ name: "open id fallback",
+ input: ` `,
+ want: `Bob `,
+ },
+ {
+ name: "name falls back to user id",
+ input: ` `,
+ want: `ou_abc `,
+ },
+ {
+ name: "missing user id returns name",
+ input: ` `,
+ want: "Alice",
+ },
+ {
+ name: "escape at XML",
+ input: ` `,
+ want: `A&B `,
+ },
+ {
+ name: "inner text fallback when attrs missing name",
+ input: `Alice `,
+ want: `Alice `,
+ },
+ {
+ name: "self-closing user cite",
+ input: ` `,
+ want: `Alice `,
+ },
+ })
+}
+
+func TestConvertToIMMarkdownCiteDoc(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCasesWithContext(t, imMarkdownContext{baseURL: "https://bytedance.larkoffice.com"}, []imMarkdownCase{
+ {
+ name: "doc id to link",
+ input: ` `,
+ want: "[Spec](https://bytedance.larkoffice.com/docx/doc_token)",
+ },
+ {
+ name: "href wins",
+ input: ` `,
+ want: "[Spec](https://example.com/doc%20%28draft%29)",
+ },
+ {
+ name: "default title and file type",
+ input: ` `,
+ want: "[document](https://bytedance.larkoffice.com/docx/doc_token)",
+ },
+ {
+ name: "missing doc id falls back to inline code",
+ input: ` `,
+ want: "` `",
+ },
+ {
+ name: "wiki file type link",
+ input: ` `,
+ want: "[Wiki](https://bytedance.larkoffice.com/wiki/wiki_token)",
+ },
+ {
+ name: "doc title is escaped",
+ input: ` `,
+ want: "[A \\[B\\]](https://bytedance.larkoffice.com/docx/doc_token)",
+ },
+ })
+}
+
+func TestConvertToIMMarkdownCiteCitation(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCases(t, []imMarkdownCase{
+ {
+ name: "inner anchor",
+ input: `Ref `,
+ want: "[Ref](https://example.com/ref)",
+ },
+ {
+ name: "href attr",
+ input: ` `,
+ want: "[Ref](https://example.com/ref)",
+ },
+ {
+ name: "plain inner fallback",
+ input: `Plain Ref `,
+ want: "Plain Ref",
+ },
+ {
+ name: "inner anchor text strips markup",
+ input: `Ref `,
+ want: "[Ref](https://example.com/ref)",
+ },
+ {
+ name: "single quoted inner anchor falls back to href text",
+ input: ` `,
+ want: "[https://example.com/ref](https://example.com/ref)",
+ },
+ {
+ name: "href attr falls back to href label",
+ input: ` `,
+ want: "[https://example.com/ref](https://example.com/ref)",
+ },
+ })
+}
+
+func TestEscapeMarkdownLinkDestinationInvalidUTF8(t *testing.T) {
+ t.Parallel()
+
+ got := escapeMarkdownLinkDestination(string([]byte{'a', 0xff, 'b'}))
+ if want := "a%FFb"; got != want {
+ t.Fatalf("escapeMarkdownLinkDestination() = %q, want %q", got, want)
+ }
+}
+
+func TestConvertToIMMarkdownCiteUnknown(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCases(t, []imMarkdownCase{
+ {
+ name: "unknown paired cite",
+ input: `x `,
+ want: "`x `",
+ },
+ {
+ name: "unknown self-closing cite",
+ input: ` `,
+ want: "``",
+ },
+ })
+}
+
+func TestConvertToIMMarkdownScannerBoundaries(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCases(t, []imMarkdownCase{
+ {
+ name: "unknown tag preserved with known child untouched",
+ input: ` `,
+ want: `[Spec](https://example.com) `,
+ },
+ {
+ name: "registered tag attributes single quotes",
+ input: ` `,
+ want: "[Spec](https://example.com)",
+ },
+ {
+ name: "registered tag name with leading text",
+ input: `alphaBeta gamma`,
+ want: "alpha# Betagamma",
+ },
+ {
+ name: "xml comment is preserved",
+ input: `aT `,
+ want: "a# T",
+ },
+ {
+ name: "br is preserved",
+ input: `a b`,
+ want: "a b",
+ },
+ {
+ name: "malformed attribute still allows handler",
+ input: `Inner `,
+ want: "[Inner](https://example.com)",
+ },
+ })
+}
+
+func TestConvertToIMMarkdownCompositeNesting(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCasesWithContext(t, imMarkdownContext{baseURL: "https://tenant.example.com"}, []imMarkdownCase{
+ {
+ name: "callout grid table and resources",
+ input: ` `,
+ want: "---\n📌 | Owner | Doc |\n| - | - |\n| Alice | [Spec](https://tenant.example.com/docx/doc_1) |\n[sheet S1](https://tenant.example.com/sheets/sht_1)\n---",
+ },
+ {
+ name: "grid inside table cell",
+ input: ``,
+ want: "| Outer |\n| - |\n| A B |",
+ },
+ {
+ name: "table inside table cell",
+ input: ``,
+ want: "| Outer | Tail |\n| - | - |\n| \\| Inner \\| \\| - \\| \\| x \\| | done |",
+ },
+ {
+ name: "bookmark wraps callout fallback text",
+ input: `Tip `,
+ want: "[💡 Tip](https://example.com)",
+ },
+ })
+}
+
+func TestConvertToIMMarkdownUnclosedFragments(t *testing.T) {
+ t.Parallel()
+
+ assertIMMarkdownCases(t, []imMarkdownCase{
+ {
+ name: "unclosed title preserves nested registered tag",
+ input: `before `,
+ want: `before `,
+ },
+ {
+ name: "unclosed callout preserves nested registered tag",
+ input: `before `,
+ want: `before `,
+ },
+ {
+ name: "unclosed grid preserves closed child",
+ input: `beforeA `,
+ want: `beforeA `,
+ },
+ {
+ name: "unclosed column preserves nested registered tag",
+ input: `before `,
+ want: `before `,
+ },
+ {
+ name: "unclosed table preserves nested cite",
+ input: `before `,
+ want: `before `,
+ },
+ {
+ name: "unclosed figure preserves nested source",
+ input: `before `,
+ want: `before `,
+ },
+ {
+ name: "unclosed whiteboard preserves nested registered tag",
+ input: `before `,
+ want: `before `,
+ },
+ {
+ name: "unclosed sheet preserves nested registered tag",
+ input: `before `,
+ want: `before `,
+ },
+ {
+ name: "unclosed bookmark preserves nested cite",
+ input: `before `,
+ want: `before `,
+ },
+ {
+ name: "unclosed cite preserves inner anchor",
+ input: `beforeRef `,
+ want: `beforeRef `,
+ },
+ })
+}
+
+func TestConvertToIMMarkdownDeepRegisteredContainers(t *testing.T) {
+ t.Parallel()
+
+ deepGrid := "leaf"
+ for i := 0; i < 32; i++ {
+ deepGrid = "" + deepGrid + " "
+ }
+ if got := convertToIMMarkdown(deepGrid, imMarkdownContext{}); got != "leaf" {
+ t.Fatalf("deep grid conversion = %q, want %q", got, "leaf")
+ }
+
+ deepCallout := "leaf"
+ for i := 0; i < 16; i++ {
+ deepCallout = `` + deepCallout + ` `
+ }
+ got := convertToIMMarkdown(deepCallout, imMarkdownContext{})
+ if !strings.Contains(got, "leaf") {
+ t.Fatalf("deep callout conversion missing leaf:\n%s", got)
+ }
+ if count := strings.Count(got, "💡"); count != 16 {
+ t.Fatalf("deep callout emoji count = %d, want 16\n%s", count, got)
+ }
+}
+
+func TestConvertToIMMarkdownDocumentExpectedTagsAndEscaping(t *testing.T) {
+ t.Parallel()
+
+ imCtx := imMarkdownContext{baseURL: "https://bytedance.larkoffice.com"}
+ input := strings.Join([]string{
+ `Roadmap Q1 `,
+ `Deep Heading `,
+ `plain next Bold Italic Gone Under Plain A [B]
`,
+ `quote Card
`,
+ ``,
+ `one three `,
+ `fmt.Println("hi")` + "\n```" + ` `,
+ `` + "`edge`" + ` E=mc^2
`,
+ ` `,
+ ` `,
+ ` `,
+ }, "\n")
+
+ want := strings.Join([]string{
+ `# Roadmap Q1`,
+ `###### Deep Heading`,
+ `plain next **Bold** *Italic* ~~Gone~~ Under Plain [A \[B\]](https://example.com/a%28b%29)`,
+ `> quote [Card](https://example.com/card)`,
+ `- first`,
+ `- **second**`,
+ `1. one`,
+ `3. three`,
+ "````Go\nfmt.Println(\"hi\")\n```\n````",
+ "`` `edge` `` $E=mc^2$ --- ![A \\[img\\]](https://example.com/i%281%29.png)",
+ "``report`v1`.pdf``",
+ "`任务``群聊卡片`",
+ "`多维表格``多维表格``OKR`",
+ }, "\n")
+
+ if got := convertToIMMarkdown(input, imCtx); got != want {
+ t.Fatalf("convertToIMMarkdown() = %q, want %q", got, want)
+ }
+}
+
+func TestConvertToIMMarkdownMixedDocumentSmoke(t *testing.T) {
+ t.Parallel()
+
+ imCtx := imMarkdownContext{baseURL: "https://bytedance.larkoffice.com"}
+ input := strings.Join([]string{
+ `Roadmap `,
+ `### Left Right `,
+ ``,
+ ` `,
+ ` `,
+ `Ref `,
+ ` `,
+ ` `,
+ }, "\n")
+
+ got := convertToIMMarkdown(input, imCtx)
+
+ for _, want := range []string{
+ "# Roadmap",
+ "### Left",
+ "Right",
+ "| A | B |\n| - | - |\n| 1 | **two** lines |",
+ `Alice `,
+ "[Spec](https://bytedance.larkoffice.com/docx/doc_token)",
+ "[Ref](https://example.com/ref)",
+ "[sheet S1](https://bytedance.larkoffice.com/sheets/sht_token)",
+ } {
+ if !strings.Contains(got, want) {
+ t.Fatalf("converted content missing %q:\n%s", want, got)
+ }
+ }
+ for _, dropped := range []string{" first\n>\n> second",
+ },
+ {
+ name: "empty latex",
+ got: handleIMMarkdownLatex("", " ", nil, ctx),
+ want: "",
+ },
+ {
+ name: "image without URL",
+ got: handleIMMarkdownImage("", "", map[string]string{"alt": "A"}, ctx),
+ want: "",
+ },
+ {
+ name: "empty strong",
+ got: handleIMMarkdownStrong("", " ", nil, ctx),
+ want: "",
+ },
+ {
+ name: "empty emphasis",
+ got: handleIMMarkdownEmphasis("", " ", nil, ctx),
+ want: "",
+ },
+ {
+ name: "empty delete",
+ got: handleIMMarkdownDelete("", " ", nil, ctx),
+ want: "",
+ },
+ {
+ name: "anchor without href",
+ got: handleIMMarkdownAnchor("", "plain ", nil, ctx),
+ want: "**plain**",
+ },
+ {
+ name: "table skips rows without cells",
+ got: handleIMMarkdownTable("", " ", nil, ctx),
+ want: "``",
+ },
+ {
+ name: "empty normalized table cell",
+ got: normalizeIMMarkdownTableCell(" "),
+ want: "",
+ },
+ {
+ name: "plain fenced code uses minimum fence",
+ got: imMarkdownFencedCode("plain", ""),
+ want: "```\nplain\n```",
+ },
+ }
+
+ for _, tt := range cases {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ if tt.got != tt.want {
+ t.Fatalf("got %q, want %q", tt.got, tt.want)
+ }
+ })
+ }
+}
+
+func TestIMMarkdownExtractionAndListBreakBranches(t *testing.T) {
+ t.Parallel()
+
+ rowBodies := extractIMMarkdownElementBodies(`open`, imMarkdownRowTagRE)
+ if want := []string{""}; !reflect.DeepEqual(rowBodies, want) {
+ t.Fatalf("extractIMMarkdownElementBodies() = %#v, want %#v", rowBodies, want)
+ }
+
+ if _, _, ok := findIMMarkdownElementClosingTag(` open `, len(""), imMarkdownRowTagRE); ok {
+ t.Fatal("findIMMarkdownElementClosingTag() found closing tag, want false")
+ }
+
+ if got := convertIMMarkdownListItems("", false, imMarkdownContext{}); got != "" {
+ t.Fatalf("empty list conversion = %q, want empty", got)
+ }
+ if got := convertIMMarkdownListItems("open", false, imMarkdownContext{}); got != "" {
+ t.Fatalf("unclosed list conversion = %q, want empty", got)
+ }
+ if _, _, ok := findIMMarkdownListItemClosingTag(` outer inner `, len("")); ok {
+ t.Fatal("findIMMarkdownListItemClosingTag() found closing tag for unbalanced nested item")
+ }
+}
+
+func TestIMMarkdownLinkAndEncodingFallbackBranches(t *testing.T) {
+ t.Parallel()
+
+ text, href, ok := extractIMMarkdownInnerLink(` `)
+ if !ok {
+ t.Fatal("extractIMMarkdownInnerLink() ok = false, want true")
+ }
+ if text != "https://example.com/ref" || href != "https://example.com/ref" {
+ t.Fatalf("inner link = (%q, %q), want href fallback", text, href)
+ }
+
+ if got := escapeMarkdownLinkDestination("a%zz%"); got != "a%25zz%25" {
+ t.Fatalf("escaped invalid percent = %q, want %q", got, "a%25zz%25")
+ }
+ if got := escapeMarkdownLinkDestination("研发"); got != "%E7%A0%94%E5%8F%91" {
+ t.Fatalf("escaped unicode = %q, want encoded UTF-8 bytes", got)
+ }
+ if got := escapeMarkdownLinkDestination(string([]byte{'a', 0xff, 'b'})); got != "a%FFb" {
+ t.Fatalf("escaped invalid UTF-8 = %q, want %q", got, "a%FFb")
+ }
+}
+
+type imMarkdownCase struct {
+ name string
+ input string
+ want string
+}
+
+func assertIMMarkdownCases(t *testing.T, cases []imMarkdownCase) {
+ t.Helper()
+ assertIMMarkdownCasesWithContext(t, imMarkdownContext{baseURL: "https://larkoffice.com"}, cases)
+}
+
+func assertIMMarkdownCasesWithContext(t *testing.T, imCtx imMarkdownContext, cases []imMarkdownCase) {
+ t.Helper()
+
+ for _, tt := range cases {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ if got := convertToIMMarkdown(tt.input, imCtx); got != tt.want {
+ t.Fatalf("convertToIMMarkdown() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go
index b3e4f2fc..16ca133f 100644
--- a/shortcuts/doc/docs_fetch_v2.go
+++ b/shortcuts/doc/docs_fetch_v2.go
@@ -17,7 +17,7 @@ import (
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
return []common.Flag{
- {Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}},
+ {Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export, im-markdown downgrades residual DocxXML fragments for IM messages", Default: "xml", Enum: []string{"xml", "markdown", "im-markdown"}},
{Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
{Name: "lang", Desc: "user cite display language, e.g. en-US, zh-CN, ja-JP"},
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
@@ -72,6 +72,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
}
+ if isIMMarkdownFetch(runtime) {
+ applyFetchIMMarkdown(data, runtime.Str("doc"))
+ }
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
if doc, ok := data["document"].(map[string]interface{}); ok {
@@ -85,7 +88,7 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
- "format": runtime.Str("doc-format"),
+ "format": effectiveFetchFormat(runtime),
}
if v := runtime.Int("revision-id"); v > 0 {
body["revision_id"] = v
@@ -122,6 +125,14 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
return body
}
+func effectiveFetchFormat(runtime *common.RuntimeContext) string {
+ format := strings.TrimSpace(runtime.Str("doc-format"))
+ if format == "im-markdown" {
+ return "markdown"
+ }
+ return format
+}
+
func resolveFetchLang(runtime *common.RuntimeContext) string {
if runtime.Changed("lang") {
return strings.TrimSpace(runtime.Str("lang"))
diff --git a/shortcuts/doc/docs_fetch_v2_test.go b/shortcuts/doc/docs_fetch_v2_test.go
index 1210f0ac..63aba4a7 100644
--- a/shortcuts/doc/docs_fetch_v2_test.go
+++ b/shortcuts/doc/docs_fetch_v2_test.go
@@ -6,9 +6,12 @@ package doc
import (
"context"
"encoding/json"
+ "errors"
+ "reflect"
"strings"
"testing"
+ "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -40,6 +43,23 @@ func TestBuildCreateBodyIncludesSceneFromContext(t *testing.T) {
}
}
+func TestBuildCreateBodyPrependsTitleToContent(t *testing.T) {
+ t.Parallel()
+
+ runtime := newCreateBodyTestRuntime(context.Background())
+ if err := runtime.Cmd.Flags().Set("title", "A & B "); err != nil {
+ t.Fatalf("set title: %v", err)
+ }
+ if err := runtime.Cmd.Flags().Set("content", "## Body"); err != nil {
+ t.Fatalf("set content: %v", err)
+ }
+
+ body := buildCreateBody(runtime)
+ if got, want := body["content"], "A & B <C> \n## Body"; got != want {
+ t.Fatalf("content = %#v, want %q", got, want)
+ }
+}
+
func TestBuildUpdateBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
@@ -104,6 +124,369 @@ func TestBuildFetchBodyExplicitBlankLangOmitsLang(t *testing.T) {
}
}
+func TestBuildFetchBodyIncludesRevisionAndFullDetail(t *testing.T) {
+ t.Parallel()
+
+ runtime := newFetchBodyTestRuntime(context.Background())
+ mustSetFetchFlag(t, runtime, "revision-id", "42")
+ mustSetFetchFlag(t, runtime, "detail", "full")
+
+ body := buildFetchBody(runtime)
+ if got := body["revision_id"]; got != 42 {
+ t.Fatalf("revision_id = %#v, want 42", got)
+ }
+ exportOption, _ := body["export_option"].(map[string]interface{})
+ want := map[string]interface{}{
+ "export_block_id": true,
+ "export_style_attrs": true,
+ "export_cite_extra_data": true,
+ }
+ if !reflect.DeepEqual(exportOption, want) {
+ t.Fatalf("export_option = %#v, want %#v", exportOption, want)
+ }
+}
+
+func TestBuildFetchBodyIncludesWithIDsDetail(t *testing.T) {
+ t.Parallel()
+
+ runtime := newFetchBodyTestRuntime(context.Background())
+ mustSetFetchFlag(t, runtime, "detail", "with-ids")
+
+ body := buildFetchBody(runtime)
+ exportOption, _ := body["export_option"].(map[string]interface{})
+ want := map[string]interface{}{
+ "export_block_id": true,
+ }
+ if !reflect.DeepEqual(exportOption, want) {
+ t.Fatalf("export_option = %#v, want %#v", exportOption, want)
+ }
+}
+
+func TestBuildFetchBodyIncludesReadOption(t *testing.T) {
+ t.Parallel()
+
+ runtime := newFetchBodyTestRuntime(context.Background())
+ mustSetFetchFlag(t, runtime, "scope", "section")
+ mustSetFetchFlag(t, runtime, "start-block-id", "blk_heading")
+
+ body := buildFetchBody(runtime)
+ want := map[string]interface{}{
+ "read_mode": "section",
+ "start_block_id": "blk_heading",
+ }
+ if got := body["read_option"]; !reflect.DeepEqual(got, want) {
+ t.Fatalf("read_option = %#v, want %#v", got, want)
+ }
+}
+
+func TestBuildReadOptionModes(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ setFlags map[string]string
+ want map[string]interface{}
+ }{
+ {
+ name: "full omits read option",
+ setFlags: map[string]string{
+ "scope": "full",
+ },
+ want: nil,
+ },
+ {
+ name: "outline with max depth",
+ setFlags: map[string]string{
+ "scope": "outline",
+ "max-depth": "3",
+ },
+ want: map[string]interface{}{
+ "read_mode": "outline",
+ "max_depth": "3",
+ },
+ },
+ {
+ name: "range with block ids and context",
+ setFlags: map[string]string{
+ "scope": "range",
+ "start-block-id": "blk_start",
+ "end-block-id": "blk_end",
+ "context-before": "2",
+ "context-after": "1",
+ "max-depth": "0",
+ },
+ want: map[string]interface{}{
+ "read_mode": "range",
+ "start_block_id": "blk_start",
+ "end_block_id": "blk_end",
+ "context_before": "2",
+ "context_after": "1",
+ "max_depth": "0",
+ },
+ },
+ {
+ name: "keyword with query",
+ setFlags: map[string]string{
+ "scope": "keyword",
+ "keyword": "foo|bar",
+ "context-before": "1",
+ },
+ want: map[string]interface{}{
+ "read_mode": "keyword",
+ "keyword": "foo|bar",
+ "context_before": "1",
+ },
+ },
+ {
+ name: "section keeps unlimited depth omitted",
+ setFlags: map[string]string{
+ "scope": "section",
+ "start-block-id": "blk_heading",
+ "max-depth": "-1",
+ },
+ want: map[string]interface{}{
+ "read_mode": "section",
+ "start_block_id": "blk_heading",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ runtime := newFetchBodyTestRuntime(context.Background())
+ for name, value := range tt.setFlags {
+ mustSetFetchFlag(t, runtime, name, value)
+ }
+
+ if got := buildReadOption(runtime); !reflect.DeepEqual(got, tt.want) {
+ t.Fatalf("buildReadOption() = %#v, want %#v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestValidateReadModeFlagsRejectsInvalidScopeOptions(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ setFlags map[string]string
+ wantParam string
+ wantParams []string
+ }{
+ {
+ name: "negative context before",
+ setFlags: map[string]string{
+ "scope": "range",
+ "start-block-id": "blk_start",
+ "context-before": "-1",
+ },
+ wantParam: "--context-before",
+ },
+ {
+ name: "negative context after",
+ setFlags: map[string]string{
+ "scope": "range",
+ "start-block-id": "blk_start",
+ "context-after": "-1",
+ },
+ wantParam: "--context-after",
+ },
+ {
+ name: "max depth below unlimited sentinel",
+ setFlags: map[string]string{
+ "scope": "range",
+ "start-block-id": "blk_start",
+ "max-depth": "-2",
+ },
+ wantParam: "--max-depth",
+ },
+ {
+ name: "range needs boundary",
+ setFlags: map[string]string{
+ "scope": "range",
+ },
+ wantParams: []string{
+ "--start-block-id",
+ "--end-block-id",
+ },
+ },
+ {
+ name: "keyword needs keyword",
+ setFlags: map[string]string{
+ "scope": "keyword",
+ },
+ wantParam: "--keyword",
+ },
+ {
+ name: "section needs start block",
+ setFlags: map[string]string{
+ "scope": "section",
+ },
+ wantParam: "--start-block-id",
+ },
+ {
+ name: "unknown scope",
+ setFlags: map[string]string{
+ "scope": "bad",
+ },
+ wantParam: "--scope",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ runtime := newFetchBodyTestRuntime(context.Background())
+ for name, value := range tt.setFlags {
+ mustSetFetchFlag(t, runtime, name, value)
+ }
+
+ err := validateReadModeFlags(runtime)
+ if err == nil {
+ t.Fatal("validateReadModeFlags() succeeded, want error")
+ }
+ assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam, tt.wantParams...)
+ })
+ }
+}
+
+func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ setFlags map[string]string
+ }{
+ {
+ name: "outline",
+ setFlags: map[string]string{
+ "scope": "outline",
+ },
+ },
+ {
+ name: "range with end block",
+ setFlags: map[string]string{
+ "scope": "range",
+ "end-block-id": "blk_end",
+ },
+ },
+ {
+ name: "keyword with keyword",
+ setFlags: map[string]string{
+ "scope": "keyword",
+ "keyword": "bug|缺陷",
+ },
+ },
+ {
+ name: "section with start block",
+ setFlags: map[string]string{
+ "scope": "section",
+ "start-block-id": "blk_heading",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ runtime := newFetchBodyTestRuntime(context.Background())
+ for name, value := range tt.setFlags {
+ mustSetFetchFlag(t, runtime, name, value)
+ }
+
+ if err := validateReadModeFlags(runtime); err != nil {
+ t.Fatalf("validateReadModeFlags() error = %v", err)
+ }
+ })
+ }
+}
+
+func TestValidateFetchV2RejectsInvalidDocAndScope(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ setFlags map[string]string
+ wantParam string
+ }{
+ {
+ name: "invalid doc",
+ setFlags: map[string]string{
+ "doc": "https://example.com/sheets/sht_token",
+ },
+ wantParam: "--doc",
+ },
+ {
+ name: "invalid scope",
+ setFlags: map[string]string{
+ "scope": "bad",
+ },
+ wantParam: "--scope",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ runtime := newFetchShortcutTestRuntime(t, "", tt.setFlags)
+ err := validateFetchV2(context.Background(), runtime)
+ if err == nil {
+ t.Fatal("validateFetchV2() succeeded, want error")
+ }
+ assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam)
+ })
+ }
+}
+
+func TestAddFetchDetailDowngradeWarningNoops(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ setFlags map[string]string
+ }{
+ {
+ name: "xml format",
+ setFlags: map[string]string{
+ "doc-format": "xml",
+ "detail": "full",
+ },
+ },
+ {
+ name: "markdown simple detail",
+ setFlags: map[string]string{
+ "doc-format": "markdown",
+ "detail": "simple",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ runtime := newFetchBodyTestRuntime(context.Background())
+ for name, value := range tt.setFlags {
+ mustSetFetchFlag(t, runtime, name, value)
+ }
+
+ data := map[string]interface{}{}
+ if got := addFetchDetailDowngradeWarning(runtime, data); got != "" {
+ t.Fatalf("warning = %q, want empty", got)
+ }
+ if _, ok := data["warnings"]; ok {
+ t.Fatalf("unexpected warnings: %#v", data["warnings"])
+ }
+ })
+ }
+}
+
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
t.Parallel()
@@ -124,10 +507,10 @@ func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
}
}
-func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
+func TestDocsFetchAPIVersionCompatFlagIsIgnored(t *testing.T) {
t.Parallel()
- runtime := newFetchShortcutTestRuntime(t, "v1", nil)
+ runtime := newFetchShortcutTestRuntime(t, "legacy", nil)
if err := validateFetchV2(context.Background(), runtime); err != nil {
t.Fatalf("validateFetchV2() error = %v", err)
}
@@ -141,36 +524,54 @@ func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
}
}
+func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) {
+ t.Parallel()
+
+ runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
+ "doc-format": "im-markdown",
+ })
+ if err := validateFetchV2(context.Background(), runtime); err != nil {
+ t.Fatalf("validateFetchV2() error = %v", err)
+ }
+
+ dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
+ if got, want := dry.API[0].Body["format"], "markdown"; got != want {
+ t.Fatalf("dry-run format = %#v, want %q", got, want)
+ }
+}
+
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
t.Parallel()
- for _, detail := range []string{"with-ids", "full"} {
- t.Run(detail, func(t *testing.T) {
- t.Parallel()
+ for _, format := range []string{"markdown", "im-markdown"} {
+ for _, detail := range []string{"with-ids", "full"} {
+ t.Run(format+"/"+detail, func(t *testing.T) {
+ t.Parallel()
- runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
- "doc-format": "markdown",
- "detail": detail,
+ runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
+ "doc-format": format,
+ "detail": detail,
+ })
+ if err := validateFetchV2(context.Background(), runtime); err != nil {
+ t.Fatalf("validateFetchV2() error = %v", err)
+ }
+
+ dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
+ exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
+ if exportOption == nil {
+ t.Fatalf("missing export_option: %#v", dry.API[0].Body)
+ }
+ if got := exportOption["export_block_id"]; got != false {
+ t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
+ }
+ if got := exportOption["export_style_attrs"]; got != false {
+ t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
+ }
+ if got := exportOption["export_cite_extra_data"]; got != false {
+ t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
+ }
})
- if err := validateFetchV2(context.Background(), runtime); err != nil {
- t.Fatalf("validateFetchV2() error = %v", err)
- }
-
- dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
- exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
- if exportOption == nil {
- t.Fatalf("missing export_option: %#v", dry.API[0].Body)
- }
- if got := exportOption["export_block_id"]; got != false {
- t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
- }
- if got := exportOption["export_style_attrs"]; got != false {
- t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
- }
- if got := exportOption["export_cite_extra_data"]; got != false {
- t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
- }
- })
+ }
}
}
@@ -261,6 +662,107 @@ func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) {
}
}
+func TestDocsFetchV2ReturnsAPIError(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-api-error"))
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/docs_ai/v1/documents/doxcnFetchAPIError/fetch",
+ Body: map[string]interface{}{
+ "code": 999999,
+ "msg": "fetch failed",
+ },
+ })
+
+ err := mountAndRunDocs(t, DocsFetch, []string{
+ "+fetch",
+ "--doc", "doxcnFetchAPIError",
+ "--as", "bot",
+ }, f, stdout)
+ if err == nil {
+ t.Fatal("mountAndRunDocs() succeeded, want API error")
+ }
+ var apiErr *errs.APIError
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("error type = %T, want *errs.APIError (%v)", err, err)
+ }
+ p, ok := errs.ProblemOf(err)
+ if !ok {
+ t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err)
+ }
+ if p.Category != errs.CategoryAPI {
+ t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
+ }
+ if p.Subtype != errs.SubtypeUnknown {
+ t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeUnknown)
+ }
+ if p.Code != 999999 {
+ t.Errorf("code = %d, want 999999", p.Code)
+ }
+ if p.Message != "fetch failed" {
+ t.Errorf("message = %q, want %q", p.Message, "fetch failed")
+ }
+ if cause := errors.Unwrap(err); cause != nil {
+ t.Fatalf("unexpected wrapped cause for API response error: %T %v", cause, cause)
+ }
+}
+
+func TestDocsFetchIMMarkdownConvertsContentInJSONOutput(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown"))
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdown/fetch",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "document": map[string]interface{}{
+ "document_id": "doxcnFetchIMMarkdown",
+ "revision_id": float64(1),
+ "content": strings.Join([]string{
+ `Doc Title `,
+ `Read **this**. `,
+ ` `,
+ }, "\n\n"),
+ },
+ },
+ },
+ })
+
+ err := mountAndRunDocs(t, DocsFetch, []string{
+ "+fetch",
+ "--doc", "doxcnFetchIMMarkdown",
+ "--doc-format", "im-markdown",
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var envelope map[string]interface{}
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
+ }
+ data, _ := envelope["data"].(map[string]interface{})
+ doc, _ := data["document"].(map[string]interface{})
+ content, _ := doc["content"].(string)
+ for _, want := range []string{
+ "# Doc Title",
+ "---\n💡 Read **this**.\n---",
+ "[Example](https://example.com)",
+ } {
+ if !strings.Contains(content, want) {
+ t.Fatalf("converted content missing %q:\n%s", want, content)
+ }
+ }
+ if strings.Contains(content, "") || strings.Contains(content, "hello ", "")
cmd.Flags().String("parent-token", "", "")
cmd.Flags().String("parent-position", "", "")
diff --git a/shortcuts/doc/docs_update_test.go b/shortcuts/doc/docs_update_test.go
index dd856188..b6b757cb 100644
--- a/shortcuts/doc/docs_update_test.go
+++ b/shortcuts/doc/docs_update_test.go
@@ -34,8 +34,8 @@ func TestValidCommandsV2(t *testing.T) {
}
}
-func TestDocsUpdateDryRunAcceptsDeprecatedAPIVersionValues(t *testing.T) {
- for _, apiVersion := range []string{"v1", "v2"} {
+func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
+ for _, apiVersion := range []string{"v1", "v2", "legacy"} {
t.Run(apiVersion, func(t *testing.T) {
t.Parallel()
diff --git a/shortcuts/doc/v2_only.go b/shortcuts/doc/v2_only.go
index 1d051516..cb49ea62 100644
--- a/shortcuts/doc/v2_only.go
+++ b/shortcuts/doc/v2_only.go
@@ -17,15 +17,14 @@ type docsLegacyFlag struct {
func docsAPIVersionCompatFlag() common.Flag {
return common.Flag{
- Name: "api-version",
- Desc: "deprecated compatibility flag; docs shortcuts always use v2, and both v1/v2 are accepted for rollback-safe skill examples",
- Default: "v2",
+ Name: "api-version",
+ Desc: "deprecated compatibility flag; ignored by docs shortcuts",
+ Hidden: true,
}
}
func docsCreateLegacyFlags() []docsLegacyFlag {
return []docsLegacyFlag{
- {Name: "title", Replacement: "put the title in --content, for example Title "},
{Name: "markdown", Replacement: "use --content with --doc-format markdown"},
{Name: "folder-token", Replacement: "use --parent-token"},
{Name: "wiki-node", Replacement: "use --parent-token"},
@@ -55,7 +54,7 @@ func docsLegacyFlagDefinitions(flags []docsLegacyFlag) []common.Flag {
for _, flag := range flags {
out = append(out, common.Flag{
Name: flag.Name,
- Desc: "deprecated v1 compatibility flag; run `lark-cli skills read lark-doc` for the v2 CLI skill",
+ Desc: "deprecated compatibility flag; run `lark-cli skills read lark-doc` for the current CLI skill",
Hidden: true,
})
}
@@ -63,12 +62,6 @@ func docsLegacyFlagDefinitions(flags []docsLegacyFlag) []common.Flag {
}
func validateDocsV2Only(runtime *common.RuntimeContext, shortcut string, legacyFlags []docsLegacyFlag) error {
- switch apiVersion := strings.TrimSpace(runtime.Str("api-version")); apiVersion {
- case "", "v1", "v2":
- default:
- return docsV2OnlyError(shortcut, "--api-version is deprecated and only accepts v1 or v2; both values execute the v2 API", "--api-version")
- }
-
var used []string
var replacements []string
for _, flag := range legacyFlags {
diff --git a/shortcuts/doc/v2_only_test.go b/shortcuts/doc/v2_only_test.go
index ce216c8a..2ebd6dab 100644
--- a/shortcuts/doc/v2_only_test.go
+++ b/shortcuts/doc/v2_only_test.go
@@ -11,8 +11,8 @@ import (
"github.com/spf13/cobra"
)
-func TestValidateDocsV2OnlyAllowsDefaultAndDeprecatedAPIVersionValues(t *testing.T) {
- for _, apiVersion := range []string{"", "v1", "v2"} {
+func TestValidateDocsV2OnlyIgnoresAPIVersionValues(t *testing.T) {
+ for _, apiVersion := range []string{"", "v1", "v2", "v0", "legacy"} {
t.Run(apiVersion, func(t *testing.T) {
runtime := docsV2OnlyTestRuntime(t, apiVersion, false)
if err := validateDocsV2Only(runtime, "+update", []docsLegacyFlag{{Name: "mode", Replacement: "use --command"}}); err != nil {
@@ -22,28 +22,6 @@ func TestValidateDocsV2OnlyAllowsDefaultAndDeprecatedAPIVersionValues(t *testing
}
}
-func TestValidateDocsV2OnlyRejectsUnknownAPIVersion(t *testing.T) {
- runtime := docsV2OnlyTestRuntime(t, "v0", false)
- err := validateDocsV2Only(runtime, "+fetch", nil)
- if err == nil {
- t.Fatal("expected unknown --api-version to be rejected")
- }
- for _, want := range []string{
- "docs +fetch is v2-only",
- "--api-version is deprecated and only accepts v1 or v2",
- "both values execute the v2 API",
- "lark-cli skills read lark-doc references/lark-doc-fetch.md",
- "lark-cli skills read lark-doc references/lark-doc-xml.md",
- "lark-cli skills read lark-doc references/lark-doc-md.md",
- "MUST NOT grep/open local SKILL.md files",
- "lark-cli docs +fetch --help",
- } {
- if !strings.Contains(err.Error(), want) {
- t.Fatalf("error missing %q: %v", want, err)
- }
- }
-}
-
func TestValidateDocsV2OnlyRejectsChangedLegacyFlags(t *testing.T) {
runtime := docsV2OnlyTestRuntime(t, "", true)
err := validateDocsV2Only(runtime, "+update", []docsLegacyFlag{{Name: "mode", Replacement: "use --command"}})
diff --git a/shortcuts/drive/drive_member_add.go b/shortcuts/drive/drive_member_add.go
new file mode 100644
index 00000000..69e89772
--- /dev/null
+++ b/shortcuts/drive/drive_member_add.go
@@ -0,0 +1,686 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package drive
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/validate"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+// driveMemberAddIDTypes covers every user-facing --member-type value accepted
+// by the shortcut. Some values are normalized before hitting the API.
+var driveMemberAddIDTypes = []string{
+ "email", "openid", "unionid", "openchat", "opendepartmentid",
+ "groupid", "appid", "wikispaceid",
+}
+
+var driveMemberAddPerms = []string{"view", "edit", "full_access"}
+var driveMemberAddPermTypes = []string{"container", "single_page"}
+var driveMemberAddWikiSpaceMemberKinds = []string{"wiki_space_member", "wiki_space_viewer", "wiki_space_editor"}
+
+// driveMemberAddPrefixToType maps ID prefixes to their expected member_type
+// for conflict validation when --member-type is provided explicitly.
+var driveMemberAddPrefixToType = map[string]string{
+ "ou_": "openid",
+ "on_": "unionid",
+ "oc_": "openchat",
+ "od_": "opendepartmentid",
+}
+
+var driveMemberAddURLPathToType = []struct {
+ Prefix string
+ Type string
+}{
+ {"/drive/folder/", "folder"},
+ {"/docx/", "docx"},
+ {"/doc/", "doc"},
+ {"/sheets/", "sheet"},
+ {"/base/", "bitable"},
+ {"/bitable/", "bitable"},
+ {"/wiki/", "wiki"},
+ {"/file/", "file"},
+ {"/mindnotes/", "mindnote"},
+ {"/slides/", "slides"},
+ {"/minutes/", "minutes"},
+}
+
+var driveMemberAddResourceTypes = []string{"docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes"}
+
+const driveMemberAddBatchLimit = 10
+
+// DriveMemberAdd adds a collaborator/member permission to a Drive resource.
+var DriveMemberAdd = common.Shortcut{
+ Service: "drive",
+ Command: "+member-add",
+ Description: "Add a collaborator/member permission to a Drive document, file, folder, or wiki node",
+ Risk: "high-risk-write",
+ Scopes: []string{"docs:permission.member:create"},
+ AuthTypes: []string{"user", "bot"},
+ HasFormat: true,
+ Flags: []common.Flag{
+ {Name: "token", Desc: "target token or document URL; type is auto-inferred from URL path when omitted", Required: true},
+ {Name: "type", Desc: "target resource type; required when --token is a bare token"},
+ {Name: "member-id", Desc: "collaborator ID; comma-separated for batch (max 10). Interpretation is decided by --member-type", Required: true},
+ {Name: "member-type", Desc: "ID type for --member-id; supported: email|openid|unionid|openchat|opendepartmentid|groupid|appid|wikispaceid", Required: true},
+ {Name: "member-kind", Desc: "request body type when --member-type=wikispaceid; one of wiki_space_member|wiki_space_viewer|wiki_space_editor"},
+ {Name: "perm", Desc: "permission role to grant; defaults to view"},
+ {Name: "perm-type", Desc: "wiki permission scope; defaults to container; rejected for non-wiki types"},
+ {Name: "need-notification", Type: "bool", Desc: "send an in-app notification after the grant (user identity only)"},
+ },
+ Tips: []string{
+ "Resource type is auto-inferred from URL paths; pass --type when --token is a bare token.",
+ "Supported --member-type values: email, openid, unionid, openchat, opendepartmentid, groupid, appid, wikispaceid.",
+ "When --member-type=wikispaceid, pass --member-kind wiki_space_member, wiki_space_viewer, or wiki_space_editor.",
+ "--member-type is required; if the ID prefix conflicts with --member-type (e.g. ou_xxx with email), the command rejects it.",
+ "--perm defaults to view (safest); use --dry-run first when granting edit or full_access.",
+ "For wiki nodes, --perm-type defaults to container (current page and sub-pages), except --member-type=wikispaceid where --member-kind provides the wiki-space role.",
+ "Department collaborator (--member-type=opendepartmentid) requires --as user; bot identity is not supported for department authorization.",
+ },
+ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ _, err := readDriveMemberAddSpec(runtime)
+ return err
+ },
+ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
+ spec, err := readDriveMemberAddSpec(runtime)
+ if err != nil {
+ return common.NewDryRunAPI().Set("error", err.Error())
+ }
+ return buildDriveMemberAddDryRun(spec)
+ },
+ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ spec, err := readDriveMemberAddSpec(runtime)
+ if err != nil {
+ return err
+ }
+
+ if len(spec.MemberIDs) == 1 {
+ return executeDriveMemberAddSingle(runtime, spec)
+ }
+ return executeDriveMemberAddBatch(runtime, spec)
+ },
+}
+
+// driveMemberAddSpec is the normalized request model shared by Validate,
+// DryRun, Execute, and output shaping so they all observe the same defaults.
+type driveMemberAddSpec struct {
+ Token string
+ ResourceType string
+ MemberIDs []string
+ MemberType string
+ // MemberKind is the explicit --member-kind value for member_type=wikispaceid.
+ MemberKind string
+ Perm string
+ PermType string
+ NeedNotification bool
+ NotificationSet bool
+}
+
+// DryRunParams builds the preview query string while preserving the semantic
+// difference between an omitted notification flag and an explicit false.
+func (spec driveMemberAddSpec) DryRunParams() map[string]interface{} {
+ params := map[string]interface{}{"type": spec.ResourceType}
+ if spec.NotificationSet {
+ params["need_notification"] = spec.NeedNotification
+ }
+ return params
+}
+
+// APIQueryParams builds the query params for permission.members.create.
+func (spec driveMemberAddSpec) APIQueryParams() map[string]interface{} {
+ params := map[string]interface{}{"type": spec.ResourceType}
+ if spec.NotificationSet {
+ params["need_notification"] = strconv.FormatBool(spec.NeedNotification)
+ }
+ return params
+}
+
+// buildMemberBody builds a single member object for the request body.
+func buildMemberBody(memberID, memberType, wikiSpaceMemberKind, perm, permType string) map[string]interface{} {
+ body := map[string]interface{}{
+ "member_id": memberID,
+ "member_type": memberType,
+ "perm": perm,
+ }
+ if bodyType := driveMemberAddBodyType(memberType, wikiSpaceMemberKind); bodyType != "" {
+ body["type"] = bodyType
+ }
+ if permType != "" {
+ body["perm_type"] = permType
+ }
+ return body
+}
+
+// readDriveMemberAddSpec parses runtime flags into a normalized request model,
+// applying inference, defaults, and cross-field validation in one place.
+func readDriveMemberAddSpec(runtime *common.RuntimeContext) (driveMemberAddSpec, error) {
+ token, resourceType, err := resolveDriveMemberAddTarget(runtime.Str("token"), runtime.Str("type"))
+ if err != nil {
+ return driveMemberAddSpec{}, err
+ }
+
+ // Parse member-id: comma-separated for batch.
+ rawMemberID := strings.TrimSpace(runtime.Str("member-id"))
+ if rawMemberID == "" {
+ return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id")
+ }
+ memberIDs := splitAndTrimMembers(rawMemberID)
+ if len(memberIDs) == 0 {
+ return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and must contain at least one non-blank ID").WithParam("--member-id")
+ }
+ if len(memberIDs) > driveMemberAddBatchLimit {
+ return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id accepts at most %d IDs, got %d", driveMemberAddBatchLimit, len(memberIDs)).WithParam("--member-id")
+ }
+ if duplicate, first, second, ok := firstDuplicateDriveMemberID(memberIDs); ok {
+ return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
+ "--member-id contains duplicate collaborator ID %q at positions %d and %d; remove duplicates before retrying",
+ duplicate, first+1, second+1,
+ ).WithParam("--member-id")
+ }
+
+ memberType, err := resolveDriveMemberAddMemberType(memberIDs, runtime.Str("member-type"))
+ if err != nil {
+ return driveMemberAddSpec{}, err
+ }
+ memberKind, err := resolveDriveMemberAddMemberKind(memberType, runtime.Str("member-kind"))
+ if err != nil {
+ return driveMemberAddSpec{}, err
+ }
+
+ // perm: default to view.
+ perm, err := normalizeDriveMemberAddEnumValue(runtime.Str("perm"), driveMemberAddPerms, "--perm")
+ if err != nil {
+ return driveMemberAddSpec{}, err
+ }
+ if perm == "" {
+ perm = "view"
+ }
+
+ // perm-type: only meaningful for wiki; default container except for wiki-space collaborators.
+ permType, err := normalizeDriveMemberAddEnumValue(runtime.Str("perm-type"), driveMemberAddPermTypes, "--perm-type")
+ if err != nil {
+ return driveMemberAddSpec{}, err
+ }
+ if resourceType == "wiki" && memberType == "wikispaceid" {
+ if runtime.Changed("perm-type") {
+ return driveMemberAddSpec{}, errs.NewValidationError(
+ errs.SubtypeInvalidArgument,
+ "--perm-type is not supported when --member-type=wikispaceid; use --member-kind wiki_space_member|wiki_space_viewer|wiki_space_editor to set the wiki-space role",
+ ).WithParam("--perm-type")
+ }
+ permType = ""
+ } else if resourceType == "wiki" && permType == "" {
+ permType = driveMemberAddDefaultPermType(resourceType)
+ } else if resourceType != "wiki" && runtime.Changed("perm-type") {
+ return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type")
+ } else if resourceType != "wiki" {
+ permType = ""
+ }
+
+ spec := driveMemberAddSpec{
+ Token: token,
+ ResourceType: resourceType,
+ MemberIDs: memberIDs,
+ MemberType: memberType,
+ MemberKind: memberKind,
+ Perm: perm,
+ PermType: permType,
+ NeedNotification: runtime.Bool("need-notification"),
+ NotificationSet: runtime.Changed("need-notification"),
+ }
+ if runtime.As().IsBot() && spec.NotificationSet {
+ return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--need-notification is only valid with --as user; omit it when using --as bot").WithParam("--need-notification")
+ }
+ if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" {
+ return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-type=opendepartmentid requires --as user; bot identity does not support adding department collaborators").WithParam("--member-type")
+ }
+ return spec, nil
+}
+
+// resolveDriveMemberAddTarget extracts (token, type) from a user-supplied
+// --token value that may be either a bare token or a full resource URL, plus an
+// optional explicit --type. Explicit --type wins over URL inference.
+func resolveDriveMemberAddTarget(raw, explicitType string) (token, resourceType string, err error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
+ }
+ explicitType = strings.ToLower(strings.TrimSpace(explicitType))
+
+ if strings.Contains(raw, "://") {
+ parsed, parseErr := url.Parse(raw)
+ if parseErr != nil || parsed.Hostname() == "" {
+ return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token")
+ }
+ urlToken, urlType, ok := parseDriveMemberAddResourceURLPath(parsed.Path)
+ if !ok {
+ return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
+ "unsupported URL path %q: expected one of %s followed by a token",
+ parsed.Path, strings.Join(driveMemberAddSupportedURLPaths(), ", "),
+ ).WithParam("--token")
+ }
+ token = urlToken
+ if explicitType == "" {
+ resourceType = urlType
+ }
+ } else {
+ token = raw
+ }
+
+ if explicitType != "" {
+ if !isSupportedDriveMemberAddResourceType(explicitType) {
+ return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--type must be one of: %s", strings.Join(driveMemberAddResourceTypes, ", ")).WithParam("--type")
+ }
+ resourceType = explicitType
+ }
+
+ if resourceType == "" {
+ return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
+ "--type is required when --token is a bare token; accepted values: %s",
+ strings.Join(driveMemberAddResourceTypes, ", "),
+ ).WithParam("--type")
+ }
+ return token, resourceType, nil
+}
+
+func driveMemberAddSupportedURLPaths() []string {
+ paths := make([]string, 0, len(driveMemberAddURLPathToType))
+ for _, mapping := range driveMemberAddURLPathToType {
+ paths = append(paths, mapping.Prefix)
+ }
+ return paths
+}
+
+func parseDriveMemberAddResourceURLPath(path string) (token, resourceType string, ok bool) {
+ for _, mapping := range driveMemberAddURLPathToType {
+ if !strings.HasPrefix(path, mapping.Prefix) {
+ continue
+ }
+ token := path[len(mapping.Prefix):]
+ token = strings.TrimRight(token, "/")
+ if idx := strings.IndexByte(token, '/'); idx >= 0 {
+ token = token[:idx]
+ }
+ token = strings.TrimSpace(token)
+ if token == "" {
+ return "", "", false
+ }
+ return token, mapping.Type, true
+ }
+ return "", "", false
+}
+
+func isSupportedDriveMemberAddResourceType(resourceType string) bool {
+ switch resourceType {
+ case "docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes":
+ return true
+ default:
+ return false
+ }
+}
+
+func resolveDriveMemberAddMemberType(memberIDs []string, explicit string) (string, error) {
+ var err error
+ explicit, err = normalizeDriveMemberAddEnumValue(explicit, driveMemberAddIDTypes, "--member-type")
+ if err != nil {
+ return "", err
+ }
+ if explicit == "" {
+ return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-type is required; accepted values: %s", strings.Join(driveMemberAddIDTypes, ", ")).WithParam("--member-type")
+ }
+ for i, memberID := range memberIDs {
+ if expected := inferMemberTypeFromID(memberID); expected != "" && expected != explicit {
+ return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
+ "member-id[%d] %q prefix implies --member-type %s, but --member-type %s was provided; fix the ID or use the matching member type",
+ i+1, memberID, expected, explicit,
+ ).WithParam("--member-id")
+ }
+ }
+ return normalizeDriveMemberAddMemberType(explicit), nil
+}
+
+func resolveDriveMemberAddMemberKind(memberType, raw string) (string, error) {
+ memberKind, err := normalizeDriveMemberAddEnumValue(raw, driveMemberAddWikiSpaceMemberKinds, "--member-kind")
+ if err != nil {
+ return "", err
+ }
+ if memberType == "wikispaceid" {
+ if memberKind == "" {
+ return "", errs.NewValidationError(
+ errs.SubtypeInvalidArgument,
+ "--member-kind is required when --member-type=wikispaceid; allowed: %s",
+ strings.Join(driveMemberAddWikiSpaceMemberKinds, ", "),
+ ).WithParam("--member-kind")
+ }
+ return memberKind, nil
+ }
+ if memberKind != "" {
+ return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-kind only applies when --member-type=wikispaceid").WithParam("--member-kind")
+ }
+ return "", nil
+}
+
+func normalizeDriveMemberAddMemberType(memberType string) string {
+ return strings.ToLower(strings.TrimSpace(memberType))
+}
+
+func normalizeDriveMemberAddEnumValue(raw string, allowed []string, flagName string) (string, error) {
+ value := strings.TrimSpace(raw)
+ if value == "" {
+ return "", nil
+ }
+ for _, candidate := range allowed {
+ if strings.EqualFold(value, candidate) {
+ return candidate, nil
+ }
+ }
+ return "", errs.NewValidationError(
+ errs.SubtypeInvalidArgument,
+ "invalid value %q for %s, allowed: %s",
+ value,
+ flagName,
+ strings.Join(allowed, ", "),
+ ).WithParam(flagName)
+}
+
+// splitAndTrimMembers splits a comma-separated member-id string and trims whitespace.
+func splitAndTrimMembers(raw string) []string {
+ parts := strings.Split(raw, ",")
+ var result []string
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ result = append(result, p)
+ }
+ }
+ return result
+}
+
+func firstDuplicateDriveMemberID(memberIDs []string) (duplicate string, first, second int, ok bool) {
+ seen := make(map[string]int, len(memberIDs))
+ for i, memberID := range memberIDs {
+ if prev, exists := seen[memberID]; exists {
+ return memberID, prev, i, true
+ }
+ seen[memberID] = i
+ }
+ return "", 0, 0, false
+}
+
+// inferMemberTypeFromID returns the expected member_type for a member-id
+// based on its prefix, or "" if no prefix matches (e.g. groupid).
+func inferMemberTypeFromID(memberID string) string {
+ memberID = strings.TrimSpace(memberID)
+ if memberID == "" {
+ return ""
+ }
+ if strings.Contains(memberID, "@") {
+ return "email"
+ }
+ for prefix, mtype := range driveMemberAddPrefixToType {
+ if strings.HasPrefix(memberID, prefix) {
+ return mtype
+ }
+ }
+ return ""
+}
+
+// driveMemberAddDefaultPermType returns the default perm_type for a given
+// resource type. For wiki nodes, container is the default for regular
+// collaborators. Wiki-space collaborators omit perm_type because their role is
+// carried by the body type field.
+func driveMemberAddDefaultPermType(resourceType string) string {
+ switch resourceType {
+ case "wiki":
+ return "container"
+ default:
+ return ""
+ }
+}
+
+// inferDriveMemberKind derives the request-body collaborator kind from
+// member-type for all supported member-type values.
+func inferDriveMemberKind(memberType string) string {
+ switch memberType {
+ case "email", "openid", "unionid", "userid":
+ return "user"
+ case "openchat":
+ return "chat"
+ case "opendepartmentid":
+ return "department"
+ case "groupid":
+ return "group"
+ default:
+ return ""
+ }
+}
+
+func driveMemberAddBodyType(memberType, wikiSpaceMemberKind string) string {
+ if memberType == "wikispaceid" {
+ return wikiSpaceMemberKind
+ }
+ return inferDriveMemberKind(memberType)
+}
+
+// buildDriveMemberAddDryRun renders the exact request preview for --dry-run.
+func buildDriveMemberAddDryRun(spec driveMemberAddSpec) *common.DryRunAPI {
+ if len(spec.MemberIDs) == 1 {
+ body := buildMemberBody(spec.MemberIDs[0], spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType)
+ return common.NewDryRunAPI().
+ Desc("Add Drive collaborator/member permission").
+ POST(fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(spec.Token))).
+ Params(spec.DryRunParams()).
+ Body(body)
+ }
+
+ members := buildDriveMemberAddMemberBodies(spec)
+ return common.NewDryRunAPI().
+ Desc("Batch add Drive collaborator/member permissions").
+ POST(fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/batch_create", validate.EncodePathSegment(spec.Token))).
+ Params(spec.DryRunParams()).
+ Body(map[string]interface{}{"members": members})
+}
+
+// executeDriveMemberAddSingle calls the single-member create API.
+func executeDriveMemberAddSingle(runtime *common.RuntimeContext, spec driveMemberAddSpec) error {
+ fmt.Fprintf(runtime.IO().ErrOut, "Adding Drive member %s (type=%s, perm=%s) to %s %s...\n",
+ common.MaskToken(spec.MemberIDs[0]), spec.MemberType, spec.Perm, spec.ResourceType, common.MaskToken(spec.Token))
+
+ body := buildMemberBody(spec.MemberIDs[0], spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType)
+ data, err := runtime.CallAPITyped(
+ "POST",
+ fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(spec.Token)),
+ spec.APIQueryParams(),
+ body,
+ )
+ if err != nil {
+ return err
+ }
+
+ out := driveMemberAddOutput(spec, spec.MemberIDs[0], common.GetMap(data, "member"))
+ fmt.Fprintf(runtime.IO().ErrOut, "Added Drive member %s\n", common.MaskToken(common.GetString(out, "member_id")))
+ runtime.Out(out, nil)
+ return nil
+}
+
+// executeDriveMemberAddBatch calls the batch_create API. A successful HTTP/API
+// response is treated as complete only when the server returns every requested
+// member_id, regardless of response array order.
+func executeDriveMemberAddBatch(runtime *common.RuntimeContext, spec driveMemberAddSpec) error {
+ members := buildDriveMemberAddMemberBodies(spec)
+
+ fmt.Fprintf(runtime.IO().ErrOut, "Adding %d Drive members (type=%s, perm=%s) to %s %s...\n",
+ len(spec.MemberIDs), spec.MemberType, spec.Perm, spec.ResourceType, common.MaskToken(spec.Token))
+
+ data, err := runtime.CallAPITyped(
+ "POST",
+ fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/batch_create", validate.EncodePathSegment(spec.Token)),
+ spec.APIQueryParams(),
+ map[string]interface{}{"members": members},
+ )
+ if err != nil {
+ return wrapDriveMemberAddBatchAPIError(err)
+ }
+
+ result := buildDriveMemberAddBatchResult(spec, data)
+ if common.GetBool(result, "partial") {
+ return runtime.OutPartialFailure(result, nil)
+ }
+
+ fmt.Fprintf(runtime.IO().ErrOut, "Added %d Drive member(s)\n", result["succeeded_count"])
+ runtime.Out(result, nil)
+ return nil
+}
+
+const (
+ driveMemberAddInvalidParameterCode = 1063001
+ driveMemberAddInvalidOperationCode = 1063003
+)
+
+func wrapDriveMemberAddBatchAPIError(err error) error {
+ var apiErr *errs.APIError
+ if !errors.As(err, &apiErr) {
+ return err
+ }
+
+ wrapped := *apiErr
+ switch apiErr.Code {
+ case driveMemberAddInvalidOperationCode:
+ wrapped.Message = "Drive batch member add failed: one or more requested members may already be collaborators on this resource"
+ wrapped.Hint = "For batch add, remove members that already have access (especially a bot/app being added again), then retry only the missing collaborators."
+ case driveMemberAddInvalidParameterCode:
+ wrapped.Message = "Drive batch member add failed: one or more requested members may be invalid for this resource or identity"
+ wrapped.Hint = "Check whether each --member-id exists, belongs to the same tenant, and is visible to the current identity; remove invalid members and retry only the valid collaborators."
+ default:
+ return err
+ }
+ wrapped.Cause = err
+ return &wrapped
+}
+
+func buildDriveMemberAddMemberBodies(spec driveMemberAddSpec) []map[string]interface{} {
+ members := make([]map[string]interface{}, len(spec.MemberIDs))
+ for i, mid := range spec.MemberIDs {
+ members[i] = buildMemberBody(mid, spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType)
+ }
+ return members
+}
+
+func buildDriveMemberAddBatchResult(spec driveMemberAddSpec, data map[string]interface{}) map[string]interface{} {
+ rawMembers, _ := data["members"].([]interface{})
+
+ // Build set of requested IDs for O(1) lookup.
+ requestedSet := make(map[string]bool, len(spec.MemberIDs))
+ for _, id := range spec.MemberIDs {
+ requestedSet[id] = true
+ }
+
+ // First pass: build returned map and results array.
+ // Matching is done by member_id, not by array index, so the server may
+ // return members in any order without causing false partial_failure.
+ results := make([]map[string]interface{}, 0, len(rawMembers))
+ succeededIDs := make(map[string]bool, len(rawMembers))
+ var mismatched []map[string]interface{}
+
+ for _, raw := range rawMembers {
+ m, ok := raw.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ rawMemberID := common.GetString(m, "member_id")
+
+ out := driveMemberAddOutputWithOptions(spec, "", m, false)
+ results = append(results, out)
+
+ if rawMemberID != "" {
+ if requestedSet[rawMemberID] {
+ succeededIDs[rawMemberID] = true
+ } else {
+ mismatched = append(mismatched, map[string]interface{}{
+ "returned": rawMemberID,
+ })
+ }
+ }
+ }
+
+ // Second pass: find requested IDs missing from the response.
+ missing := make([]string, 0)
+ for _, memberID := range spec.MemberIDs {
+ if !succeededIDs[memberID] {
+ missing = append(missing, memberID)
+ }
+ }
+
+ partial := len(results) != len(spec.MemberIDs) || len(missing) > 0 || len(mismatched) > 0
+ result := map[string]interface{}{
+ "resource_token": spec.Token,
+ "resource_type": spec.ResourceType,
+ "requested_count": len(spec.MemberIDs),
+ "succeeded_count": len(succeededIDs),
+ "partial": partial,
+ "members": results,
+ "missing_member_ids": missing,
+ }
+ if len(mismatched) > 0 {
+ result["mismatched_member_ids"] = mismatched
+ }
+ return result
+}
+
+// driveMemberAddOutput flattens the server response into a stable envelope and
+// backfills fields from spec when the server omits them.
+func driveMemberAddOutput(spec driveMemberAddSpec, fallbackMemberID string, raw map[string]interface{}) map[string]interface{} {
+ return driveMemberAddOutputWithOptions(spec, fallbackMemberID, raw, true)
+}
+
+func driveMemberAddOutputWithOptions(spec driveMemberAddSpec, fallbackMemberID string, raw map[string]interface{}, allowDefaultMemberID bool) map[string]interface{} {
+ out := map[string]interface{}{
+ "resource_token": spec.Token,
+ "resource_type": spec.ResourceType,
+ }
+ if raw != nil {
+ for _, key := range []string{"member_id", "member_type", "perm", "type"} {
+ if v, ok := raw[key]; ok {
+ out[key] = v
+ }
+ }
+ if spec.ResourceType == "wiki" {
+ if v, ok := raw["perm_type"]; ok {
+ out["perm_type"] = v
+ }
+ }
+ }
+ if common.GetString(out, "member_id") == "" {
+ if fallbackMemberID == "" && allowDefaultMemberID && len(spec.MemberIDs) > 0 {
+ fallbackMemberID = spec.MemberIDs[0]
+ }
+ if fallbackMemberID != "" {
+ out["member_id"] = fallbackMemberID
+ }
+ }
+ if common.GetString(out, "member_type") == "" {
+ out["member_type"] = spec.MemberType
+ }
+ if common.GetString(out, "perm") == "" {
+ out["perm"] = spec.Perm
+ }
+ if spec.PermType != "" && common.GetString(out, "perm_type") == "" {
+ out["perm_type"] = spec.PermType
+ }
+ if bodyType := driveMemberAddBodyType(spec.MemberType, spec.MemberKind); bodyType != "" && common.GetString(out, "type") == "" {
+ out["type"] = bodyType
+ }
+ if t := common.GetString(out, "type"); t != "" {
+ out["member_kind"] = t
+ }
+ delete(out, "type")
+ return out
+}
diff --git a/shortcuts/drive/drive_member_add_test.go b/shortcuts/drive/drive_member_add_test.go
new file mode 100644
index 00000000..66278044
--- /dev/null
+++ b/shortcuts/drive/drive_member_add_test.go
@@ -0,0 +1,1507 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package drive
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/httpmock"
+ "github.com/larksuite/cli/internal/output"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+// ── resolveDriveMemberAddTarget unit tests ──────────────────────────────────
+
+func TestResolveDriveMemberAddTarget_URLAndBareToken(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ raw string
+ explicit string
+ wantTok string
+ wantType string
+ }{
+ {"docx URL", "https://example.feishu.cn/docx/doxTok?from=share", "", "doxTok", "docx"},
+ {"folder URL", "https://example.feishu.cn/drive/folder/fldTok", "", "fldTok", "folder"},
+ {"wiki URL", "https://example.feishu.cn/wiki/wikTok", "", "wikTok", "wiki"},
+ {"mindnotes URL", "https://example.feishu.cn/mindnotes/mndTok", "", "mndTok", "mindnote"},
+ {"larkoffice URL", "https://tenant.larkoffice.com/docx/doxTok", "", "doxTok", "docx"},
+ {"explicit type overrides URL", "https://example.feishu.cn/docx/doxTok", "wiki", "doxTok", "wiki"},
+ {"bare token with explicit docx type", "N83ZduEnHooFswxnVWGcazlLnFf", "docx", "N83ZduEnHooFswxnVWGcazlLnFf", "docx"},
+ {"bare token with explicit folder type", "fldToken123", "folder", "fldToken123", "folder"},
+ }
+ for _, temp := range tests {
+ tt := temp
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ token, resourceType, err := resolveDriveMemberAddTarget(tt.raw, tt.explicit)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if token != tt.wantTok || resourceType != tt.wantType {
+ t.Fatalf("got token=%q type=%q, want %q/%q", token, resourceType, tt.wantTok, tt.wantType)
+ }
+ })
+ }
+}
+
+func TestResolveDriveMemberAddTarget_RejectsBareTokenWithoutType(t *testing.T) {
+ t.Parallel()
+
+ _, _, err := resolveDriveMemberAddTarget("N83ZduEnHooFswxnVWGcazlLnFf", "")
+ if err == nil || !strings.Contains(err.Error(), "--type is required when --token is a bare token") {
+ t.Fatalf("expected bare token type-required error, got: %v", err)
+ }
+}
+
+func TestResolveDriveMemberAddTarget_AcceptsAnyHost(t *testing.T) {
+ t.Parallel()
+
+ token, resourceType, err := resolveDriveMemberAddTarget("https://google.com/docx/doxTok", "")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if token != "doxTok" {
+ t.Fatalf("expected token 'doxTok', got %q", token)
+ }
+ if resourceType != "docx" {
+ t.Fatalf("expected resourceType 'docx', got %q", resourceType)
+ }
+}
+
+func TestResolveDriveMemberAddTarget_RejectsUnsupportedURLPath(t *testing.T) {
+ t.Parallel()
+
+ _, _, err := resolveDriveMemberAddTarget("https://example.feishu.cn/calendar/calTok", "")
+ if err == nil || !strings.Contains(err.Error(), "unsupported URL path") {
+ t.Fatalf("expected unsupported URL path error, got: %v", err)
+ }
+}
+
+func TestResolveDriveMemberAddTarget_AcceptsMinutesURL(t *testing.T) {
+ t.Parallel()
+
+ token, resourceType, err := resolveDriveMemberAddTarget("https://example.feishu.cn/minutes/obcnTok", "")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if token != "obcnTok" {
+ t.Fatalf("expected token 'obcnTok', got %q", token)
+ }
+ if resourceType != "minutes" {
+ t.Fatalf("expected resourceType 'minutes', got %q", resourceType)
+ }
+}
+
+func TestResolveDriveMemberAddTarget_RejectsInvalidExplicitType(t *testing.T) {
+ t.Parallel()
+
+ _, _, err := resolveDriveMemberAddTarget("mincnTok", "invalidtype")
+ if err == nil || !strings.Contains(err.Error(), "--type must be one of") {
+ t.Fatalf("expected invalid type error, got: %v", err)
+ }
+}
+
+func TestResolveDriveMemberAddTarget_RejectsEmpty(t *testing.T) {
+ t.Parallel()
+
+ _, _, err := resolveDriveMemberAddTarget("", "")
+ if err == nil || !strings.Contains(err.Error(), "--token is required") {
+ t.Fatalf("expected --token required error, got: %v", err)
+ }
+}
+
+// ── inferMemberTypeFromID unit tests ────────────────────────────────────────
+
+func TestInferMemberTypeFromID(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ memberID string
+ want string
+ }{
+ {"ou_xxx", "openid"},
+ {"on_xxx", "unionid"},
+ {"oc_xxx", "openchat"},
+ {"od_xxx", "opendepartmentid"},
+ {"user@example.com", "email"},
+ {"ambiguous", ""},
+ {"", ""},
+ }
+ for _, tt := range tests {
+ got := inferMemberTypeFromID(tt.memberID)
+ if got != tt.want {
+ t.Errorf("inferMemberTypeFromID(%q) = %q, want %q", tt.memberID, got, tt.want)
+ }
+ }
+}
+
+func TestResolveDriveMemberAddMemberType(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ memberIDs []string
+ explicit string
+ wantType string
+ wantErr string
+ }{
+ {
+ name: "single explicit openid",
+ memberIDs: []string{"ou_x"},
+ explicit: "openid",
+ wantType: "openid",
+ },
+ {
+ name: "explicit openchat",
+ memberIDs: []string{"oc_a"},
+ explicit: "openchat",
+ wantType: "openchat",
+ },
+ {
+ name: "explicit groupid",
+ memberIDs: []string{"group_1"},
+ explicit: "groupid",
+ wantType: "groupid",
+ },
+ {
+ name: "explicit appid",
+ memberIDs: []string{"cli_xxx"},
+ explicit: "appid",
+ wantType: "appid",
+ },
+ {
+ name: "explicit wikispaceid",
+ memberIDs: []string{"space_xxx"},
+ explicit: "wikispaceid",
+ wantType: "wikispaceid",
+ },
+ {
+ name: "missing member-type rejected",
+ memberIDs: []string{"ou_a"},
+ wantErr: "--member-type is required",
+ },
+ {
+ name: "prefix conflicts with explicit type",
+ memberIDs: []string{"oc_chat"},
+ explicit: "openid",
+ wantErr: "implies --member-type openchat",
+ },
+ {
+ name: "email prefix matches explicit email",
+ memberIDs: []string{"user@example.com"},
+ explicit: "email",
+ wantType: "email",
+ },
+ }
+
+ for _, temp := range tests {
+ tt := temp
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ gotType, err := resolveDriveMemberAddMemberType(tt.memberIDs, tt.explicit)
+ if tt.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
+ t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if gotType != tt.wantType {
+ t.Fatalf("got type=%q, want %q", gotType, tt.wantType)
+ }
+ })
+ }
+}
+
+func TestResolveDriveMemberAddMemberKind(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ memberType string
+ raw string
+ want string
+ wantErr string
+ }{
+ {
+ name: "wikispaceid requires explicit wiki-space kind",
+ memberType: "wikispaceid",
+ wantErr: "--member-kind is required when --member-type=wikispaceid",
+ },
+ {
+ name: "wikispaceid accepts member kind",
+ memberType: "wikispaceid",
+ raw: "wiki_space_member",
+ want: "wiki_space_member",
+ },
+ {
+ name: "wikispaceid accepts uppercase member kind",
+ memberType: "wikispaceid",
+ raw: "WIKI_SPACE_EDITOR",
+ want: "wiki_space_editor",
+ },
+ {
+ name: "reject invalid member kind",
+ memberType: "wikispaceid",
+ raw: "user",
+ wantErr: "invalid value \"user\" for --member-kind",
+ },
+ {
+ name: "reject member kind for other member type",
+ memberType: "openid",
+ raw: "wiki_space_viewer",
+ wantErr: "--member-kind only applies when --member-type=wikispaceid",
+ },
+ }
+
+ for _, temp := range tests {
+ tt := temp
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ got, err := resolveDriveMemberAddMemberKind(tt.memberType, tt.raw)
+ if tt.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
+ t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != tt.want {
+ t.Fatalf("memberKind = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestNormalizeDriveMemberAddMemberType(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ in string
+ want string
+ }{
+ {"openid", "openid"},
+ {"groupid", "groupid"},
+ {"appid", "appid"},
+ {"wikispaceid", "wikispaceid"},
+ }
+ for _, tt := range tests {
+ if got := normalizeDriveMemberAddMemberType(tt.in); got != tt.want {
+ t.Fatalf("normalizeDriveMemberAddMemberType(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ }
+}
+
+// ── splitAndTrimMembers unit tests ──────────────────────────────────────────
+
+func TestSplitAndTrimMembers(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ raw string
+ want []string
+ }{
+ {"ou_a", []string{"ou_a"}},
+ {"ou_a,ou_b,ou_c", []string{"ou_a", "ou_b", "ou_c"}},
+ {" ou_a , ou_b ", []string{"ou_a", "ou_b"}},
+ {"ou_a,,ou_b", []string{"ou_a", "ou_b"}},
+ }
+ for _, tt := range tests {
+ got := splitAndTrimMembers(tt.raw)
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("splitAndTrimMembers(%q) = %#v, want %#v", tt.raw, got, tt.want)
+ }
+ }
+}
+
+// ── spec body/query construction unit tests ─────────────────────────────────
+
+func TestDriveMemberAddSpec_BuildsBodyAndQuery(t *testing.T) {
+ t.Parallel()
+
+ spec := driveMemberAddSpec{
+ Token: "doxTok",
+ ResourceType: "docx",
+ MemberIDs: []string{"ou_x"},
+ MemberType: "openid",
+ Perm: "edit",
+ }
+ if got := spec.DryRunParams(); !reflect.DeepEqual(got, map[string]interface{}{"type": "docx"}) {
+ t.Fatalf("DryRunParams() = %#v", got)
+ }
+ if got := spec.APIQueryParams(); !reflect.DeepEqual(got, map[string]interface{}{"type": "docx"}) {
+ t.Fatalf("APIQueryParams() = %#v", got)
+ }
+ wantBody := map[string]interface{}{
+ "member_id": "ou_x",
+ "member_type": "openid",
+ "perm": "edit",
+ "type": "user",
+ }
+ if got := buildMemberBody("ou_x", "openid", "", "edit", ""); !reflect.DeepEqual(got, wantBody) {
+ t.Fatalf("buildMemberBody() = %#v, want %#v", got, wantBody)
+ }
+}
+
+func TestDriveMemberAddSpec_BuildsWikiSpaceIDBody(t *testing.T) {
+ t.Parallel()
+
+ wantBody := map[string]interface{}{
+ "member_id": "spc_x",
+ "member_type": "wikispaceid",
+ "perm": "view",
+ "type": "wiki_space_editor",
+ }
+ if got := buildMemberBody("spc_x", "wikispaceid", "wiki_space_editor", "view", ""); !reflect.DeepEqual(got, wantBody) {
+ t.Fatalf("buildMemberBody() = %#v, want %#v", got, wantBody)
+ }
+}
+
+func TestDriveMemberAddBodyType(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ memberType string
+ wikiSpaceMemberKind string
+ want string
+ }{
+ {name: "regular user infers body type", memberType: "openid", want: "user"},
+ {name: "regular group infers body type", memberType: "groupid", want: "group"},
+ {name: "wiki space uses explicit member kind", memberType: "wikispaceid", wikiSpaceMemberKind: "wiki_space_viewer", want: "wiki_space_viewer"},
+ {name: "wiki space does not infer fallback type", memberType: "wikispaceid", want: ""},
+ }
+
+ for _, temp := range tests {
+ tt := temp
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ if got := driveMemberAddBodyType(tt.memberType, tt.wikiSpaceMemberKind); got != tt.want {
+ t.Fatalf("driveMemberAddBodyType() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestDriveMemberAddSpec_HonorsExplicitNotificationFalse(t *testing.T) {
+ t.Parallel()
+
+ spec := driveMemberAddSpec{
+ ResourceType: "docx",
+ NotificationSet: true,
+ NeedNotification: false,
+ }
+ if got := spec.DryRunParams(); !reflect.DeepEqual(got, map[string]interface{}{"type": "docx", "need_notification": false}) {
+ t.Fatalf("DryRunParams() = %#v", got)
+ }
+ if got := spec.APIQueryParams(); !reflect.DeepEqual(got, map[string]interface{}{"type": "docx", "need_notification": "false"}) {
+ t.Fatalf("APIQueryParams() = %#v", got)
+ }
+}
+
+func TestDriveMemberAddOutputBackfillsProvidedMemberID(t *testing.T) {
+ t.Parallel()
+
+ spec := driveMemberAddSpec{
+ Token: "doxTok",
+ ResourceType: "docx",
+ MemberIDs: []string{"ou_a", "ou_b"},
+ MemberType: "openid",
+ Perm: "view",
+ }
+ out := driveMemberAddOutput(spec, "ou_b", map[string]interface{}{"perm": "view"})
+ if out["member_id"] != "ou_b" {
+ t.Fatalf("member_id = %v, want ou_b", out["member_id"])
+ }
+}
+
+func TestDriveMemberAddOutput_BackfillsAppIDMemberType(t *testing.T) {
+ t.Parallel()
+
+ spec := driveMemberAddSpec{
+ Token: "doxTok",
+ ResourceType: "docx",
+ MemberIDs: []string{"cli_app_123"},
+ MemberType: "appid",
+ Perm: "view",
+ }
+ out := driveMemberAddOutput(spec, "cli_app_123", map[string]interface{}{"perm": "view"})
+ if out["member_type"] != "appid" {
+ t.Fatalf("member_type = %v, want appid", out["member_type"])
+ }
+}
+
+func TestDriveMemberAddOutput_OmitsPermTypeForNonWiki(t *testing.T) {
+ t.Parallel()
+
+ spec := driveMemberAddSpec{
+ Token: "doxTok",
+ ResourceType: "docx",
+ MemberIDs: []string{"ou_x"},
+ MemberType: "openid",
+ Perm: "view",
+ }
+ out := driveMemberAddOutput(spec, "ou_x", map[string]interface{}{
+ "member_id": "ou_x",
+ "member_type": "openid",
+ "perm": "view",
+ "perm_type": "container",
+ "type": "user",
+ })
+ if _, ok := out["perm_type"]; ok {
+ t.Fatalf("perm_type should be omitted for non-wiki output, got %#v", out["perm_type"])
+ }
+}
+
+func TestBuildDriveMemberAddBatchResult_OmitsPermTypeForNonWiki(t *testing.T) {
+ t.Parallel()
+
+ spec := driveMemberAddSpec{
+ Token: "doxTok",
+ ResourceType: "docx",
+ MemberIDs: []string{"ou_a", "ou_b"},
+ MemberType: "openid",
+ Perm: "view",
+ }
+ result := buildDriveMemberAddBatchResult(spec, map[string]interface{}{
+ "members": []interface{}{
+ map[string]interface{}{"member_id": "ou_a", "member_type": "openid", "perm": "view", "perm_type": "container", "type": "user"},
+ map[string]interface{}{"member_id": "ou_b", "member_type": "openid", "perm": "view", "perm_type": "container", "type": "user"},
+ },
+ })
+ members, ok := result["members"].([]map[string]interface{})
+ if !ok {
+ t.Fatalf("members = %#v, want []map[string]interface{}", result["members"])
+ }
+ for i, member := range members {
+ if _, exists := member["perm_type"]; exists {
+ t.Fatalf("members[%d].perm_type should be omitted for non-wiki output, got %#v", i, member["perm_type"])
+ }
+ }
+}
+
+// ── shortcut integration tests ──────────────────────────────────────────────
+
+func TestDriveMemberAdd_PermDefaultsToView(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "doxcnTok",
+ "--type", "docx",
+ "--member-id", "ou_x",
+ "--member-type", "openid",
+ "--dry-run",
+ "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var got struct {
+ API []struct {
+ Body map[string]interface{} `json:"body"`
+ } `json:"api"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
+ t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
+ }
+ if got.API[0].Body["perm"] != "view" {
+ t.Fatalf("perm = %v, want view", got.API[0].Body["perm"])
+ }
+}
+
+func TestDriveMemberAdd_RejectsNotificationWithBot(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "doxcnTok",
+ "--type", "docx",
+ "--member-id", "ou_x",
+ "--member-type", "openid",
+ "--perm", "view",
+ "--need-notification",
+ "--as", "bot",
+ "--yes",
+ }, f, stdout)
+ if err == nil || !strings.Contains(err.Error(), "--need-notification is only valid with --as user") {
+ t.Fatalf("expected bot notification validation error, got: %v", err)
+ }
+}
+
+func TestDriveMemberAdd_RejectsDepartmentWithBot(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "doxcnTok",
+ "--type", "docx",
+ "--member-id", "od_dept",
+ "--member-type", "opendepartmentid",
+ "--perm", "view",
+ "--as", "bot",
+ "--yes",
+ }, f, stdout)
+ if err == nil || !strings.Contains(err.Error(), "--member-type=opendepartmentid requires --as user") {
+ t.Fatalf("expected bot+opendepartmentid validation error, got: %v", err)
+ }
+}
+
+func TestDriveMemberAdd_AcceptsAmbiguousIDWithExplicitType(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/permissions/doxcnTok/members",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "success",
+ "data": map[string]interface{}{
+ "member": map[string]interface{}{
+ "member_id": "ambiguous_id",
+ "member_type": "openid",
+ "perm": "view",
+ "type": "user",
+ },
+ },
+ },
+ }
+ reg.Register(stub)
+
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "doxcnTok",
+ "--type", "docx",
+ "--member-id", "ambiguous_id",
+ "--member-type", "openid",
+ "--perm", "view",
+ "--as", "user",
+ "--yes",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestDriveMemberAdd_DryRunAcceptsAppID(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "doxcnTok",
+ "--type", "docx",
+ "--member-id", "cli_app_123",
+ "--member-type", "appid",
+ "--perm", "view",
+ "--dry-run",
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var got struct {
+ API []struct {
+ Body map[string]interface{} `json:"body"`
+ } `json:"api"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
+ t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
+ }
+ if got.API[0].Body["member_type"] != "appid" {
+ t.Fatalf("member_type = %v, want appid", got.API[0].Body["member_type"])
+ }
+ if _, ok := got.API[0].Body["type"]; ok {
+ t.Fatalf("type = %v, want omitted for appid", got.API[0].Body["type"])
+ }
+}
+
+func TestDriveMemberAdd_DryRunAcceptsWikiSpaceID(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "doxcnTok",
+ "--type", "docx",
+ "--member-id", "spc_x",
+ "--member-type", "wikispaceid",
+ "--member-kind", "wiki_space_viewer",
+ "--perm", "view",
+ "--dry-run",
+ "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var got struct {
+ API []struct {
+ Body map[string]interface{} `json:"body"`
+ } `json:"api"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
+ t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
+ }
+ if got.API[0].Body["member_type"] != "wikispaceid" || got.API[0].Body["type"] != "wiki_space_viewer" {
+ t.Fatalf("body = %#v, want wikispaceid + wiki_space_viewer", got.API[0].Body)
+ }
+}
+
+func TestDriveMemberAdd_RejectsWikiSpaceIDWithoutMemberKind(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "doxcnTok",
+ "--type", "docx",
+ "--member-id", "spc_x",
+ "--member-type", "wikispaceid",
+ "--perm", "view",
+ "--dry-run",
+ "--as", "user",
+ }, f, stdout)
+ if err == nil || !strings.Contains(err.Error(), "--member-kind is required when --member-type=wikispaceid") {
+ t.Fatalf("expected wikispaceid member-kind validation error, got: %v", err)
+ }
+}
+
+func TestDriveMemberAdd_RejectsMemberKindForNonWikiSpaceID(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "doxcnTok",
+ "--type", "docx",
+ "--member-id", "ou_x",
+ "--member-type", "openid",
+ "--member-kind", "wiki_space_member",
+ "--perm", "view",
+ "--dry-run",
+ "--as", "user",
+ }, f, stdout)
+ if err == nil || !strings.Contains(err.Error(), "--member-kind only applies when --member-type=wikispaceid") {
+ t.Fatalf("expected non-wikispaceid member-kind validation error, got: %v", err)
+ }
+}
+
+func TestDriveMemberAdd_RejectsBlankMemberIDList(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "doxcnTok",
+ "--type", "docx",
+ "--member-id", ",,,",
+ "--member-type", "openid",
+ "--perm", "view",
+ "--as", "user",
+ "--yes",
+ }, f, stdout)
+ if err == nil || !strings.Contains(err.Error(), "at least one non-blank ID") {
+ t.Fatalf("expected blank member-id validation error, got: %v", err)
+ }
+}
+
+func TestDriveMemberAdd_RejectsBatchOverLimit(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ ids := make([]string, 11)
+ for i := range ids {
+ ids[i] = fmt.Sprintf("ou_%d", i)
+ }
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "doxcnTok",
+ "--type", "docx",
+ "--member-id", strings.Join(ids, ","),
+ "--member-type", "openid",
+ "--perm", "view",
+ "--as", "user",
+ "--yes",
+ }, f, stdout)
+ if err == nil || !strings.Contains(err.Error(), "at most 10") {
+ t.Fatalf("expected batch limit error, got: %v", err)
+ }
+}
+
+func TestDriveMemberAdd_RejectsDuplicateBatchMemberIDs(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "doxcnTok",
+ "--type", "docx",
+ "--member-id", "ou_a,ou_b,ou_a",
+ "--member-type", "openid",
+ "--perm", "view",
+ "--as", "user",
+ "--yes",
+ }, f, stdout)
+ if err == nil || !strings.Contains(err.Error(), "duplicate collaborator ID") {
+ t.Fatalf("expected duplicate member-id validation error, got: %v", err)
+ }
+}
+
+func TestDriveMemberAdd_DryRunInfersTypeAndDefaultsWikiPermType(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "https://example.feishu.cn/wiki/wikTok?from=share",
+ "--member-id", "ou_x",
+ "--member-type", "openid",
+ "--perm", "full_access",
+ "--need-notification=false",
+ "--dry-run",
+ "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var got struct {
+ API []struct {
+ Method string `json:"method"`
+ URL string `json:"url"`
+ Params map[string]interface{} `json:"params"`
+ Body map[string]interface{} `json:"body"`
+ } `json:"api"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
+ t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
+ }
+ if len(got.API) != 1 {
+ t.Fatalf("api count = %d, want 1; stdout=%s", len(got.API), stdout.String())
+ }
+ api := got.API[0]
+ if api.Method != "POST" || api.URL != "/open-apis/drive/v1/permissions/wikTok/members" {
+ t.Fatalf("api = %#v", api)
+ }
+ if api.Params["type"] != "wiki" || api.Params["need_notification"] != false {
+ t.Fatalf("params = %#v", api.Params)
+ }
+ if api.Body["member_id"] != "ou_x" || api.Body["member_type"] != "openid" || api.Body["perm"] != "full_access" || api.Body["type"] != "user" || api.Body["perm_type"] != "container" {
+ t.Fatalf("body = %#v", api.Body)
+ }
+}
+
+func TestDriveMemberAdd_DryRunAcceptsUppercaseEnumsForDocx(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "doxcnTok",
+ "--type", "DOCX",
+ "--member-id", "ou_x",
+ "--member-type", "OPENID",
+ "--perm", "EDIT",
+ "--dry-run",
+ "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var got struct {
+ API []struct {
+ Params map[string]interface{} `json:"params"`
+ Body map[string]interface{} `json:"body"`
+ } `json:"api"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
+ t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
+ }
+ if got.API[0].Params["type"] != "docx" {
+ t.Fatalf("params.type = %v, want docx", got.API[0].Params["type"])
+ }
+ if got.API[0].Body["member_type"] != "openid" || got.API[0].Body["perm"] != "edit" {
+ t.Fatalf("body = %#v, want canonical lowercase enum values", got.API[0].Body)
+ }
+}
+
+func TestDriveMemberAdd_DryRunAcceptsUppercaseWikiPermType(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "wikcnTok",
+ "--type", "WIKI",
+ "--member-id", "ou_x",
+ "--member-type", "OPENID",
+ "--perm", "EDIT",
+ "--perm-type", "CONTAINER",
+ "--dry-run",
+ "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var got struct {
+ API []struct {
+ Params map[string]interface{} `json:"params"`
+ Body map[string]interface{} `json:"body"`
+ } `json:"api"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
+ t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
+ }
+ if got.API[0].Params["type"] != "wiki" {
+ t.Fatalf("params.type = %v, want wiki", got.API[0].Params["type"])
+ }
+ if got.API[0].Body["member_type"] != "openid" || got.API[0].Body["perm"] != "edit" || got.API[0].Body["perm_type"] != "container" {
+ t.Fatalf("body = %#v, want canonical lowercase enum values", got.API[0].Body)
+ }
+}
+
+func TestDriveMemberAdd_RejectsInvalidPermLocallyWithoutGlobalEnum(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "doxcnTok",
+ "--type", "DOCX",
+ "--member-id", "ou_x",
+ "--member-type", "OPENID",
+ "--perm", "INVALID_EDIT",
+ "--dry-run",
+ "--as", "user",
+ }, f, stdout)
+ if err == nil || !strings.Contains(err.Error(), "invalid value \"INVALID_EDIT\" for --perm") {
+ t.Fatalf("expected local invalid --perm validation error, got: %v", err)
+ }
+}
+
+func TestDriveMemberAdd_PermTypeRejectedForNonWiki(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "doxcnTok",
+ "--type", "docx",
+ "--member-id", "ou_x",
+ "--member-type", "openid",
+ "--perm", "edit",
+ "--perm-type", "single_page",
+ "--dry-run",
+ "--as", "user",
+ }, f, stdout)
+ if err == nil {
+ t.Fatalf("expected validation error for --perm-type on non-wiki resource")
+ }
+ if got, want := err.Error(), "--perm-type only applies when resource type is wiki"; !strings.Contains(got, want) {
+ t.Fatalf("error %q does not contain %q", got, want)
+ }
+}
+
+func TestDriveMemberAdd_DryRunBatch(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "shtcnTok",
+ "--type", "sheet",
+ "--member-id", "ou_a,ou_b,ou_c",
+ "--member-type", "openid",
+ "--perm", "edit",
+ "--dry-run",
+ "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var got struct {
+ API []struct {
+ Method string `json:"method"`
+ URL string `json:"url"`
+ Body map[string]interface{} `json:"body"`
+ } `json:"api"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
+ t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
+ }
+ if len(got.API) != 1 {
+ t.Fatalf("api count = %d, want 1", len(got.API))
+ }
+ api := got.API[0]
+ if api.Method != "POST" || api.URL != "/open-apis/drive/v1/permissions/shtcnTok/members/batch_create" {
+ t.Fatalf("api = %#v", api)
+ }
+ members, ok := api.Body["members"].([]interface{})
+ if !ok || len(members) != 3 {
+ t.Fatalf("body.members = %#v, want 3 items", api.Body["members"])
+ }
+}
+
+func TestDriveMemberAdd_ExecuteSuccessFlattensMember(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
+
+ var capturedQuery string
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/permissions/doxcnTok/members",
+ OnMatch: func(req *http.Request) {
+ capturedQuery = req.URL.RawQuery
+ },
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "success",
+ "data": map[string]interface{}{
+ "member": map[string]interface{}{
+ "member_id": "ou_x",
+ "member_type": "openid",
+ "perm": "view",
+ "type": "user",
+ },
+ },
+ },
+ }
+ reg.Register(stub)
+
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "doxcnTok",
+ "--type", "docx",
+ "--member-id", "ou_x",
+ "--member-type", "openid",
+ "--perm", "view",
+ "--need-notification",
+ "--as", "user",
+ "--yes",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var captured map[string]interface{}
+ if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {
+ t.Fatalf("decode captured body: %v\n%s", err, string(stub.CapturedBody))
+ }
+ wantBody := map[string]interface{}{"member_id": "ou_x", "member_type": "openid", "perm": "view", "type": "user"}
+ if !reflect.DeepEqual(captured, wantBody) {
+ t.Fatalf("captured body = %#v, want %#v", captured, wantBody)
+ }
+ if !strings.Contains(capturedQuery, "type=docx") || !strings.Contains(capturedQuery, "need_notification=true") {
+ t.Fatalf("captured query = %q", capturedQuery)
+ }
+
+ data := decodeDriveEnvelope(t, stdout)
+ if data["resource_token"] != "doxcnTok" || data["resource_type"] != "docx" ||
+ data["member_id"] != "ou_x" || data["member_type"] != "openid" ||
+ data["perm"] != "view" || data["member_kind"] != "user" {
+ t.Fatalf("flattened output = %#v", data)
+ }
+ if !strings.Contains(stderr.String(), "Added Drive member") {
+ t.Fatalf("stderr = %q, want success log", stderr.String())
+ }
+}
+
+func TestDriveMemberAdd_ExecuteBatchSuccess(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
+
+ var capturedQuery string
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/permissions/bascnTok/members/batch_create",
+ OnMatch: func(req *http.Request) {
+ capturedQuery = req.URL.RawQuery
+ },
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "success",
+ "data": map[string]interface{}{
+ "members": []interface{}{
+ map[string]interface{}{"member_id": "ou_a", "member_type": "openid", "perm": "view", "type": "user"},
+ map[string]interface{}{"member_id": "ou_b", "member_type": "openid", "perm": "view", "type": "user"},
+ },
+ },
+ },
+ }
+ reg.Register(stub)
+
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "bascnTok",
+ "--type", "bitable",
+ "--member-id", "ou_a,ou_b",
+ "--member-type", "openid",
+ "--perm", "view",
+ "--as", "user",
+ "--yes",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ data := decodeDriveEnvelope(t, stdout)
+ if data["requested_count"] != float64(2) || data["succeeded_count"] != float64(2) || data["partial"] != false {
+ t.Fatalf("batch output counts = %#v", data)
+ }
+ missing, ok := data["missing_member_ids"].([]interface{})
+ if !ok || len(missing) != 0 {
+ t.Fatalf("missing_member_ids = %#v, want empty array", data["missing_member_ids"])
+ }
+ members, ok := data["members"].([]interface{})
+ if !ok || len(members) != 2 {
+ t.Fatalf("members = %#v, want 2", data["members"])
+ }
+ first, _ := members[0].(map[string]interface{})
+ second, _ := members[1].(map[string]interface{})
+ if first["member_id"] != "ou_a" || second["member_id"] != "ou_b" {
+ t.Fatalf("members = %#v, want request-order fallback IDs", members)
+ }
+ if !strings.Contains(stderr.String(), "Added 2 Drive member(s)") {
+ t.Fatalf("stderr = %q, want success log", stderr.String())
+ }
+ if !strings.Contains(capturedQuery, "type=bitable") {
+ t.Fatalf("captured query = %q, want type=bitable for bascn token", capturedQuery)
+ }
+}
+
+func TestDriveMemberAdd_ExecuteBatchSuccessWithOutOfOrderResponse(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
+
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/permissions/bascnTok/members/batch_create",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "success",
+ "data": map[string]interface{}{
+ "members": []interface{}{
+ map[string]interface{}{"member_id": "ou_b", "member_type": "openid", "perm": "view", "type": "user"},
+ map[string]interface{}{"member_id": "ou_a", "member_type": "openid", "perm": "view", "type": "user"},
+ },
+ },
+ },
+ }
+ reg.Register(stub)
+
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "bascnTok",
+ "--type", "bitable",
+ "--member-id", "ou_a,ou_b",
+ "--member-type", "openid",
+ "--perm", "view",
+ "--as", "user",
+ "--yes",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ data := decodeDriveEnvelope(t, stdout)
+ if data["requested_count"] != float64(2) || data["succeeded_count"] != float64(2) || data["partial"] != false {
+ t.Fatalf("batch output counts = %#v", data)
+ }
+ missing, ok := data["missing_member_ids"].([]interface{})
+ if !ok || len(missing) != 0 {
+ t.Fatalf("missing_member_ids = %#v, want empty array", data["missing_member_ids"])
+ }
+ if _, ok := data["mismatched_member_ids"]; ok {
+ t.Fatalf("mismatched_member_ids should not be present on success: %#v", data["mismatched_member_ids"])
+ }
+ members, ok := data["members"].([]interface{})
+ if !ok || len(members) != 2 {
+ t.Fatalf("members = %#v, want 2", data["members"])
+ }
+}
+
+func TestDriveMemberAdd_ExecuteBatchPartialWhenResponseMissingMember(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
+
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/permissions/bascnTok/members/batch_create",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "success",
+ "data": map[string]interface{}{
+ "members": []interface{}{
+ map[string]interface{}{"member_id": "ou_a", "member_type": "openid", "perm": "view", "type": "user"},
+ },
+ },
+ },
+ }
+ reg.Register(stub)
+
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "bascnTok",
+ "--type", "bitable",
+ "--member-id", "ou_a,ou_b",
+ "--member-type", "openid",
+ "--perm", "view",
+ "--as", "user",
+ "--yes",
+ }, f, stdout)
+ exitErr := assertDriveMemberAddPartialFailure(t, err)
+ if exitErr.Code != output.ExitAPI {
+ t.Fatalf("exit code = %d, want %d (ExitAPI)", exitErr.Code, output.ExitAPI)
+ }
+
+ // stdout must carry ok:false envelope with structured partial result.
+ var env struct {
+ OK bool `json:"ok"`
+ Data map[string]interface{} `json:"data"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
+ t.Fatalf("unmarshal stdout envelope: %v\nstdout: %s", err, stdout.String())
+ }
+ if env.OK {
+ t.Fatalf("ok must be false on partial failure, got ok:true\nstdout: %s", stdout.String())
+ }
+ if v := common.GetInt(env.Data, "succeeded_count"); v != 1 {
+ t.Fatalf("succeeded_count = %d, want 1", v)
+ }
+ if v := common.GetInt(env.Data, "requested_count"); v != 2 {
+ t.Fatalf("requested_count = %d, want 2", v)
+ }
+ if v := common.GetBool(env.Data, "partial"); !v {
+ t.Fatal("partial must be true")
+ }
+}
+
+func TestDriveMemberAdd_ExecuteBatchPartialDoesNotInferMissingMemberID(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
+
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/permissions/bascnTok/members/batch_create",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "success",
+ "data": map[string]interface{}{
+ "members": []interface{}{
+ map[string]interface{}{"member_type": "openid", "perm": "view", "type": "user"},
+ },
+ },
+ },
+ }
+ reg.Register(stub)
+
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "bascnTok",
+ "--type", "bitable",
+ "--member-id", "ou_a,ou_b",
+ "--member-type", "openid",
+ "--perm", "view",
+ "--as", "user",
+ "--yes",
+ }, f, stdout)
+ exitErr := assertDriveMemberAddPartialFailure(t, err)
+ if exitErr.Code != output.ExitAPI {
+ t.Fatalf("exit code = %d, want %d (ExitAPI)", exitErr.Code, output.ExitAPI)
+ }
+
+ var env struct {
+ OK bool `json:"ok"`
+ Data map[string]interface{} `json:"data"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
+ t.Fatalf("unmarshal stdout envelope: %v\nstdout: %s", err, stdout.String())
+ }
+ if env.OK {
+ t.Fatalf("ok must be false on partial failure, got ok:true\nstdout: %s", stdout.String())
+ }
+ if v := common.GetInt(env.Data, "succeeded_count"); v != 0 {
+ t.Fatalf("succeeded_count = %d, want 0", v)
+ }
+ if v := common.GetInt(env.Data, "requested_count"); v != 2 {
+ t.Fatalf("requested_count = %d, want 2", v)
+ }
+ if v := common.GetBool(env.Data, "partial"); !v {
+ t.Fatal("partial must be true")
+ }
+}
+
+func TestDriveMemberAdd_ExecuteBatchPartialWhenMemberIDMismatches(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
+
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/permissions/bascnTok/members/batch_create",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "success",
+ "data": map[string]interface{}{
+ "members": []interface{}{
+ map[string]interface{}{"member_id": "ou_a", "member_type": "openid", "perm": "view", "type": "user"},
+ map[string]interface{}{"member_id": "ou_other", "member_type": "openid", "perm": "view", "type": "user"},
+ },
+ },
+ },
+ }
+ reg.Register(stub)
+
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "bascnTok",
+ "--type", "bitable",
+ "--member-id", "ou_a,ou_b",
+ "--member-type", "openid",
+ "--perm", "view",
+ "--as", "user",
+ "--yes",
+ }, f, stdout)
+ exitErr := assertDriveMemberAddPartialFailure(t, err)
+ if exitErr.Code != output.ExitAPI {
+ t.Fatalf("exit code = %d, want %d (ExitAPI)", exitErr.Code, output.ExitAPI)
+ }
+
+ var env struct {
+ OK bool `json:"ok"`
+ Data map[string]interface{} `json:"data"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
+ t.Fatalf("unmarshal stdout envelope: %v\nstdout: %s", err, stdout.String())
+ }
+ if env.OK {
+ t.Fatalf("ok must be false on partial failure, got ok:true\nstdout: %s", stdout.String())
+ }
+ if v := common.GetBool(env.Data, "partial"); !v {
+ t.Fatal("partial must be true")
+ }
+ missing, _ := env.Data["missing_member_ids"].([]interface{})
+ if len(missing) != 1 || missing[0] != "ou_b" {
+ t.Fatalf("missing_member_ids = %#v, want [ou_b]", env.Data["missing_member_ids"])
+ }
+ mismatched, _ := env.Data["mismatched_member_ids"].([]interface{})
+ if len(mismatched) != 1 {
+ t.Fatalf("mismatched_member_ids = %#v, want 1 entry", env.Data["mismatched_member_ids"])
+ }
+ mismatchedEntry, _ := mismatched[0].(map[string]interface{})
+ if mismatchedEntry["returned"] != "ou_other" {
+ t.Fatalf("mismatched_member_ids[0].returned = %#v, want ou_other", mismatchedEntry["returned"])
+ }
+}
+
+func TestDriveMemberAdd_ExecuteBatchInvalidOperationHasActionableHint(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
+
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/permissions/bascnTok/members/batch_create",
+ Body: map[string]interface{}{
+ "code": 1063003,
+ "msg": "Invalid operation",
+ "data": map[string]interface{}{},
+ },
+ }
+ reg.Register(stub)
+
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "bascnTok",
+ "--type", "bitable",
+ "--member-id", "ou_a,ou_b",
+ "--member-type", "openid",
+ "--perm", "view",
+ "--as", "user",
+ "--yes",
+ }, f, stdout)
+ if err == nil {
+ t.Fatal("expected API error, got nil")
+ }
+ var apiErr *errs.APIError
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("expected *errs.APIError, got %T: %v", err, err)
+ }
+ if apiErr.Code != 1063003 {
+ t.Fatalf("code = %d, want 1063003", apiErr.Code)
+ }
+ if !strings.Contains(apiErr.Message, "requested members may already be collaborators") {
+ t.Fatalf("message = %q, want duplicate-permission guidance", apiErr.Message)
+ }
+ if !strings.Contains(apiErr.Hint, "retry only the missing collaborators") {
+ t.Fatalf("hint = %q, want retry guidance", apiErr.Hint)
+ }
+}
+
+func TestDriveMemberAdd_ExecuteBatchInvalidParameterHasConservativeHint(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
+
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/permissions/bascnTok/members/batch_create",
+ Body: map[string]interface{}{
+ "code": 1063001,
+ "msg": "Invalid parameter",
+ "data": map[string]interface{}{},
+ },
+ }
+ reg.Register(stub)
+
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "bascnTok",
+ "--type", "bitable",
+ "--member-id", "ou_a,ou_missing",
+ "--member-type", "openid",
+ "--perm", "view",
+ "--as", "user",
+ "--yes",
+ }, f, stdout)
+ if err == nil {
+ t.Fatal("expected API error, got nil")
+ }
+ var apiErr *errs.APIError
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("expected *errs.APIError, got %T: %v", err, err)
+ }
+ if apiErr.Code != 1063001 {
+ t.Fatalf("code = %d, want 1063001", apiErr.Code)
+ }
+ if !strings.Contains(apiErr.Message, "requested members may be invalid") {
+ t.Fatalf("message = %q, want invalid-member guidance", apiErr.Message)
+ }
+ if !strings.Contains(apiErr.Hint, "belongs to the same tenant") || !strings.Contains(apiErr.Hint, "visible to the current identity") {
+ t.Fatalf("hint = %q, want conservative validation guidance", apiErr.Hint)
+ }
+}
+
+func TestDriveMemberAdd_ExecuteSuccessAsBot(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
+
+ var capturedQuery string
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/permissions/wikcnBotTok/members",
+ OnMatch: func(req *http.Request) {
+ capturedQuery = req.URL.RawQuery
+ },
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "success",
+ "data": map[string]interface{}{
+ "member": map[string]interface{}{
+ "member_id": "ou_bot_target",
+ "member_type": "openid",
+ "perm": "edit",
+ "type": "user",
+ },
+ },
+ },
+ }
+ reg.Register(stub)
+
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "wikcnBotTok",
+ "--type", "wiki",
+ "--member-id", "ou_bot_target",
+ "--member-type", "openid",
+ "--perm", "edit",
+ "--as", "bot",
+ "--yes",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Bot identity should NOT send need_notification in query.
+ if strings.Contains(capturedQuery, "need_notification") {
+ t.Fatalf("captured query = %q, want need_notification absent for bot", capturedQuery)
+ }
+
+ var captured map[string]interface{}
+ if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {
+ t.Fatalf("decode captured body: %v", err)
+ }
+ if captured["perm"] != "edit" {
+ t.Fatalf("captured body perm = %v, want edit", captured["perm"])
+ }
+
+ data := decodeDriveEnvelope(t, stdout)
+ if data["resource_type"] != "wiki" || data["member_kind"] != "user" || data["perm"] != "edit" {
+ t.Fatalf("flattened output = %#v", data)
+ }
+}
+
+func assertDriveMemberAddPartialFailure(t *testing.T, err error) *output.PartialFailureError {
+ t.Helper()
+ if err == nil {
+ t.Fatal("expected partial_failure error, got nil")
+ }
+ var pfErr *output.PartialFailureError
+ if !errors.As(err, &pfErr) {
+ t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
+ }
+ return pfErr
+}
+
+func TestDriveMemberAdd_RequiresYesForHighRiskWrite(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
+ err := mountAndRunDrive(t, DriveMemberAdd, []string{
+ "+member-add",
+ "--token", "doxcnTok",
+ "--type", "docx",
+ "--member-id", "ou_x",
+ "--member-type", "openid",
+ "--perm", "view",
+ "--as", "user",
+ }, f, stdout)
+ if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
+ t.Fatalf("expected confirmation error, got: %v", err)
+ }
+}
diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go
index 3da5fdf5..281f8b9d 100644
--- a/shortcuts/drive/shortcuts.go
+++ b/shortcuts/drive/shortcuts.go
@@ -30,6 +30,7 @@ func Shortcuts() []common.Shortcut {
DriveSync,
DriveTaskResult,
DriveApplyPermission,
+ DriveMemberAdd,
DriveSecureLabelList,
DriveSecureLabelUpdate,
DriveSearch,
diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go
index a38a5c0f..85e45ed4 100644
--- a/shortcuts/drive/shortcuts_test.go
+++ b/shortcuts/drive/shortcuts_test.go
@@ -33,6 +33,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+sync",
"+task_result",
"+apply-permission",
+ "+member-add",
"+secure-label-list",
"+secure-label-update",
"+search",
diff --git a/shortcuts/minutes/minutes_detail.go b/shortcuts/minutes/minutes_detail.go
new file mode 100644
index 00000000..8a7f4f1a
--- /dev/null
+++ b/shortcuts/minutes/minutes_detail.go
@@ -0,0 +1,320 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+//
+// minutes +detail — query minute details with selective artifact flags
+
+package minutes
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/extension/fileio"
+ "github.com/larksuite/cli/internal/auth"
+ "github.com/larksuite/cli/internal/credential"
+ "github.com/larksuite/cli/internal/output"
+ "github.com/larksuite/cli/internal/validate"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+const minutesDetailLogPrefix = "[minutes +detail]"
+
+// Error codes from the minutes API.
+const minutesDetailNoReadPermissionCode = 2091005
+
+var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
+
+var scopesDetailMinuteTokens = []string{
+ "minutes:minutes.basic:read",
+ "minutes:minutes.artifacts:read",
+}
+
+// minuteDetailItem represents a single minute detail result.
+type minuteDetailItem struct {
+ MinuteToken string `json:"minute_token"`
+ Title string `json:"title"`
+ NoteID string `json:"note_id"`
+ Artifacts map[string]any `json:"artifacts,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// fetchMinuteDetail queries a single minute's metadata and selected artifacts.
+func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
+ data, err := runtime.CallAPITyped(http.MethodGet,
+ fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
+ if err != nil {
+ result := &minuteDetailItem{MinuteToken: minuteToken}
+ if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
+ result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
+ } else {
+ result.Error = fmt.Sprintf("failed to query minute: %v", err)
+ }
+ return result
+ }
+
+ minute, _ := data["minute"].(map[string]any)
+ if minute == nil {
+ return &minuteDetailItem{MinuteToken: minuteToken, Error: "minute not found"}
+ }
+
+ result := &minuteDetailItem{MinuteToken: minuteToken}
+ if v, ok := minute["title"].(string); ok && v != "" {
+ result.Title = v
+ }
+ if v, ok := minute["note_id"].(string); ok && v != "" {
+ result.NoteID = v
+ }
+
+ // Fetch artifacts selectively based on flags
+ needSummary := runtime.Bool("summary")
+ needTodo := runtime.Bool("todo")
+ needChapter := runtime.Bool("chapter")
+ needTranscript := runtime.Bool("transcript")
+ needKeyword := runtime.Bool("keyword")
+
+ if needSummary || needTodo || needChapter || needTranscript || needKeyword {
+ artData, err := runtime.CallAPITyped(http.MethodGet,
+ fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
+ if err != nil {
+ fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err)
+ } else {
+ artifacts := make(map[string]any)
+ if needSummary {
+ if v, ok := artData["summary"].(string); ok && v != "" {
+ artifacts["summary"] = v
+ } else {
+ artifacts["summary"] = ""
+ }
+ }
+ if needTodo {
+ if v, ok := artData["minute_todos"].([]any); ok && len(v) > 0 {
+ artifacts["todos"] = v
+ } else {
+ artifacts["todos"] = []any{}
+ }
+ }
+ if needChapter {
+ if v, ok := artData["minute_chapters"].([]any); ok && len(v) > 0 {
+ artifacts["chapters"] = v
+ } else {
+ artifacts["chapters"] = []any{}
+ }
+ }
+ if needKeyword {
+ if v, ok := artData["keywords"].([]any); ok && len(v) > 0 {
+ artifacts["keywords"] = v
+ } else {
+ artifacts["keywords"] = []any{}
+ }
+ }
+ if needTranscript {
+ if v, ok := artData["transcript"].(string); ok && v != "" {
+ if path := saveDetailTranscript(runtime, minuteToken, result.Title, []byte(v)); path != "" {
+ artifacts["transcript_file"] = path
+ } else {
+ artifacts["transcript_file"] = ""
+ }
+ } else {
+ artifacts["transcript_file"] = ""
+ }
+ }
+ result.Artifacts = artifacts
+ }
+ }
+
+ return result
+}
+
+// saveDetailTranscript persists transcript bytes to the canonical artifact path.
+// With --output-dir, transcripts land under /artifact--/
+// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
+// ./minutes// shared with `minutes +download`.
+func saveDetailTranscript(runtime *common.RuntimeContext, minuteToken, title string, content []byte) string {
+ errOut := runtime.IO().ErrOut
+ var dirName string
+ if outDir := runtime.Str("output-dir"); outDir != "" {
+ dirName = filepath.Join(outDir, sanitizeDetailDirName(title, minuteToken))
+ } else {
+ dirName = common.DefaultMinuteArtifactDir(minuteToken)
+ }
+ transcriptPath := filepath.Join(dirName, common.DefaultTranscriptFileName)
+
+ if !runtime.Bool("overwrite") {
+ if _, statErr := runtime.FileIO().Stat(transcriptPath); statErr == nil {
+ fmt.Fprintf(errOut, "%s transcript already exists: %s (use --overwrite to replace)\n", minutesDetailLogPrefix, transcriptPath)
+ return transcriptPath
+ }
+ }
+
+ fmt.Fprintf(errOut, "%s writing transcript: %s\n", minutesDetailLogPrefix, transcriptPath)
+ if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{}, bytes.NewReader(content)); err != nil {
+ fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", minutesDetailLogPrefix, err)
+ return ""
+ }
+ return transcriptPath
+}
+
+// sanitizeDetailDirName generates a filesystem-safe directory name using title
+// and minuteToken for uniqueness. Mirrors the layout produced by `vc +notes`
+// so both shortcuts write artifacts to identical paths under --output-dir.
+func sanitizeDetailDirName(title, minuteToken string) string {
+ const maxLen = 200
+ replacer := strings.NewReplacer(
+ "/", "_", "\\", "_", ":", "_", "*", "_", "?", "_",
+ "\"", "_", "<", "_", ">", "_", "|", "_",
+ "\n", "_", "\r", "_", "\t", "_", "\x00", "_",
+ )
+ safe := replacer.Replace(strings.TrimSpace(title))
+ safe = strings.Trim(safe, ".")
+ if len(safe) > maxLen {
+ safe = safe[:maxLen]
+ }
+ if safe == "" {
+ return fmt.Sprintf("artifact-%s", minuteToken)
+ }
+ return fmt.Sprintf("artifact-%s-%s", safe, minuteToken)
+}
+
+// MinutesDetail queries minute details with selective artifact flags.
+var MinutesDetail = common.Shortcut{
+ Service: "minutes",
+ Command: "+detail",
+ Description: "Query minute details with selective artifact flags (summary, todo, chapter, transcript, keyword)",
+ Risk: "read",
+ Scopes: []string{"minutes:minutes.basic:read", "minutes:minutes.artifacts:read"},
+ AuthTypes: []string{"user"},
+ HasFormat: true,
+ Flags: []common.Flag{
+ {Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch", Required: true},
+ {Name: "summary", Type: "bool", Desc: "include summary"},
+ {Name: "todo", Type: "bool", Desc: "include todos"},
+ {Name: "chapter", Type: "bool", Desc: "include chapters"},
+ {Name: "transcript", Type: "bool", Desc: "include transcript (saved to file)"},
+ {Name: "keyword", Type: "bool", Desc: "include keywords"},
+ {Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
+ {Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
+ },
+ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ tokens := common.SplitCSV(runtime.Str("minute-tokens"))
+ const maxBatchSize = 50
+ if len(tokens) > maxBatchSize {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize).WithParam("--minute-tokens")
+ }
+ for _, token := range tokens {
+ if !validMinuteTokenDetail.MatchString(token) {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid minute token %q: must contain only lowercase alphanumeric characters", token).WithParam("--minute-tokens")
+ }
+ }
+ if outDir := runtime.Str("output-dir"); outDir != "" {
+ if err := common.ValidateSafePathTyped(runtime.FileIO(), outDir); err != nil {
+ return err
+ }
+ }
+ // dynamic scope check
+ result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
+ if err == nil && result != nil && result.Scopes != "" {
+ if missing := auth.MissingScopes(result.Scopes, scopesDetailMinuteTokens); len(missing) > 0 {
+ return errs.NewPermissionError(errs.SubtypeMissingScope,
+ "missing required scope(s): %s", strings.Join(missing, ", ")).
+ WithHint("run `lark-cli auth login --scope %q` 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, " ")).
+ WithMissingScopes(missing...).
+ WithIdentity(string(runtime.As()))
+ }
+ }
+ return nil
+ },
+ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
+ tokens := runtime.Str("minute-tokens")
+ d := common.NewDryRunAPI().
+ GET("/open-apis/minutes/v1/minutes/{minute_token}").
+ Set("minute_tokens", common.SplitCSV(tokens))
+
+ if runtime.Bool("summary") || runtime.Bool("todo") || runtime.Bool("chapter") || runtime.Bool("transcript") || runtime.Bool("keyword") {
+ d.GET("/open-apis/minutes/v1/minutes/{minute_token}/artifacts")
+ }
+ return d
+ },
+ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ errOut := runtime.IO().ErrOut
+ minuteTokens := common.SplitCSV(runtime.Str("minute-tokens"))
+ results := make([]*minuteDetailItem, 0, len(minuteTokens))
+
+ const batchDelay = 100 * time.Millisecond
+ fmt.Fprintf(errOut, "%s querying %d minute_token(s)\n", minutesDetailLogPrefix, len(minuteTokens))
+ for i, token := range minuteTokens {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if i > 0 {
+ time.Sleep(batchDelay)
+ }
+ fmt.Fprintf(errOut, "%s querying minute_token=%s ...\n", minutesDetailLogPrefix, token)
+ results = append(results, fetchMinuteDetail(ctx, runtime, token))
+ }
+
+ successCount := 0
+ for _, r := range results {
+ if r.Error == "" {
+ successCount++
+ }
+ }
+ fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", minutesDetailLogPrefix, len(results), successCount, len(results)-successCount)
+
+ if successCount == 0 && len(results) > 0 {
+ return runtime.OutPartialFailure(map[string]any{"minutes": results}, &output.Meta{Count: len(results)})
+ }
+
+ outData := map[string]any{"minutes": results}
+ runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
+ if len(results) == 0 {
+ fmt.Fprintln(w, "No minutes.")
+ return
+ }
+ var rows []map[string]interface{}
+ for _, r := range results {
+ row := map[string]interface{}{"minute_token": r.MinuteToken}
+ if r.Error != "" {
+ row["status"] = "FAIL"
+ row["error"] = r.Error
+ } else {
+ row["status"] = "OK"
+ row["title"] = r.Title
+ row["note_id"] = r.NoteID
+ if len(r.Artifacts) > 0 {
+ var parts []string
+ if _, ok := r.Artifacts["summary"]; ok {
+ parts = append(parts, "summary")
+ }
+ if _, ok := r.Artifacts["todos"]; ok {
+ parts = append(parts, "todo")
+ }
+ if _, ok := r.Artifacts["chapters"]; ok {
+ parts = append(parts, "chapter")
+ }
+ if _, ok := r.Artifacts["keywords"]; ok {
+ parts = append(parts, "keyword")
+ }
+ if _, ok := r.Artifacts["transcript_file"]; ok {
+ parts = append(parts, "transcript")
+ }
+ if len(parts) > 0 {
+ row["artifacts"] = strings.Join(parts, ", ")
+ }
+ }
+ }
+ rows = append(rows, row)
+ }
+ output.PrintTable(w, rows)
+ fmt.Fprintf(w, "\n%d minute(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
+ })
+ return nil
+ },
+}
diff --git a/shortcuts/minutes/minutes_detail_test.go b/shortcuts/minutes/minutes_detail_test.go
new file mode 100644
index 00000000..b5c8f46d
--- /dev/null
+++ b/shortcuts/minutes/minutes_detail_test.go
@@ -0,0 +1,394 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package minutes
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/spf13/cobra"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/httpmock"
+ "github.com/larksuite/cli/internal/output"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+// ---------------------------------------------------------------------------
+// helpers
+// ---------------------------------------------------------------------------
+
+var detailWarmOnce sync.Once
+
+func detailWarmTokenCache(t *testing.T) {
+ t.Helper()
+ detailWarmOnce.Do(func() {
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(&httpmock.Stub{
+ URL: "/open-apis/test/v1/warm",
+ Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
+ })
+ s := common.Shortcut{
+ Service: "test",
+ Command: "+warm",
+ AuthTypes: []string{"bot"},
+ Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
+ _, err := rctx.CallAPITyped("GET", "/open-apis/test/v1/warm", nil, nil)
+ return err
+ },
+ }
+ parent := &cobra.Command{Use: "test"}
+ s.Mount(parent, f)
+ parent.SetArgs([]string{"+warm"})
+ parent.SilenceErrors = true
+ parent.SilenceUsage = true
+ parent.Execute()
+ })
+}
+
+func detailMountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
+ t.Helper()
+ detailWarmTokenCache(t)
+ parent := &cobra.Command{Use: "minutes"}
+ s.Mount(parent, f)
+ parent.SetArgs(args)
+ parent.SilenceErrors = true
+ parent.SilenceUsage = true
+ if stdout != nil {
+ stdout.Reset()
+ }
+ return parent.Execute()
+}
+
+// ---------------------------------------------------------------------------
+// Validation tests
+// ---------------------------------------------------------------------------
+
+func detailMinuteGetStub(token, noteID, title string) *httpmock.Stub {
+ minute := map[string]interface{}{"title": title}
+ if noteID != "" {
+ minute["note_id"] = noteID
+ }
+ return &httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/minutes/v1/minutes/" + token,
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{"minute": minute},
+ },
+ }
+}
+
+func detailArtifactsStub(token, transcript string) *httpmock.Stub {
+ data := map[string]interface{}{
+ "summary": "Test summary content",
+ "minute_todos": []interface{}{map[string]interface{}{"content": "Buy milk"}},
+ "minute_chapters": []interface{}{map[string]interface{}{"title": "Intro", "summary_content": "Opening"}},
+ "keywords": []interface{}{"budget", "roadmap"},
+ }
+ if transcript != "" {
+ data["transcript"] = transcript
+ }
+ return &httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/minutes/v1/minutes/" + token + "/artifacts",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": data,
+ },
+ }
+}
+
+func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
+ f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
+ err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
+ if err == nil {
+ t.Fatal("expected validation error for missing --minute-tokens")
+ }
+}
+
+func TestDetail_Validation_InvalidToken(t *testing.T) {
+ f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
+ err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "INVALID!", "--as", "user"}, f, nil)
+ if err == nil {
+ t.Fatal("expected validation error for invalid token")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
+ }
+ if ve.Param != "--minute-tokens" {
+ t.Errorf("Param = %q, want --minute-tokens", ve.Param)
+ }
+}
+
+func TestDetail_Validation_BatchLimit(t *testing.T) {
+ f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
+ tokens := make([]string, 51)
+ for i := range tokens {
+ tokens[i] = fmt.Sprintf("tok%d", i)
+ }
+ err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", strings.Join(tokens, ","), "--as", "user"}, f, nil)
+ if err == nil {
+ t.Fatal("expected batch limit error")
+ }
+ if !strings.Contains(err.Error(), "too many tokens") {
+ t.Errorf("expected 'too many tokens' error, got: %v", err)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// DryRun tests
+// ---------------------------------------------------------------------------
+
+func TestDetail_DryRun(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
+ err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--dry-run", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(stdout.String(), "/open-apis/minutes/v1/minutes/") {
+ t.Errorf("dry-run should show minutes API path, got: %s", stdout.String())
+ }
+}
+
+func TestDetail_DryRun_WithArtifactFlags(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
+ err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--summary", "--todo", "--dry-run", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(stdout.String(), "artifacts") {
+ t.Errorf("dry-run should show artifacts API path when artifact flags are set, got: %s", stdout.String())
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Execute tests with mocked HTTP
+// ---------------------------------------------------------------------------
+
+func TestDetail_Execute_BasicInfo(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(detailMinuteGetStub("tokbasic", "", "Test Meeting"))
+
+ err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokbasic", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var resp map[string]any
+ if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse output: %v", err)
+ }
+ data, _ := resp["data"].(map[string]any)
+ minutes, _ := data["minutes"].([]any)
+ if len(minutes) != 1 {
+ t.Fatalf("expected 1 minute, got %d", len(minutes))
+ }
+ m, _ := minutes[0].(map[string]any)
+ if m["minute_token"] != "tokbasic" {
+ t.Errorf("minute_token = %v, want tokbasic", m["minute_token"])
+ }
+ if m["title"] != "Test Meeting" {
+ t.Errorf("title = %v, want Test Meeting", m["title"])
+ }
+ noteID, hasNoteID := m["note_id"]
+ if !hasNoteID {
+ t.Error("note_id should always be present in output (even when empty)")
+ }
+ if noteID != "" {
+ t.Errorf("note_id should be empty string when minute has no note_id, got %v", noteID)
+ }
+}
+
+func TestDetail_Execute_WithSummaryAndTodo(t *testing.T) {
+ chdirForDetailTest(t)
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(detailMinuteGetStub("tokart", "note_art", "Artifact Meeting"))
+ reg.Register(detailArtifactsStub("tokart", ""))
+
+ err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokart", "--summary", "--todo", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var resp map[string]any
+ if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse output: %v", err)
+ }
+ data, _ := resp["data"].(map[string]any)
+ minutes, _ := data["minutes"].([]any)
+ if len(minutes) != 1 {
+ t.Fatalf("expected 1 minute, got %d", len(minutes))
+ }
+ m, _ := minutes[0].(map[string]any)
+ if m["note_id"] != "note_art" {
+ t.Errorf("note_id = %v, want note_art", m["note_id"])
+ }
+ arts, _ := m["artifacts"].(map[string]any)
+ if arts == nil {
+ t.Fatal("expected artifacts to be present")
+ }
+ if _, ok := arts["summary"]; !ok {
+ t.Error("expected summary in artifacts")
+ }
+ if _, ok := arts["todos"]; !ok {
+ t.Error("expected todos in artifacts")
+ }
+ // chapter and keywords should NOT be present since flags not set
+ if _, ok := arts["chapters"]; ok {
+ t.Error("chapters should not be present when --chapter not set")
+ }
+ if _, ok := arts["keywords"]; ok {
+ t.Error("keywords should not be present when --keyword not set")
+ }
+}
+
+func TestDetail_Execute_NoArtifactFlags(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(detailMinuteGetStub("toknoart", "", "No Artifacts"))
+
+ err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "toknoart", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var resp map[string]any
+ if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse output: %v", err)
+ }
+ data, _ := resp["data"].(map[string]any)
+ minutes, _ := data["minutes"].([]any)
+ if len(minutes) != 1 {
+ t.Fatalf("expected 1 minute, got %d", len(minutes))
+ }
+ m, _ := minutes[0].(map[string]any)
+ if _, ok := m["artifacts"]; ok {
+ t.Error("artifacts should not be present when no artifact flags set")
+ }
+}
+
+func TestDetail_Execute_Transcript(t *testing.T) {
+ chdirForDetailTest(t)
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(detailMinuteGetStub("toktrans", "", "Transcript Meeting"))
+ reg.Register(detailArtifactsStub("toktrans", "speaker1: hello world\n"))
+
+ err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "toktrans", "--transcript", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Check transcript file was saved
+ wantPath := "minutes/toktrans/transcript.txt"
+ data, err := os.ReadFile(wantPath)
+ if err != nil {
+ t.Fatalf("expected file at %s: %v", wantPath, err)
+ }
+ if string(data) != "speaker1: hello world\n" {
+ t.Errorf("content mismatch: %q", string(data))
+ }
+}
+
+func TestDetail_Execute_Transcript_OutputDir(t *testing.T) {
+ chdirForDetailTest(t)
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(detailMinuteGetStub("tokod", "", "Output Dir Meeting"))
+ reg.Register(detailArtifactsStub("tokod", "alice: hi\n"))
+
+ err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokod", "--transcript", "--output-dir", "custom_out", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Mirrors `minutes +detail --output-dir` layout: artifact--/transcript.txt
+ wantPath := "custom_out/artifact-Output Dir Meeting-tokod/transcript.txt"
+ data, err := os.ReadFile(wantPath)
+ if err != nil {
+ t.Fatalf("expected file at %s: %v", wantPath, err)
+ }
+ if string(data) != "alice: hi\n" {
+ t.Errorf("content mismatch: %q", string(data))
+ }
+}
+
+func TestDetail_Validation_OutputDirEscape(t *testing.T) {
+ chdirForDetailTest(t)
+ f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
+ err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--output-dir", "../escape", "--as", "user"}, f, nil)
+ if err == nil {
+ t.Fatal("expected validation error for escaping output-dir")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
+ }
+}
+
+func TestDetail_Execute_MinuteNotFound(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/minutes/v1/minutes/tokbad",
+ Body: map[string]interface{}{"code": 2091004, "msg": "not found"},
+ })
+
+ err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokbad", "--as", "user"}, f, stdout)
+ if err == nil {
+ t.Fatal("expected partial failure error")
+ }
+ var pfErr *output.PartialFailureError
+ if !errors.As(err, &pfErr) {
+ t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Pure function tests
+// ---------------------------------------------------------------------------
+
+func TestValidMinuteTokenDetail(t *testing.T) {
+ tests := []struct {
+ token string
+ valid bool
+ }{
+ {"abc123", true},
+ {"obcnmgn1429t5xt9j82i1p3h", true},
+ {"INVALID!", false},
+ {"has-space", false},
+ {"", false},
+ }
+ for _, tt := range tests {
+ got := validMinuteTokenDetail.MatchString(tt.token)
+ if got != tt.valid {
+ t.Errorf("validMinuteTokenDetail(%q) = %v, want %v", tt.token, got, tt.valid)
+ }
+ }
+}
+
+// chdirForDetailTest switches cwd to a temp dir for the test.
+func chdirForDetailTest(t *testing.T) string {
+ t.Helper()
+ orig, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+ dir := t.TempDir()
+ if err := os.Chdir(dir); err != nil {
+ t.Fatalf("chdir: %v", err)
+ }
+ t.Cleanup(func() { os.Chdir(orig) })
+ return dir
+}
diff --git a/shortcuts/minutes/minutes_search.go b/shortcuts/minutes/minutes_search.go
index bdaa8c5e..46843e4c 100644
--- a/shortcuts/minutes/minutes_search.go
+++ b/shortcuts/minutes/minutes_search.go
@@ -184,12 +184,6 @@ func minuteSearchAppLink(item map[string]interface{}) string {
return common.GetString(meta, "app_link")
}
-// minuteSearchAvatar extracts the avatar URL from a search result item.
-func minuteSearchAvatar(item map[string]interface{}) string {
- meta := common.GetMap(item, "meta_data")
- return common.GetString(meta, "avatar")
-}
-
// buildMinuteSearchRows converts API items into pretty output rows.
func buildMinuteSearchRows(items []interface{}) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(items))
@@ -203,12 +197,27 @@ func buildMinuteSearchRows(items []interface{}) []map[string]interface{} {
"display_info": common.TruncateStr(minuteSearchDisplayInfo(item), 40),
"description": common.TruncateStr(minuteSearchDescription(item), 40),
"app_link": common.TruncateStr(minuteSearchAppLink(item), 80),
- "avatar": common.TruncateStr(minuteSearchAvatar(item), 80),
})
}
return rows
}
+// stripAvatarFromItems removes meta_data.avatar from each search item in place
+// so the structured output does not surface avatars to AI agents.
+func stripAvatarFromItems(items []interface{}) {
+ for _, raw := range items {
+ item, _ := raw.(map[string]interface{})
+ if item == nil {
+ continue
+ }
+ meta, _ := item["meta_data"].(map[string]interface{})
+ if meta == nil {
+ continue
+ }
+ delete(meta, "avatar")
+ }
+}
+
// MinutesSearch searches minutes by keyword, owners, participants, and time range.
var MinutesSearch = common.Shortcut{
Service: "minutes",
@@ -298,13 +307,13 @@ var MinutesSearch = common.Shortcut{
}
items := minuteSearchItems(data)
+ stripAvatarFromItems(items)
hasMore, _ := data["has_more"].(bool)
pageToken, _ := data["page_token"].(string)
rows := buildMinuteSearchRows(items)
outData := map[string]interface{}{
"items": items,
- "total": data["total"],
"has_more": data["has_more"],
"page_token": data["page_token"],
}
diff --git a/shortcuts/minutes/minutes_search_test.go b/shortcuts/minutes/minutes_search_test.go
index 47ae8772..63b33750 100644
--- a/shortcuts/minutes/minutes_search_test.go
+++ b/shortcuts/minutes/minutes_search_test.go
@@ -526,7 +526,7 @@ func TestMinutesSearchExecuteRendersRowsAndMoreHint(t *testing.T) {
}
out := stdout.String()
- for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "https://p3-lark-file.byteimg.com/img/xxxx.jpg", "next_token", "more available"} {
+ for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "next_token", "more available"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q, got: %s", want, out)
}
@@ -672,7 +672,6 @@ func TestMinuteSearchFieldExtractors(t *testing.T) {
"meta_data": map[string]interface{}{
"description": "周会纪要",
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
- "avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
},
}
@@ -688,9 +687,6 @@ func TestMinuteSearchFieldExtractors(t *testing.T) {
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/obcn123" {
t.Fatalf("minuteSearchAppLink() = %q", got)
}
- if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/xxxx.jpg" {
- t.Fatalf("minuteSearchAvatar() = %q", got)
- }
}
// TestMinuteSearchFieldExtractorsFallbacks verifies extractors keep working for alternate sample data.
@@ -703,7 +699,6 @@ func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) {
"meta_data": map[string]interface{}{
"description": "回退纪要",
"app_link": "https://meetings.feishu.cn/minutes/fallback",
- "avatar": "https://p3-lark-file.byteimg.com/img/fallback.jpg",
},
}
@@ -716,9 +711,6 @@ func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) {
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/fallback" {
t.Fatalf("minuteSearchAppLink() = %q", got)
}
- if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/fallback.jpg" {
- t.Fatalf("minuteSearchAvatar() = %q", got)
- }
}
// TestMinuteSearchFieldExtractorsMissingMetaData verifies extractors fall back to empty values without metadata.
@@ -739,7 +731,32 @@ func TestMinuteSearchFieldExtractorsMissingMetaData(t *testing.T) {
if got := minuteSearchAppLink(item); got != "" {
t.Fatalf("minuteSearchAppLink() = %q, want empty", got)
}
- if got := minuteSearchAvatar(item); got != "" {
- t.Fatalf("minuteSearchAvatar() = %q, want empty", got)
+}
+
+// TestStripAvatarFromItems verifies the avatar field is removed from items in place.
+func TestStripAvatarFromItems(t *testing.T) {
+ t.Parallel()
+
+ items := []interface{}{
+ map[string]interface{}{
+ "token": "minute_1",
+ "meta_data": map[string]interface{}{
+ "description": "周会纪要",
+ "avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
+ },
+ },
+ nil,
+ map[string]interface{}{"token": "minute_no_meta"},
+ }
+
+ stripAvatarFromItems(items)
+
+ first, _ := items[0].(map[string]interface{})
+ meta, _ := first["meta_data"].(map[string]interface{})
+ if _, ok := meta["avatar"]; ok {
+ t.Fatalf("avatar should be stripped, got meta = %v", meta)
+ }
+ if meta["description"] != "周会纪要" {
+ t.Fatalf("description should be preserved, got %v", meta["description"])
}
}
diff --git a/shortcuts/minutes/minutes_speaker_replace.go b/shortcuts/minutes/minutes_speaker_replace.go
index f0fa649f..716230d3 100644
--- a/shortcuts/minutes/minutes_speaker_replace.go
+++ b/shortcuts/minutes/minutes_speaker_replace.go
@@ -25,12 +25,13 @@ var MinutesSpeakerReplace = common.Shortcut{
Command: "+speaker-replace",
Description: "Replace a speaker in a minute's transcript (rebind from one user to another)",
Risk: "write",
- Scopes: []string{"minutes:minutes:update"},
+ Scopes: []string{"minutes:minutes:readonly", "minutes:minutes:update"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "minute-token", Desc: "minute token", Required: true},
- {Name: "from-user-id", Desc: "speaker to replace, must be an open_id starting with 'ou_'", Required: true},
+ {Name: "from-speaker-id", Desc: "speaker to replace: opaque speaker_id from transcript speakerlist API (do not pass display names)"},
+ {Name: "from-user-id", Desc: "deprecated: open_id of the speaker to replace; prefer --from-speaker-id", Hidden: true},
{Name: "to-user-id", Desc: "new speaker, must be an open_id starting with 'ou_'", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -41,12 +42,10 @@ var MinutesSpeakerReplace = common.Shortcut{
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token")
}
+ fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
- if fromUserID == "" {
- return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id is required").WithParam("--from-user-id")
- }
- if _, err := common.ValidateUserIDTyped("--from-user-id", fromUserID); err != nil {
- return err
+ if fromSpeakerID == "" && fromUserID == "" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-speaker-id is required").WithParam("--from-speaker-id")
}
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
if toUserID == "" {
@@ -55,53 +54,93 @@ var MinutesSpeakerReplace = common.Shortcut{
if _, err := common.ValidateUserIDTyped("--to-user-id", toUserID); err != nil {
return err
}
- if fromUserID == toUserID {
- return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id and --to-user-id must be different").WithParam("--to-user-id")
+ if fromSpeakerID == "" {
+ if _, err := common.ValidateUserIDTyped("--from-user-id", fromUserID); err != nil {
+ return err
+ }
+ if fromUserID == toUserID {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id and --to-user-id must be different").WithParam("--to-user-id")
+ }
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
- fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
- toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
- return common.NewDryRunAPI().
- PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
- Body(map[string]interface{}{
- "minute_token": minuteToken,
- "from_user_id": fromUserID,
- "to_user_id": toUserID,
- })
+ dr := common.NewDryRunAPI()
+ if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" {
+ dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
+ }
+ return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
+ Body(buildSpeakerReplaceRequestBody(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
- fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
+ fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id"))
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
- body := map[string]interface{}{
- "minute_token": minuteToken,
- "from_user_id": fromUserID,
- "to_user_id": toUserID,
- }
-
- _, err := runtime.CallAPITyped(http.MethodPut,
- fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
- nil, body)
+ fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken)
if err != nil {
- return minutesSpeakerReplaceError(err, minuteToken, fromUserID)
+ return err
}
- outData := map[string]interface{}{
- "minute_token": minuteToken,
- "from_user_id": fromUserID,
- "to_user_id": toUserID,
+ _, err = runtime.CallAPITyped(http.MethodPut,
+ fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
+ map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
+ if err != nil {
+ return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID))
}
- runtime.OutFormat(outData, nil, nil)
+ runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
return nil
},
}
-func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error {
+func buildSpeakerReplaceRequestBody(runtime *common.RuntimeContext) map[string]interface{} {
+ fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
+ fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
+ toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
+ return buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID)
+}
+
+func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
+ body := map[string]interface{}{
+ "to_user_id": toUserID,
+ }
+ if fromSpeakerID != "" {
+ body["from_speaker_id"] = fromSpeakerID
+ } else {
+ body["from_user_id"] = fromUserID
+ }
+ return body
+}
+
+func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
+ out := map[string]interface{}{
+ "minute_token": minuteToken,
+ "to_user_id": toUserID,
+ }
+ if fromSpeakerID != "" {
+ out["from_speaker_id"] = fromSpeakerID
+ if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
+ out["from_speaker_input"] = fromSpeakerInput
+ }
+ } else {
+ out["from_user_id"] = fromUserID
+ }
+ return out
+}
+
+func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string {
+ if fromSpeakerInput != "" {
+ return fromSpeakerInput
+ }
+ if fromSpeakerID != "" {
+ return fromSpeakerID
+ }
+ return fromUserID
+}
+
+func minutesSpeakerReplaceError(err error, minuteToken, sourceSpeaker string) error {
p, ok := errs.ProblemOf(err)
if !ok {
return err
@@ -112,8 +151,8 @@ func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error
p.Hint = "Ask the minute owner for minute edit permission"
case minutesSpeakerReplaceSpeakerNotFoundCode:
p.Subtype = errs.SubtypeNotFound
- p.Message = fmt.Sprintf("Speaker not found in minute %q: --from-user-id %q does not match an existing speaker in the transcript.", minuteToken, fromUserID)
- p.Hint = "Check --minute-token and --from-user-id. Use an open_id for a speaker that appears in the minute transcript, then retry."
+ p.Message = fmt.Sprintf("Speaker not found in minute %q: source speaker %q does not match an existing speaker in the transcript.", minuteToken, sourceSpeaker)
+ p.Hint = "Verify --from-speaker-id is a valid speaker_id or display name from the transcript; if multiple speakers share the same name, pass the exact speaker_id after reviewing their utterances."
}
return err
}
diff --git a/shortcuts/minutes/minutes_speaker_replace_test.go b/shortcuts/minutes/minutes_speaker_replace_test.go
index 78944b2d..5b028a24 100644
--- a/shortcuts/minutes/minutes_speaker_replace_test.go
+++ b/shortcuts/minutes/minutes_speaker_replace_test.go
@@ -34,7 +34,7 @@ func TestMinutesSpeakerReplace_Validate(t *testing.T) {
{
name: "missing from",
args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--to-user-id", "ou_b", "--as", "user"},
- wantErr: "required flag(s) \"from-user-id\" not set",
+ wantErr: "--from-speaker-id is required",
},
{
name: "missing to",
@@ -153,6 +153,129 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
}
}
+func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
+ warmTokenCache(t)
+
+ err := mountAndRun(t, MinutesSpeakerReplace, []string{
+ "+speaker-replace",
+ "--minute-token", minutesSpeakerReplaceTestToken,
+ "--from-speaker-id", "说话人1",
+ "--to-user-id", "ou_new_speaker",
+ "--dry-run", "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ out := stdout.String()
+ if !strings.Contains(out, "GET") {
+ t.Errorf("expected GET for internal speaker list, got:\n%s", out)
+ }
+ if !strings.Contains(out, "/transcript/speakerlist") {
+ t.Errorf("expected speakerlist path, got:\n%s", out)
+ }
+ if !strings.Contains(out, "PUT") {
+ t.Errorf("expected PUT for speaker replace, got:\n%s", out)
+ }
+ if !strings.Contains(out, "ou_new_speaker") {
+ t.Errorf("expected to_user_id in body, got:\n%s", out)
+ }
+}
+
+func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ warmTokenCache(t)
+
+ reg.Register(&httpmock.Stub{
+ Method: http.MethodGet,
+ URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "speakers": []interface{}{
+ map[string]interface{}{
+ "speaker_id": "ENCRYPTED_TOKEN_ABC",
+ "name": "说话人1",
+ },
+ },
+ },
+ },
+ })
+ reg.Register(&httpmock.Stub{
+ Method: http.MethodPut,
+ URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{},
+ },
+ })
+
+ err := mountAndRun(t, MinutesSpeakerReplace, []string{
+ "+speaker-replace",
+ "--minute-token", minutesSpeakerReplaceTestToken,
+ "--from-speaker-id", "说话人1",
+ "--to-user-id", "ou_new_speaker",
+ "--format", "json", "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var envelope struct {
+ Data struct {
+ MinuteToken string `json:"minute_token"`
+ FromSpeakerInput string `json:"from_speaker_input"`
+ FromSpeakerID string `json:"from_speaker_id"`
+ ToUserID string `json:"to_user_id"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("unmarshal stdout: %v", err)
+ }
+ if envelope.Data.FromSpeakerInput != "说话人1" {
+ t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
+ }
+ if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
+ t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
+ }
+}
+
+func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
+ warmTokenCache(t)
+
+ err := mountAndRun(t, MinutesSpeakerReplace, []string{
+ "+speaker-replace",
+ "--minute-token", minutesSpeakerReplaceTestToken,
+ "--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
+ "--to-user-id", "ou_new_speaker",
+ "--dry-run", "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ out := stdout.String()
+ if !strings.Contains(out, "GET") {
+ t.Errorf("expected GET for internal speaker list, got:\n%s", out)
+ }
+ if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
+ t.Errorf("expected from_speaker_id in body, got:\n%s", out)
+ }
+ if strings.Contains(out, "from_user_id") {
+ t.Errorf("from_speaker_id path should not send from_user_id, got:\n%s", out)
+ }
+ if !strings.Contains(out, "ou_new_speaker") {
+ t.Errorf("expected to_user_id in body, got:\n%s", out)
+ }
+}
+
func TestMinutesSpeakerReplace_Execute(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
@@ -238,8 +361,8 @@ func TestMinutesSpeakerReplace_SpeakerNotFound(t *testing.T) {
if !strings.Contains(p.Message, "ou_missing_speaker") {
t.Errorf("message should include missing speaker id, got: %s", p.Message)
}
- if !strings.Contains(p.Hint, "--from-user-id") {
- t.Errorf("hint should mention --from-user-id, got: %s", p.Hint)
+ if !strings.Contains(p.Hint, "--from-speaker-id") {
+ t.Errorf("hint should mention --from-speaker-id, got: %s", p.Hint)
}
}
diff --git a/shortcuts/minutes/minutes_speakers.go b/shortcuts/minutes/minutes_speakers.go
new file mode 100644
index 00000000..b9649f6d
--- /dev/null
+++ b/shortcuts/minutes/minutes_speakers.go
@@ -0,0 +1,104 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package minutes
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/validate"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+type minuteSpeaker struct {
+ SpeakerID string
+ Name string
+}
+
+func minuteTranscriptSpeakerlistPath(minuteToken string) string {
+ return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
+}
+
+func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
+ data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ if data == nil {
+ return nil, nil
+ }
+
+ items := common.GetSlice(data, "speakers")
+ speakers := make([]minuteSpeaker, 0, len(items))
+ for _, raw := range items {
+ item, _ := raw.(map[string]interface{})
+ if item == nil {
+ continue
+ }
+ id := strings.TrimSpace(common.GetString(item, "speaker_id"))
+ name := strings.TrimSpace(common.GetString(item, "name"))
+ if id == "" {
+ continue
+ }
+ speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
+ }
+ return speakers, nil
+}
+
+func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
+ name = strings.TrimSpace(name)
+ var matches []minuteSpeaker
+ for _, s := range speakers {
+ if s.Name == name {
+ matches = append(matches, s)
+ }
+ }
+ switch len(matches) {
+ case 0:
+ return "", errs.NewValidationError(errs.SubtypeNotFound,
+ "no speaker named %q in minute transcript", name).
+ WithParam("--from-speaker-id").
+ WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
+ case 1:
+ return matches[0].SpeakerID, nil
+ default:
+ ids := make([]string, len(matches))
+ for i, m := range matches {
+ ids[i] = m.SpeakerID
+ }
+ return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
+ "multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
+ WithParam("--from-speaker-id").
+ WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
+ }
+}
+
+// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
+// The input may already be an opaque speaker_id, or a display name that requires
+// an internal speaker-list fetch.
+func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
+ input = strings.TrimSpace(input)
+ speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
+ if err != nil {
+ return "", err
+ }
+ for _, s := range speakers {
+ if s.SpeakerID == input {
+ return input, nil
+ }
+ }
+ return resolveSpeakerIDByName(speakers, input)
+}
+
+func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
+ fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
+ if fromUserID != "" {
+ return "", fromUserID, nil
+ }
+
+ fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
+ return fromSpeakerID, "", err
+}
diff --git a/shortcuts/minutes/minutes_speakers_test.go b/shortcuts/minutes/minutes_speakers_test.go
new file mode 100644
index 00000000..015a362c
--- /dev/null
+++ b/shortcuts/minutes/minutes_speakers_test.go
@@ -0,0 +1,45 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package minutes
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/larksuite/cli/errs"
+)
+
+func TestResolveSpeakerIDByName(t *testing.T) {
+ speakers := []minuteSpeaker{
+ {SpeakerID: "id_a", Name: "Alice"},
+ {SpeakerID: "id_b", Name: "Bob"},
+ {SpeakerID: "id_c", Name: "Alice"},
+ }
+
+ id, err := resolveSpeakerIDByName(speakers, "Bob")
+ if err != nil || id != "id_b" {
+ t.Fatalf("resolve Bob: id=%q err=%v", id, err)
+ }
+
+ _, err = resolveSpeakerIDByName(speakers, "Carol")
+ if err == nil {
+ t.Fatal("expected not found error")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
+ t.Fatalf("want not-found validation error, got %T: %v", err, err)
+ }
+
+ _, err = resolveSpeakerIDByName(speakers, "Alice")
+ if err == nil {
+ t.Fatal("expected duplicate name error")
+ }
+ if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
+ t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
+ }
+ if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
+ t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
+ }
+}
diff --git a/shortcuts/minutes/minutes_summary.go b/shortcuts/minutes/minutes_summary.go
index f8277eb2..a96f024d 100644
--- a/shortcuts/minutes/minutes_summary.go
+++ b/shortcuts/minutes/minutes_summary.go
@@ -31,7 +31,7 @@ var MinutesSummary = common.Shortcut{
},
Tips: []string{
minutesSummaryMarkdownTip,
- "Use `lark-cli vc +notes --minute-tokens ` to read the current summary before replacing it.",
+ "Use `lark-cli minutes +detail --minute-tokens --summary` to read the current summary before replacing it.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := runtime.Str("minute-token")
diff --git a/shortcuts/minutes/minutes_todo.go b/shortcuts/minutes/minutes_todo.go
index 05888c33..70730b43 100644
--- a/shortcuts/minutes/minutes_todo.go
+++ b/shortcuts/minutes/minutes_todo.go
@@ -59,7 +59,7 @@ var MinutesTodo = common.Shortcut{
"Update: `--operation update --todo-id --todo \"...\" --is-done`.",
"Delete: `--operation delete --todo-id `.",
"`content` is plain text only; markdown formatting is not supported.",
- "Use `lark-cli vc +notes --minute-tokens ` to read current todos before writing.",
+ "Use `lark-cli minutes +detail --minute-tokens --todo` to read current todos before writing.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := runtime.Str("minute-token")
diff --git a/shortcuts/minutes/minutes_word_replace.go b/shortcuts/minutes/minutes_word_replace.go
index fc079872..2cae38eb 100644
--- a/shortcuts/minutes/minutes_word_replace.go
+++ b/shortcuts/minutes/minutes_word_replace.go
@@ -148,7 +148,7 @@ func minutesWordReplaceError(err error, minuteToken string) error {
if strings.Contains(strings.ToLower(p.Message), "not found in transcript") {
p.Subtype = errs.SubtypeNotFound
p.Message = fmt.Sprintf("None of the source words were found in minute %q transcript; nothing was replaced.", minuteToken)
- p.Hint = "Verify each source_word's exact spelling and case against the current transcript (use vc +notes to read it), then retry"
+ p.Hint = "Verify each source_word's exact spelling and case against the current transcript (use `minutes +detail --minute-tokens --transcript` to read it), then retry"
}
}
diff --git a/shortcuts/minutes/shortcuts.go b/shortcuts/minutes/shortcuts.go
index 283eca7f..c70c37c7 100644
--- a/shortcuts/minutes/shortcuts.go
+++ b/shortcuts/minutes/shortcuts.go
@@ -16,5 +16,6 @@ func Shortcuts() []common.Shortcut {
MinutesTodo,
MinutesSpeakerReplace,
MinutesWordReplace,
+ MinutesDetail,
}
}
diff --git a/shortcuts/note/note_transcript.go b/shortcuts/note/note_transcript.go
index 6810afe6..fe84f057 100644
--- a/shortcuts/note/note_transcript.go
+++ b/shortcuts/note/note_transcript.go
@@ -153,7 +153,7 @@ func ensureUnifiedNote(ctx context.Context, runtime *common.RuntimeContext, note
if detail.DisplayType != "unified" {
if detail.VerbatimDocToken != "" {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=%s)", noteID, detail.DisplayType, detail.VerbatimDocToken).
- WithHint("Use docs +fetch --api-version v2 --doc %s for normal note transcripts", detail.VerbatimDocToken)
+ WithHint("Use docs +fetch --doc %s for normal note transcripts", detail.VerbatimDocToken)
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=)", noteID, detail.DisplayType).
WithHint("Use note +detail to inspect document tokens")
diff --git a/shortcuts/note/note_transcript_test.go b/shortcuts/note/note_transcript_test.go
index d6979414..f9914ec7 100644
--- a/shortcuts/note/note_transcript_test.go
+++ b/shortcuts/note/note_transcript_test.go
@@ -39,7 +39,7 @@ func TestNoteTranscriptRequiresUnifiedNote(t *testing.T) {
if problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype = %v, want FailedPrecondition", problem.Subtype)
}
- if !strings.Contains(problem.Hint, "docs +fetch --api-version v2 --doc doc_verbatim") {
+ if !strings.Contains(problem.Hint, "docs +fetch --doc doc_verbatim") {
t.Fatalf("hint = %q, want docs +fetch guidance", problem.Hint)
}
if stdout.Len() != 0 {
diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go
index 04d2e40f..5137584b 100644
--- a/shortcuts/register_test.go
+++ b/shortcuts/register_test.go
@@ -246,8 +246,9 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
shortcutHelp: "Create a Lark document",
visibleFlag: "--content",
skillCommand: "lark-cli skills read lark-doc references/lark-doc-create.md",
- hiddenFlags: []string{"title", "markdown", "folder-token", "wiki-node", "wiki-space"},
+ hiddenFlags: []string{"api-version", "markdown", "folder-token", "wiki-node", "wiki-space"},
contentHelp: []string{
+ "--title",
"AI agents MUST read",
"lark-cli skills read lark-doc references/lark-doc-xml.md",
"before writing any --content payload",
@@ -257,7 +258,7 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
"MUST NOT grep/open local SKILL.md files",
"use --help for the latest command flags",
},
- unwanted: []string{"--markdown", "--title", "--folder-token", "--wiki-node", "--wiki-space"},
+ unwanted: []string{"--api-version", "--markdown", "--folder-token", "--wiki-node", "--wiki-space"},
},
{
name: "fetch",
@@ -265,8 +266,8 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
shortcutHelp: "Fetch Lark document content",
visibleFlag: "read scope",
skillCommand: "lark-cli skills read lark-doc references/lark-doc-fetch.md",
- hiddenFlags: []string{"offset", "limit"},
- unwanted: []string{"--offset", "--limit"},
+ hiddenFlags: []string{"api-version", "offset", "limit"},
+ unwanted: []string{"--api-version", "--offset", "--limit"},
},
{
name: "update",
@@ -274,7 +275,7 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
shortcutHelp: "Update a Lark document",
visibleFlag: "--command",
skillCommand: "lark-cli skills read lark-doc references/lark-doc-update.md",
- hiddenFlags: []string{"mode", "markdown", "selection-with-ellipsis", "selection-by-title", "new-title"},
+ hiddenFlags: []string{"api-version", "mode", "markdown", "selection-with-ellipsis", "selection-by-title", "new-title"},
contentHelp: []string{
"AI agents MUST read",
"lark-cli skills read lark-doc references/lark-doc-xml.md",
@@ -285,7 +286,7 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
"MUST NOT grep/open local SKILL.md files",
"use --help for the latest command flags",
},
- unwanted: []string{"--mode", "--markdown", "--selection-with-ellipsis", "--selection-by-title", "--new-title"},
+ unwanted: []string{"--api-version", "--mode", "--markdown", "--selection-with-ellipsis", "--selection-by-title", "--new-title"},
},
}
@@ -311,17 +312,6 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
t.Fatalf("docs %s flag %q should be hidden", tt.shortcut, flagName)
}
}
- apiVersionFlag := cmd.Flags().Lookup("api-version")
- if apiVersionFlag == nil {
- t.Fatalf("docs %s missing --api-version flag", tt.shortcut)
- }
- if apiVersionFlag.Hidden {
- t.Fatalf("docs %s --api-version should be visible", tt.shortcut)
- }
- if apiVersionFlag.DefValue != "v2" {
- t.Fatalf("docs %s --api-version default = %q, want v2", tt.shortcut, apiVersionFlag.DefValue)
- }
-
var out bytes.Buffer
cmd.SetOut(&out)
if err := cmd.Help(); err != nil {
@@ -331,10 +321,6 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
for _, want := range []string{
tt.shortcutHelp,
tt.visibleFlag,
- "--api-version",
- "deprecated compatibility flag; docs shortcuts always use v2",
- "both v1/v2 are accepted",
- "(default \"v2\")",
"Start here (required for AI agents):",
"AI agents MUST read the matching embedded skill",
"Do not skip this step",
diff --git a/shortcuts/sheets/backward/lark_sheets_float_images.go b/shortcuts/sheets/backward/lark_sheets_float_images.go
index b1c62331..a117bbc2 100644
--- a/shortcuts/sheets/backward/lark_sheets_float_images.go
+++ b/shortcuts/sheets/backward/lark_sheets_float_images.go
@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"path/filepath"
+ "strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
@@ -14,7 +15,25 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
-const sheetImageParentType = "sheet_image"
+// Drive media parent_type values for uploading an image into a spreadsheet.
+// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
+// synthetic token prefixed with "fake_office_" and the backend requires
+// "office_sheet_file" instead.
+const (
+ sheetImageParentType = "sheet_image"
+ officeSheetFileParentType = "office_sheet_file"
+ fakeOfficeTokenPrefix = "fake_office_"
+)
+
+// sheetMediaParentType returns the drive media parent_type to use when
+// uploading an image whose parent_node is spreadsheetToken, mapping the
+// "fake_office_" imported-spreadsheet token prefix to "office_sheet_file".
+func sheetMediaParentType(spreadsheetToken string) string {
+ if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
+ return officeSheetFileParentType
+ }
+ return sheetImageParentType
+}
var SheetMediaUpload = common.Shortcut{
Service: "sheets",
@@ -49,7 +68,7 @@ var SheetMediaUpload = common.Shortcut{
POST("/open-apis/drive/v1/medias/upload_prepare").
Body(map[string]interface{}{
"file_name": fileName,
- "parent_type": sheetImageParentType,
+ "parent_type": sheetMediaParentType(parentNode),
"parent_node": parentNode,
"size": "",
}).
@@ -71,7 +90,7 @@ var SheetMediaUpload = common.Shortcut{
POST("/open-apis/drive/v1/medias/upload_all").
Body(map[string]interface{}{
"file_name": fileName,
- "parent_type": sheetImageParentType,
+ "parent_type": sheetMediaParentType(parentNode),
"parent_node": parentNode,
"size": "",
"file": "@" + filePath,
@@ -141,13 +160,14 @@ func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, erro
}
func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) {
+ parentType := sheetMediaParentType(parentNode)
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
pn := parentNode
return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
- ParentType: sheetImageParentType,
+ ParentType: parentType,
ParentNode: &pn,
})
}
@@ -155,7 +175,7 @@ func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName str
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
- ParentType: sheetImageParentType,
+ ParentType: parentType,
ParentNode: parentNode,
})
}
diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go
index 03b10416..40b31d31 100644
--- a/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go
+++ b/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go
@@ -91,6 +91,39 @@ func TestSheetMediaUploadDryRunSmallFile(t *testing.T) {
}
}
+// TestSheetMediaUploadDryRunSmallFileOfficeParentType pins the small-file
+// upload_all dry-run preview to the token-derived parent_type so the preview
+// agents/users will copy matches what Execute actually sends. Without this the
+// multipart dry-run branch could drift back to a hard-coded "sheet_image".
+func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
+ dir := t.TempDir()
+ withSheetsTestWorkingDir(t, dir)
+ if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
+ err := mountAndRunSheets(t, SheetMediaUpload, []string{
+ "+media-upload",
+ "--spreadsheet-token", "fake_office_abc123",
+ "--file", "img.png",
+ "--dry-run", "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ out := stdout.String()
+ if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") {
+ t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
+ }
+ if !strings.Contains(out, `"office_sheet_file"`) {
+ t.Fatalf("dry-run should include parent_type=office_sheet_file for fake_office_ token, got: %s", out)
+ }
+ if strings.Contains(out, `"sheet_image"`) {
+ t.Fatalf("dry-run must not emit sheet_image for fake_office_ token, got: %s", out)
+ }
+}
+
func TestSheetMediaUploadDryRunURLExtractsToken(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
@@ -205,6 +238,47 @@ func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
}
}
+// TestSheetMediaUploadExecuteOfficeParentType confirms that an imported
+// "office" spreadsheet (token prefixed with "fake_office_") uploads with
+// parent_type=office_sheet_file instead of the native sheet_image.
+func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
+ dir := t.TempDir()
+ withSheetsTestWorkingDir(t, dir)
+ if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/medias/upload_all",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{"file_token": "boxTOK123"},
+ },
+ }
+ reg.Register(stub)
+
+ const officeToken = "fake_office_abc123"
+ err := mountAndRunSheets(t, SheetMediaUpload, []string{
+ "+media-upload",
+ "--spreadsheet-token", officeToken,
+ "--file", "img.png",
+ "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ body := decodeSheetsMultipartBody(t, stub)
+ if got := body.Fields["parent_type"]; got != officeSheetFileParentType {
+ t.Fatalf("parent_type = %q, want %q", got, officeSheetFileParentType)
+ }
+ if got := body.Fields["parent_node"]; got != officeToken {
+ t.Fatalf("parent_node = %q, want %q", got, officeToken)
+ }
+}
+
func TestSheetMediaUploadFileNotFound(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go
index ad364312..5c2545c6 100644
--- a/shortcuts/sheets/helpers.go
+++ b/shortcuts/sheets/helpers.go
@@ -50,6 +50,42 @@ func sheetsInputStatError(flag string, err error) error {
return wrapped
}
+// Drive media parent_type values for uploading an image into a spreadsheet.
+// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
+// synthetic token prefixed with "fake_office_" and the backend requires
+// "office_sheet_file" instead.
+const (
+ sheetImageParentType = "sheet_image"
+ officeSheetFileParentType = "office_sheet_file"
+ fakeOfficeTokenPrefix = "fake_office_"
+)
+
+// sheetMediaParentType returns the drive media parent_type to use when
+// uploading an image whose parent_node is spreadsheetToken. It is the single
+// place that maps a spreadsheet token to its parent_type so every image-upload
+// entry point (and its dry-run preview) stays consistent.
+func sheetMediaParentType(spreadsheetToken string) string {
+ if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
+ return officeSheetFileParentType
+ }
+ return sheetImageParentType
+}
+
+// uploadSheetImage uploads a local image file as a spreadsheet media asset and
+// returns its file_token. It funnels every sheets image upload through one
+// place so the parent_type selection (see sheetMediaParentType) is never
+// duplicated or forgotten at a call site. Callers are expected to have already
+// resolved spreadsheetToken (the upload's parent_node) and stat'd the file.
+func uploadSheetImage(runtime *common.RuntimeContext, spreadsheetToken, filePath, fileName string, fileSize int64) (string, error) {
+ return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
+ FilePath: filePath,
+ FileName: fileName,
+ FileSize: fileSize,
+ ParentType: sheetMediaParentType(spreadsheetToken),
+ ParentNode: &spreadsheetToken,
+ })
+}
+
// spreadsheetRef classification: a --url / --spreadsheet-token input names a
// spreadsheet either directly (a /sheets/ URL or raw token) or indirectly via a
// wiki node that must be resolved to its backing spreadsheet at Execute time.
diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go
index 580e9daa..83f3b550 100644
--- a/shortcuts/sheets/lark_sheet_object_crud.go
+++ b/shortcuts/sheets/lark_sheet_object_crud.go
@@ -861,10 +861,10 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH
manageBody, _ := buildToolBody("manage_float_image_object", input)
return common.NewDryRunAPI().
POST("/open-apis/drive/v1/medias/upload_all").
- Desc("upload local image to drive (parent_type=sheet_image)").
+ Desc("upload local image to drive (parent_type=" + sheetMediaParentType(token) + ")").
Body(map[string]interface{}{
"file_name": floatImageName(runtime),
- "parent_type": "sheet_image",
+ "parent_type": sheetMediaParentType(token),
"parent_node": token,
"size": "",
"file": "@" + img,
@@ -918,13 +918,7 @@ func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken st
if err != nil {
return "", sheetsInputStatError("image", err)
}
- return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
- FilePath: img,
- FileName: floatImageName(runtime),
- FileSize: info.Size(),
- ParentType: "sheet_image",
- ParentNode: &spreadsheetToken,
- })
+ return uploadSheetImage(runtime, spreadsheetToken, img, floatImageName(runtime), info.Size())
}
func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string, withIDFlag bool, uploadedImageToken string) (map[string]interface{}, error) {
diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go
index 654c0634..57974bf6 100644
--- a/shortcuts/sheets/lark_sheet_write_cells.go
+++ b/shortcuts/sheets/lark_sheet_write_cells.go
@@ -791,10 +791,10 @@ var CellsSetImage = common.Shortcut{
})
return common.NewDryRunAPI().
POST("/open-apis/drive/v1/medias/upload_all").
- Desc("upload local image to drive (parent_type=sheet_image)").
+ Desc("upload local image to drive (parent_type=" + sheetMediaParentType(token) + ")").
Body(map[string]interface{}{
"file_name": fileName,
- "parent_type": "sheet_image",
+ "parent_type": sheetMediaParentType(token),
"parent_node": token,
"size": "",
"file": "@" + imgPath,
@@ -832,13 +832,7 @@ var CellsSetImage = common.Shortcut{
WithParam("--image").
WithCause(err)
}
- fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
- FilePath: imgPath,
- FileName: fileName,
- FileSize: info.Size(),
- ParentType: "sheet_image",
- ParentNode: &token,
- })
+ fileToken, err := uploadSheetImage(runtime, token, imgPath, fileName, info.Size())
if err != nil {
return err
}
diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go
index b450bb62..c36f59fd 100644
--- a/shortcuts/sheets/lark_sheet_write_cells_test.go
+++ b/shortcuts/sheets/lark_sheet_write_cells_test.go
@@ -496,6 +496,31 @@ func TestCellsSetImage_DryRun(t *testing.T) {
}
}
+// TestCellsSetImage_DryRunOfficeParentType confirms that an imported "office"
+// spreadsheet (token prefixed with "fake_office_") uploads with
+// parent_type=office_sheet_file instead of the native sheet_image, and that the
+// preview's parent_node carries the same token.
+func TestCellsSetImage_DryRunOfficeParentType(t *testing.T) {
+ t.Parallel()
+ const officeToken = "fake_office_abc123"
+ calls := parseDryRunAPI(t, CellsSetImage, []string{
+ "--spreadsheet-token", officeToken, "--sheet-id", testSheetID,
+ "--range", "A1",
+ "--image", "./README.md", // any existing-shaped path; dry-run skips stat
+ })
+ if len(calls) != 2 {
+ t.Fatalf("api calls = %d, want 2 (upload + set_cell_range)", len(calls))
+ }
+ upload := calls[0].(map[string]interface{})
+ ubody, _ := upload["body"].(map[string]interface{})
+ if ubody["parent_type"] != officeSheetFileParentType {
+ t.Errorf("parent_type = %v, want %s", ubody["parent_type"], officeSheetFileParentType)
+ }
+ if ubody["parent_node"] != officeToken {
+ t.Errorf("parent_node = %v, want %s", ubody["parent_node"], officeToken)
+ }
+}
+
func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, CellsSetImage, []string{
diff --git a/shortcuts/sheets/sheet_media_parent_type_test.go b/shortcuts/sheets/sheet_media_parent_type_test.go
new file mode 100644
index 00000000..ebf03dec
--- /dev/null
+++ b/shortcuts/sheets/sheet_media_parent_type_test.go
@@ -0,0 +1,192 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package sheets
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "io/fs"
+ "mime"
+ "mime/multipart"
+ "os"
+ "testing"
+
+ "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/httpmock"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+// TestSheetMediaParentType pins the token→parent_type mapping that every
+// sheets image-upload entry point funnels through. Native spreadsheet tokens
+// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_"
+// synthetic token and must upload with "office_sheet_file".
+func TestSheetMediaParentType(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ token string
+ want string
+ }{
+ {"native spreadsheet token", "shtcnABC123", sheetImageParentType},
+ {"empty token", "", sheetImageParentType},
+ {"office imported token", "fake_office_abc123", officeSheetFileParentType},
+ {"office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType},
+ {"prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ if got := sheetMediaParentType(tc.token); got != tc.want {
+ t.Fatalf("sheetMediaParentType(%q) = %q, want %q", tc.token, got, tc.want)
+ }
+ })
+ }
+}
+
+// TestUploadSheetImage_ParentType exercises the uploadSheetImage collector end
+// to end (the Execute path the dry-run tests don't reach), asserting the
+// parent_type that actually goes out on the wire is derived from the token: a
+// native spreadsheet uploads as sheet_image, an imported "office" spreadsheet
+// (fake_office_-prefixed token) as office_sheet_file.
+func TestUploadSheetImage_ParentType(t *testing.T) {
+ cases := []struct {
+ name string
+ token string
+ wantParentType string
+ }{
+ {"native spreadsheet", "shtcnTOK123", sheetImageParentType},
+ {"office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ runtime, reg := newSheetMediaTestRuntime(t)
+ // UploadDriveMediaAllTyped opens the file via the runtime's FileIO,
+ // which sandboxes paths to the current working directory; chdir to a
+ // temp dir and pass a relative name so the open is allowed.
+ cmdutil.TestChdir(t, t.TempDir())
+ if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/medias/upload_all",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{"file_token": "boxTOK123"},
+ },
+ }
+ reg.Register(stub)
+
+ fileToken, err := uploadSheetImage(runtime, tc.token, "img.png", "img.png", 9)
+ if err != nil {
+ t.Fatalf("uploadSheetImage() error: %v", err)
+ }
+ if fileToken != "boxTOK123" {
+ t.Fatalf("file_token = %q, want boxTOK123", fileToken)
+ }
+
+ body := decodeSheetMediaMultipartBody(t, stub)
+ if got := body.Fields["parent_type"]; got != tc.wantParentType {
+ t.Fatalf("parent_type = %q, want %q", got, tc.wantParentType)
+ }
+ if got := body.Fields["parent_node"]; got != tc.token {
+ t.Fatalf("parent_node = %q, want %q", got, tc.token)
+ }
+ if got := body.Fields["file_name"]; got != "img.png" {
+ t.Fatalf("file_name = %q, want img.png", got)
+ }
+ })
+ }
+}
+
+// TestUploadSheetImage_FileOpenError confirms a missing image surfaces as a
+// typed validation error (category=validation, subtype=invalid_argument) with
+// the original os-level cause preserved for errors.Is, and proves the upload
+// endpoint is never hit. No httpmock stub is registered, so if uploadSheetImage
+// ever tried to POST upload_all the RoundTrip would return a
+// "no stub for POST ..." network failure — that would surface as a
+// non-validation category and fail the metadata assertion below. The
+// category=validation + fs.ErrNotExist cause therefore strictly implies the
+// short-circuit happened before the wire.
+func TestUploadSheetImage_FileOpenError(t *testing.T) {
+ runtime, _ := newSheetMediaTestRuntime(t)
+ cmdutil.TestChdir(t, t.TempDir())
+
+ _, err := uploadSheetImage(runtime, "shtcnTOK123", "missing.png", "missing.png", 1)
+ if err == nil {
+ t.Fatal("expected error for missing file, got nil")
+ }
+
+ p, ok := errs.ProblemOf(err)
+ if !ok {
+ t.Fatalf("err = %v; want typed problem carrier", err)
+ }
+ if p.Category != errs.CategoryValidation {
+ t.Fatalf("category = %q, want %q (non-validation implies the upload endpoint was reached)", p.Category, errs.CategoryValidation)
+ }
+ if p.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
+ }
+ if !errors.Is(err, fs.ErrNotExist) {
+ t.Fatalf("err = %v; want wrapped fs.ErrNotExist cause to be preserved", err)
+ }
+}
+
+func newSheetMediaTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
+ t.Helper()
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ cfg := &core.CliConfig{
+ AppID: "test-sheets-media-" + t.Name(),
+ AppSecret: "test-secret",
+ Brand: core.BrandFeishu,
+ }
+ f, _, _, reg := cmdutil.TestFactory(t, cfg)
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "sheets"}, cfg, f, core.AsBot)
+ return runtime, reg
+}
+
+type sheetMediaCapturedMultipart struct {
+ Fields map[string]string
+ Files map[string][]byte
+}
+
+func decodeSheetMediaMultipartBody(t *testing.T, stub *httpmock.Stub) sheetMediaCapturedMultipart {
+ t.Helper()
+ contentType := stub.CapturedHeaders.Get("Content-Type")
+ mediaType, params, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ t.Fatalf("parse content-type %q: %v", contentType, err)
+ }
+ if mediaType != "multipart/form-data" {
+ t.Fatalf("content type = %q, want multipart/form-data", mediaType)
+ }
+ reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
+ body := sheetMediaCapturedMultipart{Fields: map[string]string{}, Files: map[string][]byte{}}
+ for {
+ part, err := reader.NextPart()
+ if err != nil {
+ if err == io.EOF {
+ break
+ }
+ t.Fatalf("read multipart part: %v", err)
+ }
+ buf := new(bytes.Buffer)
+ if _, err := buf.ReadFrom(part); err != nil {
+ t.Fatalf("read multipart body for %q: %v", part.FormName(), err)
+ }
+ if part.FileName() != "" {
+ body.Files[part.FormName()] = buf.Bytes()
+ continue
+ }
+ body.Fields[part.FormName()] = buf.String()
+ }
+ return body
+}
diff --git a/shortcuts/slides/shortcuts.go b/shortcuts/slides/shortcuts.go
index f665810c..011e27e0 100644
--- a/shortcuts/slides/shortcuts.go
+++ b/shortcuts/slides/shortcuts.go
@@ -11,6 +11,8 @@ func Shortcuts() []common.Shortcut {
SlidesCreate,
SlidesMediaUpload,
SlidesReplaceSlide,
+ SlidesReplacePages,
SlidesScreenshot,
+ SlidesXMLGet,
}
}
diff --git a/shortcuts/slides/slides_create.go b/shortcuts/slides/slides_create.go
index 115f04cd..b03fea49 100644
--- a/shortcuts/slides/slides_create.go
+++ b/shortcuts/slides/slides_create.go
@@ -204,13 +204,11 @@ var SlidesCreate = common.Shortcut{
}
}
- // Build the presentation URL locally from the token. The brand-standard
- // host transparently redirects to the tenant domain (same fallback used by
- // drive +upload / wiki +node-create). This avoids the prior best-effort
- // drive metas/batch_query call, which needed an extra drive scope and 403'd
- // for users who only authorized slides scopes — without ever blocking an
- // otherwise-successful creation.
- if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
+ // Prefer the URL returned by presentation.create. Fall back to a local
+ // brand-standard URL only when the API omits it.
+ if url := common.GetString(data, "url"); url != "" {
+ result["url"] = url
+ } else if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
result["url"] = url
}
diff --git a/shortcuts/slides/slides_create_test.go b/shortcuts/slides/slides_create_test.go
index 36b964a0..a5ba64cd 100644
--- a/shortcuts/slides/slides_create_test.go
+++ b/shortcuts/slides/slides_create_test.go
@@ -34,6 +34,7 @@ func TestSlidesCreateBasic(t *testing.T) {
"data": map[string]interface{}{
"xml_presentation_id": "pres_abc123",
"revision_id": 1,
+ "url": "https://tenant.example.com/slides/pres_abc123",
},
},
})
@@ -54,10 +55,8 @@ func TestSlidesCreateBasic(t *testing.T) {
if data["title"] != "项目汇报" {
t.Fatalf("title = %v, want 项目汇报", data["title"])
}
- // URL is built locally from the token (brand-standard host), not fetched from
- // drive metas, so it is deterministic and needs no drive scope.
- if data["url"] != "https://www.feishu.cn/slides/pres_abc123" {
- t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_abc123", data["url"])
+ if data["url"] != "https://tenant.example.com/slides/pres_abc123" {
+ t.Fatalf("url = %v, want https://tenant.example.com/slides/pres_abc123", data["url"])
}
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode")
@@ -647,12 +646,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
}
}
-// TestSlidesCreateURLBuiltLocally verifies the presentation URL is constructed
-// locally from the token — no drive metas/batch_query call is made, so creation
-// works for users who only authorized slides scopes. The httpmock registry has no
-// batch_query stub registered; if the shortcut tried to call it, the request would
-// fail the test (unregistered stub), proving the URL is built without a drive call.
-func TestSlidesCreateURLBuiltLocally(t *testing.T) {
+// TestSlidesCreateURLFallsBackToLocalBuild verifies the presentation URL is
+// constructed locally from the token when presentation.create omits url — no
+// drive metas/batch_query call is made, so creation works for users who only
+// authorized slides scopes. The httpmock registry has no batch_query stub
+// registered; if the shortcut tried to call it, the request would fail the test.
+func TestSlidesCreateURLFallsBackToLocalBuild(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
@@ -665,6 +664,7 @@ func TestSlidesCreateURLBuiltLocally(t *testing.T) {
"data": map[string]interface{}{
"xml_presentation_id": "pres_local_url",
"revision_id": 1,
+ "url": "",
},
},
})
diff --git a/shortcuts/slides/slides_replace_pages.go b/shortcuts/slides/slides_replace_pages.go
new file mode 100644
index 00000000..a2ee7716
--- /dev/null
+++ b/shortcuts/slides/slides_replace_pages.go
@@ -0,0 +1,426 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package slides
+
+import (
+ "context"
+ "encoding/json"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/validate"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+// SlidesReplacePages rebuilds multiple pages inside an existing presentation.
+// It deliberately creates the new page before deleting the old one so a create
+// failure cannot remove existing user content. The operation is not atomic.
+const replacePagesInitialRevisionID = -1
+
+var SlidesReplacePages = common.Shortcut{
+ Service: "slides",
+ Command: "+replace-pages",
+ Description: "Batch rebuild pages inside an existing Slides presentation (create before old page, then delete old page; not atomic)",
+ Risk: "write",
+ Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
+ // wiki:node:read is required only when --presentation is a wiki URL.
+ ConditionalScopes: []string{"wiki:node:read"},
+ AuthTypes: []string{"user", "bot"},
+ Flags: []common.Flag{
+ {Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
+ {Name: "pages", Desc: "JSON array of page replacements (each: {slide_id, content}); supports @file or -", Required: true, Input: []string{common.File, common.Stdin}},
+ {Name: "continue-on-error", Type: "bool", Desc: "continue with later pages after a create/delete failure; default false"},
+ {Name: "validate-only", Type: "bool", Desc: "validate input and build the create/delete plan without write calls"},
+ },
+ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ ref, err := parsePresentationRef(runtime.Str("presentation"))
+ if err != nil {
+ return err
+ }
+ if ref.Kind == "wiki" {
+ if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
+ return err
+ }
+ }
+ pages, err := parseReplacePages(runtime.Str("pages"))
+ if err != nil {
+ return err
+ }
+ return validateReplacePagesInput(pages)
+ },
+ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
+ dry := common.NewDryRunAPI()
+ resolved, err := prepareReplacePages(runtime)
+ if err != nil {
+ return dry.Set("error", err.Error())
+ }
+ appendReplacePagesDryRunCalls(dry, resolved)
+ return dry.
+ Set("xml_presentation_id", resolved.PresentationID).
+ Set("pages_count", len(resolved.Plan)).
+ Set("plan", replacePagesPlanOutput(resolved.Plan)).
+ Set("note", "dry-run built a create/delete plan from slide_id inputs; no Slides presentation get/create/delete calls were executed")
+ },
+ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ resolved, err := prepareReplacePages(runtime)
+ if err != nil {
+ return err
+ }
+ if runtime.Bool("validate-only") {
+ runtime.Out(map[string]interface{}{
+ "xml_presentation_id": resolved.PresentationID,
+ "pages_count": len(resolved.Plan),
+ "plan": replacePagesPlanOutput(resolved.Plan),
+ "status": "validated",
+ "note": "validate-only checked input and built the create/delete plan; no Slides presentation get/create/delete calls were executed",
+ }, nil)
+ return nil
+ }
+
+ revisionID := replacePagesInitialRevisionID
+ results := make([]replacePageResult, 0, len(resolved.Plan))
+ for i, item := range resolved.Plan {
+ result, err := replaceOnePage(runtime, resolved.PresentationID, item, revisionID)
+ results = append(results, result)
+ if result.RevisionID != nil {
+ revisionID = *result.RevisionID
+ }
+ if err != nil {
+ if runtime.Bool("continue-on-error") {
+ continue
+ }
+ return appendSlidesProgressHint(err, fmt.Sprintf("slides +replace-pages stopped at item %d/%d; %d page(s) completed before failure; old page is kept when create failed", i+1, len(resolved.Plan), countReplacedPages(results)))
+ }
+ }
+
+ out := map[string]interface{}{
+ "xml_presentation_id": resolved.PresentationID,
+ "pages_count": len(resolved.Plan),
+ "results": replacePageResultsOutput(results),
+ "status": "completed",
+ "summary": replacePagesSummaryOutput(results),
+ "note": "batch replace is not atomic; each page was created before its old page was deleted",
+ }
+ if revisionID != replacePagesInitialRevisionID {
+ out["revision_id"] = revisionID
+ }
+ if hasReplacePageFailures(results) {
+ out["status"] = "partial_failure"
+ return runtime.OutPartialFailure(out, nil)
+ }
+ runtime.Out(out, nil)
+ return nil
+ },
+}
+
+type replacePageInput struct {
+ SlideID string
+ Content string
+}
+
+type replacePagePlanItem struct {
+ OldSlideID string
+ Content string
+ Locator string
+}
+
+type replacePagesPrepared struct {
+ PresentationID string
+ Plan []replacePagePlanItem
+}
+
+type replacePageResult struct {
+ OldSlideID string
+ NewSlideID string
+ Status string
+ Error string
+ RevisionID *int
+}
+
+func prepareReplacePages(runtime *common.RuntimeContext) (*replacePagesPrepared, error) {
+ ref, err := parsePresentationRef(runtime.Str("presentation"))
+ if err != nil {
+ return nil, err
+ }
+ presentationID, err := resolvePresentationID(runtime, ref)
+ if err != nil {
+ return nil, err
+ }
+ pages, err := parseReplacePages(runtime.Str("pages"))
+ if err != nil {
+ return nil, err
+ }
+ if err := validateReplacePagesInput(pages); err != nil {
+ return nil, err
+ }
+
+ plan, err := buildReplacePagesPlan(pages)
+ if err != nil {
+ return nil, err
+ }
+ return &replacePagesPrepared{PresentationID: presentationID, Plan: plan}, nil
+}
+
+func parseReplacePages(raw string) ([]replacePageInput, error) {
+ s := strings.TrimSpace(raw)
+ if s == "" {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages cannot be empty").WithParam("--pages")
+ }
+ var decoded []map[string]interface{}
+ if err := json.Unmarshal([]byte(s), &decoded); err != nil {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages invalid JSON, must be an array of objects: %v", err).WithParam("--pages").WithCause(err)
+ }
+ out := make([]replacePageInput, 0, len(decoded))
+ for i, m := range decoded {
+ p := replacePageInput{}
+ if v, ok := m["slide_number"]; ok {
+ _ = v
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_number is no longer supported; use slide_id", i).WithParam("--pages").WithHint("read current slide IDs first, then pass slide_id for each page replacement")
+ }
+ if v, ok := m["slide_id"]; ok {
+ s, ok := v.(string)
+ if !ok {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id must be a string", i).WithParam("--pages")
+ }
+ p.SlideID = s
+ }
+ if v, ok := m["content"]; ok {
+ s, ok := v.(string)
+ if !ok {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a string", i).WithParam("--pages")
+ }
+ p.Content = s
+ }
+ out = append(out, p)
+ }
+ return out, nil
+}
+
+func validateReplacePagesInput(pages []replacePageInput) error {
+ if len(pages) == 0 {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages must contain at least 1 item").WithParam("--pages")
+ }
+ seenIDs := map[string]bool{}
+ for i, p := range pages {
+ id := strings.TrimSpace(p.SlideID)
+ if id == "" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id is required", i).WithParam("--pages")
+ }
+ if seenIDs[id] {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages contains duplicate slide_id %q", id).WithParam("--pages")
+ }
+ seenIDs[id] = true
+ if strings.TrimSpace(p.Content) == "" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content cannot be empty", i).WithParam("--pages")
+ }
+ if err := validateCompleteSlideXML(p.Content); err != nil {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a complete XML element: %v", i, err).WithParam("--pages").WithCause(err)
+ }
+ }
+ return nil
+}
+
+func validateCompleteSlideXML(content string) error {
+ dec := xml.NewDecoder(strings.NewReader(content))
+ depth := 0
+ seenRoot := false
+ for {
+ tok, err := dec.Token()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ return err
+ }
+ switch t := tok.(type) {
+ case xml.StartElement:
+ if depth == 0 {
+ if seenRoot {
+ return invalidSlideXMLStructureError("multiple root elements")
+ }
+ if t.Name.Local != "slide" {
+ return invalidSlideXMLStructureError("root element is <%s>, want ", t.Name.Local)
+ }
+ seenRoot = true
+ }
+ depth++
+ case xml.EndElement:
+ depth--
+ case xml.CharData:
+ if depth == 0 && strings.TrimSpace(string(t)) != "" {
+ return invalidSlideXMLStructureError("non-whitespace text outside root element")
+ }
+ }
+ }
+ if !seenRoot {
+ return invalidSlideXMLStructureError("missing root element")
+ }
+ if depth != 0 {
+ return invalidSlideXMLStructureError("unclosed XML element")
+ }
+ return nil
+}
+
+func invalidSlideXMLStructureError(format string, args ...interface{}) error {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
+}
+
+func buildReplacePagesPlan(pages []replacePageInput) ([]replacePagePlanItem, error) {
+ plan := make([]replacePagePlanItem, 0, len(pages))
+ for _, page := range pages {
+ id := strings.TrimSpace(page.SlideID)
+ plan = append(plan, replacePagePlanItem{
+ OldSlideID: id,
+ Content: page.Content,
+ Locator: "slide_id",
+ })
+ }
+ return plan, nil
+}
+
+func appendReplacePagesDryRunCalls(dry *common.DryRunAPI, resolved *replacePagesPrepared) {
+ dry.Desc("Batch replace pages in-place: create each new page before old page, then delete old page (not atomic)")
+ for i, item := range resolved.Plan {
+ dry.POST(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
+ Desc(fmt.Sprintf("[%d/%d] Create replacement before old slide %s", i*2+1, len(resolved.Plan)*2, item.OldSlideID)).
+ Params(map[string]interface{}{"revision_id": ""}).
+ Body(map[string]interface{}{
+ "slide": map[string]interface{}{"content": item.Content},
+ "before_slide_id": item.OldSlideID,
+ })
+ dry.DELETE(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
+ Desc(fmt.Sprintf("[%d/%d] Delete old slide %s after create succeeds", i*2+2, len(resolved.Plan)*2, item.OldSlideID)).
+ Params(map[string]interface{}{
+ "slide_id": item.OldSlideID,
+ "revision_id": "",
+ })
+ }
+}
+
+func replaceOnePage(runtime *common.RuntimeContext, presentationID string, item replacePagePlanItem, revisionID int) (replacePageResult, error) {
+ result := replacePageResult{
+ OldSlideID: item.OldSlideID,
+ Status: "pending",
+ }
+ slideURL := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID))
+ createData, err := runtime.CallAPITyped(
+ "POST",
+ slideURL,
+ map[string]interface{}{"revision_id": revisionID},
+ map[string]interface{}{
+ "slide": map[string]interface{}{"content": item.Content},
+ "before_slide_id": item.OldSlideID,
+ },
+ )
+ if err != nil {
+ result.Status = "create_failed"
+ result.Error = err.Error()
+ return result, err
+ }
+ newSlideID := common.GetString(createData, "slide_id")
+ if newSlideID == "" {
+ err := errs.NewInternalError(errs.SubtypeInvalidResponse, "slide.create returned no slide_id for replacement of slide_id %q", item.OldSlideID)
+ result.Status = "create_failed"
+ result.Error = err.Error()
+ return result, err
+ }
+ result.NewSlideID = newSlideID
+ if rev, ok := revisionFromData(createData); ok {
+ revisionID = rev
+ result.RevisionID = &rev
+ }
+
+ deleteData, err := runtime.CallAPITyped(
+ "DELETE",
+ slideURL,
+ map[string]interface{}{
+ "slide_id": item.OldSlideID,
+ "revision_id": revisionID,
+ },
+ nil,
+ )
+ if err != nil {
+ result.Status = "delete_failed"
+ result.Error = err.Error()
+ return result, err
+ }
+ if rev, ok := revisionFromData(deleteData); ok {
+ result.RevisionID = &rev
+ }
+ result.Status = "replaced"
+ return result, nil
+}
+
+func revisionFromData(data map[string]interface{}) (int, bool) {
+ if _, ok := data["revision_id"]; !ok {
+ return 0, false
+ }
+ return int(common.GetFloat(data, "revision_id")), true
+}
+
+func replacePagesPlanOutput(plan []replacePagePlanItem) []map[string]interface{} {
+ out := make([]map[string]interface{}, 0, len(plan))
+ for _, item := range plan {
+ out = append(out, map[string]interface{}{
+ "old_slide_id": item.OldSlideID,
+ "insert_before_slide_id": item.OldSlideID,
+ "locator": item.Locator,
+ "action": "create_before_then_delete_old",
+ })
+ }
+ return out
+}
+
+func replacePageResultsOutput(results []replacePageResult) []map[string]interface{} {
+ out := make([]map[string]interface{}, 0, len(results))
+ for _, result := range results {
+ m := map[string]interface{}{
+ "old_slide_id": result.OldSlideID,
+ "status": result.Status,
+ }
+ if result.NewSlideID != "" {
+ m["new_slide_id"] = result.NewSlideID
+ }
+ if result.Error != "" {
+ m["error"] = result.Error
+ }
+ if result.RevisionID != nil {
+ m["revision_id"] = *result.RevisionID
+ }
+ out = append(out, m)
+ }
+ return out
+}
+
+func replacePagesSummaryOutput(results []replacePageResult) map[string]interface{} {
+ replaced := countReplacedPages(results)
+ return map[string]interface{}{
+ "replaced": replaced,
+ "failed": len(results) - replaced,
+ "total": len(results),
+ }
+}
+
+func countReplacedPages(results []replacePageResult) int {
+ n := 0
+ for _, result := range results {
+ if result.Status == "replaced" {
+ n++
+ }
+ }
+ return n
+}
+
+func hasReplacePageFailures(results []replacePageResult) bool {
+ for _, result := range results {
+ if result.Status == "create_failed" || result.Status == "delete_failed" {
+ return true
+ }
+ }
+ return false
+}
diff --git a/shortcuts/slides/slides_replace_pages_test.go b/shortcuts/slides/slides_replace_pages_test.go
new file mode 100644
index 00000000..fd1c5158
--- /dev/null
+++ b/shortcuts/slides/slides_replace_pages_test.go
@@ -0,0 +1,341 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package slides
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/httpmock"
+ "github.com/larksuite/cli/internal/output"
+)
+
+func TestReplacePagesDeclaredScopes(t *testing.T) {
+ if got := SlidesReplacePages.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
+ t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got)
+ }
+ if got := SlidesReplacePages.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
+ t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got)
+ }
+
+ got := SlidesReplacePages.DeclaredScopesForIdentity("user")
+ want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("declared scopes = %#v, want %#v", got, want)
+ }
+}
+
+func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+ var requestOrder []string
+ createStub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
+ },
+ OnMatch: func(req *http.Request) {
+ requestOrder = append(requestOrder, req.Method)
+ },
+ }
+ reg.Register(createStub)
+ var deleteQuery map[string][]string
+ deleteStub := &httpmock.Stub{
+ Method: "DELETE",
+ URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{"revision_id": 12},
+ },
+ OnMatch: func(req *http.Request) {
+ requestOrder = append(requestOrder, req.Method)
+ deleteQuery = req.URL.Query()
+ },
+ }
+ reg.Register(deleteStub)
+
+ pages := `[{"slide_id":"old2","content":" "}]`
+ err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
+ "+replace-pages",
+ "--presentation", "pres_abc",
+ "--pages", pages,
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var createBody struct {
+ Slide struct {
+ Content string `json:"content"`
+ } `json:"slide"`
+ BeforeSlideID string `json:"before_slide_id"`
+ }
+ if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
+ t.Fatalf("decode create body: %v\nraw=%s", err, createStub.CapturedBody)
+ }
+ if createBody.BeforeSlideID != "old2" {
+ t.Fatalf("before_slide_id = %q, want old2", createBody.BeforeSlideID)
+ }
+ if !strings.Contains(createBody.Slide.Content, " "},
+ {"slide_id":"old2","content":" "}
+ ]`
+ err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
+ "+replace-pages",
+ "--presentation", "pres_abc",
+ "--pages", pages,
+ "--continue-on-error",
+ "--as", "user",
+ })
+ var pfErr *output.PartialFailureError
+ if !errors.As(err, &pfErr) {
+ t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
+ }
+
+ env := decodeReplacePagesEnvelope(t, stdout)
+ if env.OK {
+ t.Fatalf("stdout ok = true, want false for partial failure")
+ }
+ data := env.Data
+ if data["status"] != "partial_failure" {
+ t.Fatalf("status = %v, want partial_failure", data["status"])
+ }
+ summary, _ := data["summary"].(map[string]interface{})
+ if summary["replaced"] != float64(1) || summary["failed"] != float64(1) || summary["total"] != float64(2) {
+ t.Fatalf("summary = %#v, want replaced=1 failed=1 total=2", summary)
+ }
+ results, _ := data["results"].([]interface{})
+ if len(results) != 2 {
+ t.Fatalf("results len = %d, want 2", len(results))
+ }
+ first, _ := results[0].(map[string]interface{})
+ second, _ := results[1].(map[string]interface{})
+ if first["status"] != "create_failed" {
+ t.Fatalf("first status = %v, want create_failed", first["status"])
+ }
+ if second["status"] != "replaced" || second["new_slide_id"] != "new2" {
+ t.Fatalf("second result = %#v, want replaced with new2", second)
+ }
+}
+
+func TestReplacePagesContinueOnErrorDeleteFailureIncludesNewSlideID(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{"slide_id": "new1", "revision_id": 11},
+ },
+ })
+ reg.Register(&httpmock.Stub{
+ Method: "DELETE",
+ URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
+ Body: map[string]interface{}{
+ "code": 3350001,
+ "msg": "invalid param",
+ "data": map[string]interface{}{},
+ },
+ })
+
+ pages := `[{"slide_id":"old1","content":" "}]`
+ err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
+ "+replace-pages",
+ "--presentation", "pres_abc",
+ "--pages", pages,
+ "--continue-on-error",
+ "--as", "user",
+ })
+ var pfErr *output.PartialFailureError
+ if !errors.As(err, &pfErr) {
+ t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
+ }
+
+ env := decodeReplacePagesEnvelope(t, stdout)
+ if env.OK {
+ t.Fatalf("stdout ok = true, want false for partial failure")
+ }
+ results, _ := env.Data["results"].([]interface{})
+ if len(results) != 1 {
+ t.Fatalf("results len = %d, want 1", len(results))
+ }
+ first, _ := results[0].(map[string]interface{})
+ if first["status"] != "delete_failed" {
+ t.Fatalf("status = %v, want delete_failed", first["status"])
+ }
+ if first["new_slide_id"] != "new1" {
+ t.Fatalf("new_slide_id = %v, want new1", first["new_slide_id"])
+ }
+}
+
+func TestReplacePagesDryRunPlansOnly(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+
+ pages := `[{"slide_id":"old2","content":" "}]`
+ err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
+ "+replace-pages",
+ "--presentation", "pres_abc",
+ "--pages", pages,
+ "--dry-run",
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var out map[string]interface{}
+ if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
+ t.Fatalf("decode dry-run: %v\nraw=%s", err, stdout.String())
+ }
+ if out["xml_presentation_id"] != "pres_abc" {
+ t.Fatalf("xml_presentation_id = %v", out["xml_presentation_id"])
+ }
+ plan, _ := out["plan"].([]interface{})
+ if len(plan) != 1 {
+ t.Fatalf("plan len = %d, want 1", len(plan))
+ }
+ item, _ := plan[0].(map[string]interface{})
+ if item["old_slide_id"] != "old2" || item["action"] != "create_before_then_delete_old" {
+ t.Fatalf("plan item = %#v", item)
+ }
+ api, _ := out["api"].([]interface{})
+ if len(api) != 2 {
+ t.Fatalf("api len = %d, want create/delete plan", len(api))
+ }
+}
+
+func TestReplacePagesValidationParam(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ pages string
+ }{
+ {"empty pages", `[]`},
+ {"slide number no longer supported", `[{"slide_number":1,"content":" "}]`},
+ {"no locator", `[{"content":" "}]`},
+ {"empty content", `[{"slide_id":"s1","content":" "}]`},
+ {"not slide XML", `[{"slide_id":"s1","content":" "}]`},
+ {"duplicate id", `[{"slide_id":"s1","content":" "},{"slide_id":"s1","content":" "}]`},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+ err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
+ "+replace-pages",
+ "--presentation", "pres_abc",
+ "--pages", tt.pages,
+ "--as", "user",
+ })
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("err = %v, want *errs.ValidationError", err)
+ }
+ if ve.Param != "--pages" {
+ t.Fatalf("Param = %q, want --pages", ve.Param)
+ }
+ })
+ }
+}
+
+type replacePagesEnvelope struct {
+ OK bool `json:"ok"`
+ Data map[string]interface{} `json:"data"`
+}
+
+func decodeReplacePagesEnvelope(t *testing.T, stdout interface{ Bytes() []byte }) replacePagesEnvelope {
+ t.Helper()
+ var env replacePagesEnvelope
+ if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
+ t.Fatalf("decode output: %v\nraw=%s", err, string(stdout.Bytes()))
+ }
+ if env.Data == nil {
+ t.Fatalf("missing data: %#v", env)
+ }
+ return env
+}
diff --git a/shortcuts/slides/slides_replace_slide.go b/shortcuts/slides/slides_replace_slide.go
index a6910728..b518b2c8 100644
--- a/shortcuts/slides/slides_replace_slide.go
+++ b/shortcuts/slides/slides_replace_slide.go
@@ -43,8 +43,10 @@ var SlidesReplaceSlide = common.Shortcut{
Command: "+replace-slide",
Description: "Replace elements on a slide via block_replace / block_insert parts (auto-injects id + on shape elements)",
Risk: "write",
- Scopes: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"},
- AuthTypes: []string{"user", "bot"},
+ Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
+ // wiki:node:read is required only when --presentation is a wiki URL.
+ ConditionalScopes: []string{"wiki:node:read"},
+ AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
{Name: "slide-id", Desc: "slide page identifier (slide_id)", Required: true},
@@ -53,9 +55,15 @@ var SlidesReplaceSlide = common.Shortcut{
{Name: "tid", Desc: "transaction id for concurrent-edit locking (usually empty)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
- if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil {
+ ref, err := parsePresentationRef(runtime.Str("presentation"))
+ if err != nil {
return err
}
+ if ref.Kind == "wiki" {
+ if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
+ return err
+ }
+ }
if strings.TrimSpace(runtime.Str("slide-id")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty").WithParam("--slide-id")
}
diff --git a/shortcuts/slides/slides_replace_slide_test.go b/shortcuts/slides/slides_replace_slide_test.go
index a4c37db3..97a531b2 100644
--- a/shortcuts/slides/slides_replace_slide_test.go
+++ b/shortcuts/slides/slides_replace_slide_test.go
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "reflect"
"strings"
"testing"
@@ -15,6 +16,21 @@ import (
"github.com/larksuite/cli/internal/httpmock"
)
+func TestReplaceSlideDeclaredScopes(t *testing.T) {
+ if got := SlidesReplaceSlide.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
+ t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got)
+ }
+ if got := SlidesReplaceSlide.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
+ t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got)
+ }
+
+ got := SlidesReplaceSlide.DeclaredScopesForIdentity("user")
+ want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("declared scopes = %#v, want %#v", got, want)
+ }
+}
+
// TestReplaceSlideBlockReplaceInjectsID is the core regression: users write
// … as replacement and the CLI must stitch id=""
// onto the root before sending. The backend returns 3350001 otherwise.
diff --git a/shortcuts/slides/slides_screenshot.go b/shortcuts/slides/slides_screenshot.go
index a265facd..ba09549b 100644
--- a/shortcuts/slides/slides_screenshot.go
+++ b/shortcuts/slides/slides_screenshot.go
@@ -34,7 +34,9 @@ var SlidesScreenshot = common.Shortcut{
Command: "+screenshot",
Description: "Save slide screenshots to local files without printing Base64 image data",
Risk: "read",
- Scopes: []string{"slides:presentation:screenshot"},
+ Scopes: []string{},
+ // The screenshot API is allowlist-gated for only a few apps, so do not
+ // advertise/preflight its scope. Let the API fail and let callers degrade.
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
diff --git a/shortcuts/slides/slides_screenshot_test.go b/shortcuts/slides/slides_screenshot_test.go
index 8c477b68..3efae372 100644
--- a/shortcuts/slides/slides_screenshot_test.go
+++ b/shortcuts/slides/slides_screenshot_test.go
@@ -17,11 +17,23 @@ import (
)
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
+ if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
+ t.Fatalf("user preflight scopes = %#v, want empty", got)
+ }
+ if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
+ t.Fatalf("bot preflight scopes = %#v, want empty", got)
+ }
+
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
- want := []string{"slides:presentation:screenshot", "wiki:node:read"}
- if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
+ want := []string{"wiki:node:read"}
+ if len(got) != len(want) || got[0] != want[0] {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
+ for _, scope := range got {
+ if scope == "slides:presentation:screenshot" {
+ t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
+ }
+ }
}
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
diff --git a/shortcuts/slides/slides_xml_get.go b/shortcuts/slides/slides_xml_get.go
new file mode 100644
index 00000000..cc210148
--- /dev/null
+++ b/shortcuts/slides/slides_xml_get.go
@@ -0,0 +1,144 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package slides
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/extension/fileio"
+ "github.com/larksuite/cli/internal/validate"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+// SlidesXMLGet fetches the full XML presentation content and writes it to a
+// local file, keeping the terminal output small for large decks.
+var SlidesXMLGet = common.Shortcut{
+ Service: "slides",
+ Command: "+xml-get",
+ Description: "Fetch full presentation XML and save it to a local file",
+ Risk: "read",
+ Scopes: []string{"slides:presentation:read"},
+ // wiki:node:read is required only when --presentation is a wiki URL.
+ ConditionalScopes: []string{"wiki:node:read"},
+ AuthTypes: []string{"user", "bot"},
+ Flags: []common.Flag{
+ {Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
+ {Name: "output", Desc: "local XML output path; existing file is overwritten", Required: true},
+ {Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
+ {Name: "remove-attr-id", Type: "bool", Desc: "remove XML id attributes in the returned content; useful for read-only inspection, not precise block editing"},
+ },
+ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ ref, err := parsePresentationRef(runtime.Str("presentation"))
+ if err != nil {
+ return err
+ }
+ if ref.Kind == "wiki" {
+ if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
+ return err
+ }
+ }
+ if strings.TrimSpace(runtime.Str("output")) == "" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output")
+ }
+ if _, err := runtime.ResolveSavePath(runtime.Str("output")); err != nil {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
+ }
+ if runtime.Int("revision-id") < -1 {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id")
+ }
+ return nil
+ },
+ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
+ ref, err := parsePresentationRef(runtime.Str("presentation"))
+ if err != nil {
+ return common.NewDryRunAPI().Set("error", err.Error())
+ }
+ presentationID := ref.Token
+ dry := common.NewDryRunAPI()
+ if ref.Kind == "wiki" {
+ presentationID = ""
+ dry.Desc("2-step orchestration: resolve wiki → fetch full presentation XML").
+ GET("/open-apis/wiki/v2/spaces/get_node").
+ Desc("[1] Resolve wiki node to slides presentation").
+ Params(map[string]interface{}{"token": ref.Token})
+ } else {
+ dry.Desc("Fetch full presentation XML and save it to a local file")
+ }
+ params := map[string]interface{}{
+ "revision_id": runtime.Int("revision-id"),
+ }
+ if runtime.Bool("remove-attr-id") {
+ params["remove_attr_id"] = true
+ }
+ dry.GET(fmt.Sprintf(
+ "/open-apis/slides_ai/v1/xml_presentations/%s",
+ validate.EncodePathSegment(presentationID),
+ )).
+ Params(params)
+ return dry.Set("output", runtime.Str("output")).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
+ },
+ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ ref, err := parsePresentationRef(runtime.Str("presentation"))
+ if err != nil {
+ return err
+ }
+ presentationID, err := resolvePresentationID(runtime, ref)
+ if err != nil {
+ return err
+ }
+
+ params := map[string]interface{}{
+ "revision_id": runtime.Int("revision-id"),
+ }
+ if runtime.Bool("remove-attr-id") {
+ params["remove_attr_id"] = true
+ }
+ data, err := runtime.CallAPITyped(
+ "GET",
+ fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID)),
+ params,
+ nil,
+ )
+ if err != nil {
+ return err
+ }
+
+ presentation := common.GetMap(data, "xml_presentation")
+ content := common.GetString(presentation, "content")
+ if content == "" {
+ return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content")
+ }
+ outputPath := runtime.Str("output")
+ result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
+ ContentType: "application/xml",
+ ContentLength: int64(len(content)),
+ }, bytes.NewReader([]byte(content)))
+ if err != nil {
+ return common.WrapSaveErrorTyped(err)
+ }
+ resolvedPath, err := runtime.ResolveSavePath(outputPath)
+ if err != nil {
+ return errs.NewInternalError(errs.SubtypeFileIO, "resolve saved XML path %s: %v", outputPath, err).WithCause(err)
+ }
+
+ out := map[string]interface{}{
+ "xml_presentation_id": presentationID,
+ "path": resolvedPath,
+ "size": result.Size(),
+ "content_saved": true,
+ }
+ if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 {
+ out["revision_id"] = int(revisionID)
+ }
+ if runtime.Bool("remove-attr-id") {
+ out["remove_attr_id"] = true
+ }
+ runtime.Out(out, nil)
+ return nil
+ },
+}
diff --git a/shortcuts/slides/slides_xml_get_test.go b/shortcuts/slides/slides_xml_get_test.go
new file mode 100644
index 00000000..f0f3a2ab
--- /dev/null
+++ b/shortcuts/slides/slides_xml_get_test.go
@@ -0,0 +1,165 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package slides
+
+import (
+ "errors"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/httpmock"
+)
+
+func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
+ dir := t.TempDir()
+ withSlidesTestWorkingDir(t, dir)
+
+ xml := `hello `
+ var capturedQuery url.Values
+ f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "xml_presentation": map[string]interface{}{
+ "presentation_id": "pres_abc",
+ "revision_id": 7,
+ "content": xml,
+ },
+ },
+ },
+ OnMatch: func(req *http.Request) {
+ capturedQuery = req.URL.Query()
+ },
+ })
+
+ err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
+ "+xml-get",
+ "--presentation", "pres_abc",
+ "--output", "readback.xml",
+ "--revision-id", "7",
+ "--remove-attr-id",
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ path := filepath.Join(dir, "readback.xml")
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read saved XML: %v", err)
+ }
+ if string(got) != xml {
+ t.Fatalf("saved XML = %q, want %q", got, xml)
+ }
+ if strings.Contains(stdout.String(), xml) {
+ t.Fatalf("stdout leaked full XML content: %s", stdout.String())
+ }
+ if got := capturedQuery.Get("revision_id"); got != "7" {
+ t.Fatalf("revision_id query = %q, want 7", got)
+ }
+ if got := capturedQuery.Get("remove_attr_id"); got != "true" {
+ t.Fatalf("remove_attr_id query = %q, want true", got)
+ }
+
+ data := decodeShortcutData(t, stdout)
+ if data["xml_presentation_id"] != "pres_abc" {
+ t.Fatalf("xml_presentation_id = %v, want pres_abc", data["xml_presentation_id"])
+ }
+ if data["revision_id"] != float64(7) {
+ t.Fatalf("revision_id = %v, want 7", data["revision_id"])
+ }
+ if data["size"] != float64(len(xml)) {
+ t.Fatalf("size = %v, want %d", data["size"], len(xml))
+ }
+ gotPath, _ := data["path"].(string)
+ if !filepath.IsAbs(gotPath) {
+ t.Fatalf("path = %v, want absolute path", gotPath)
+ }
+ if !strings.HasSuffix(gotPath, "readback.xml") {
+ t.Fatalf("path = %v, want readback.xml suffix", gotPath)
+ }
+}
+
+func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
+ dir := t.TempDir()
+ withSlidesTestWorkingDir(t, dir)
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/wiki/v2/spaces/get_node",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "node": map[string]interface{}{
+ "obj_type": "slides",
+ "obj_token": "pres_real",
+ },
+ },
+ },
+ })
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/slides_ai/v1/xml_presentations/pres_real",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "xml_presentation": map[string]interface{}{
+ "content": ` `,
+ },
+ },
+ },
+ })
+
+ err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
+ "+xml-get",
+ "--presentation", "https://example.feishu.cn/wiki/wikcn123",
+ "--output", "wiki.xml",
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ data := decodeShortcutData(t, stdout)
+ if data["xml_presentation_id"] != "pres_real" {
+ t.Fatalf("xml_presentation_id = %v, want pres_real", data["xml_presentation_id"])
+ }
+}
+
+func TestSlidesXMLGetRejectsUnsafeOutputPath(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+ err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
+ "+xml-get",
+ "--presentation", "pres_abc",
+ "--output", "../readback.xml",
+ "--as", "user",
+ })
+ if err == nil {
+ t.Fatal("expected unsafe output path error, got nil")
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok {
+ t.Fatalf("expected typed error, got %T %v", err, err)
+ }
+ if problem.Category != errs.CategoryValidation {
+ t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
+ }
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) {
+ t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
+ }
+ if validationErr.Param != "--output" {
+ t.Fatalf("param = %q, want --output", validationErr.Param)
+ }
+}
diff --git a/shortcuts/vc/shortcuts.go b/shortcuts/vc/shortcuts.go
index 44464447..6b4ab523 100644
--- a/shortcuts/vc/shortcuts.go
+++ b/shortcuts/vc/shortcuts.go
@@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut {
VCSearch,
VCNotes,
VCRecording,
+ VCDetail,
VCMeetingJoin,
VCMeetingLeave,
VCMeetingListActive,
diff --git a/shortcuts/vc/vc_detail.go b/shortcuts/vc/vc_detail.go
new file mode 100644
index 00000000..05f1055f
--- /dev/null
+++ b/shortcuts/vc/vc_detail.go
@@ -0,0 +1,216 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+//
+// vc +detail — get meeting details including note_id and minute_token
+
+package vc
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/auth"
+ "github.com/larksuite/cli/internal/credential"
+ "github.com/larksuite/cli/internal/output"
+ "github.com/larksuite/cli/internal/validate"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+const detailLogPrefix = "[vc +detail]"
+
+var scopesDetailMeetingIDs = []string{
+ "vc:meeting.meetingevent:read",
+ "vc:record:readonly",
+}
+
+// meetingDetailItem represents a single meeting detail result.
+type meetingDetailItem struct {
+ MeetingID string `json:"meeting_id"`
+ MeetingNo string `json:"meeting_no,omitempty"`
+ Topic string `json:"topic"`
+ StartTime string `json:"start_time,omitempty"`
+ EndTime string `json:"end_time,omitempty"`
+ NoteID string `json:"note_id,omitempty"`
+ MinuteToken string `json:"minute_token,omitempty"`
+ Error string `json:"error,omitempty"`
+ Hint string `json:"hint,omitempty"`
+}
+
+// fetchMeetingDetail queries meeting.get and recording API to return a
+// consolidated view of meeting metadata, note_id, and minute_token.
+// Error is only set when an API call actually fails; note_id and minute_token
+// are always present (empty string when not available).
+func fetchMeetingDetail(ctx context.Context, runtime *common.RuntimeContext, meetingID string) *meetingDetailItem {
+ result := &meetingDetailItem{MeetingID: meetingID}
+
+ // Step 1: query meeting detail
+ data, err := runtime.CallAPITyped(http.MethodGet,
+ fmt.Sprintf("/open-apis/vc/v1/meetings/%s", validate.EncodePathSegment(meetingID)),
+ map[string]interface{}{"with_participants": "false", "query_mode": "0"}, nil)
+ if err != nil {
+ result.Error = fmt.Sprintf("failed to query meeting detail: %v", err)
+ return result
+ }
+
+ meeting, _ := data["meeting"].(map[string]any)
+ if meeting == nil {
+ result.Error = "meeting not found in response"
+ return result
+ }
+
+ if v, ok := meeting["meeting_no"].(string); ok {
+ result.MeetingNo = v
+ }
+ if v, ok := meeting["topic"].(string); ok {
+ result.Topic = v
+ }
+ if v := common.FormatTime(meeting["start_time"]); v != "" {
+ result.StartTime = v
+ }
+ if v := common.FormatTime(meeting["end_time"]); v != "" {
+ result.EndTime = v
+ }
+ if v, ok := meeting["note_id"].(string); ok && v != "" {
+ result.NoteID = v
+ }
+
+ // Step 2: query minute_token via recording API
+ minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
+ if minuteErr != nil {
+ // Recording API failed — surface the error but keep data from step 1
+ result.Error = fmt.Sprintf("failed to query minutes: %v", minuteErr)
+ minuteHint = ""
+ }
+ if minuteToken != "" {
+ result.MinuteToken = minuteToken
+ }
+
+ // Add hints for empty resources (not errors, just informational)
+ var emptyFields []string
+ if result.NoteID == "" {
+ emptyFields = append(emptyFields, "note_id")
+ }
+ if result.MinuteToken == "" && minuteErr == nil && minuteHint == "" {
+ emptyFields = append(emptyFields, "minute_token")
+ }
+ if len(emptyFields) > 0 {
+ result.Hint = fmt.Sprintf("%s not found for this meeting", strings.Join(emptyFields, ", "))
+ }
+ if minuteHint != "" {
+ if result.Hint != "" {
+ result.Hint += "; " + minuteHint
+ } else {
+ result.Hint = minuteHint
+ }
+ }
+
+ return result
+}
+
+// VCDetail gets meeting details including note_id and minute_token.
+var VCDetail = common.Shortcut{
+ Service: "vc",
+ Command: "+detail",
+ Description: "Get meeting details including note_id and minute_token by meeting IDs",
+ Risk: "read",
+ Scopes: []string{"vc:meeting.meetingevent:read", "vc:record:readonly"},
+ AuthTypes: []string{"user"},
+ HasFormat: true,
+ Flags: []common.Flag{
+ {Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch", Required: true},
+ },
+ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ ids := common.SplitCSV(runtime.Str("meeting-ids"))
+ const maxBatchSize = 50
+ if len(ids) > maxBatchSize {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-ids: too many IDs (%d), maximum is %d", len(ids), maxBatchSize).WithParam("--meeting-ids")
+ }
+ // dynamic scope check
+ result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
+ if err == nil && result != nil && result.Scopes != "" {
+ if missing := auth.MissingScopes(result.Scopes, scopesDetailMeetingIDs); len(missing) > 0 {
+ return errs.NewPermissionError(errs.SubtypeMissingScope,
+ "missing required scope(s): %s", strings.Join(missing, ", ")).
+ WithHint("run `lark-cli auth login --scope %q` 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, " ")).
+ WithMissingScopes(missing...).
+ WithIdentity(string(runtime.As()))
+ }
+ }
+ return nil
+ },
+ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
+ ids := runtime.Str("meeting-ids")
+ return common.NewDryRunAPI().
+ GET("/open-apis/vc/v1/meetings/{meeting_id}").
+ GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
+ Set("meeting_ids", common.SplitCSV(ids)).
+ Set("steps", "meeting.get → note_id + recording API → minute_token")
+ },
+ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ errOut := runtime.IO().ErrOut
+ meetingIDs := common.SplitCSV(runtime.Str("meeting-ids"))
+ results := make([]*meetingDetailItem, 0, len(meetingIDs))
+
+ const batchDelay = 100 * time.Millisecond
+ fmt.Fprintf(errOut, "%s querying %d meeting_id(s)\n", detailLogPrefix, len(meetingIDs))
+ for i, id := range meetingIDs {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if i > 0 {
+ time.Sleep(batchDelay)
+ }
+ fmt.Fprintf(errOut, "%s querying meeting_id=%s ...\n", detailLogPrefix, sanitizeLogValue(id))
+ results = append(results, fetchMeetingDetail(ctx, runtime, id))
+ }
+
+ successCount := 0
+ for _, r := range results {
+ if r.Error == "" {
+ successCount++
+ }
+ }
+ fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", detailLogPrefix, len(results), successCount, len(results)-successCount)
+
+ if successCount == 0 && len(results) > 0 {
+ return runtime.OutPartialFailure(map[string]any{"meetings": results}, &output.Meta{Count: len(results)})
+ }
+
+ outData := map[string]any{"meetings": results}
+ runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
+ if len(results) == 0 {
+ fmt.Fprintln(w, "No meetings.")
+ return
+ }
+ var rows []map[string]interface{}
+ for _, r := range results {
+ row := map[string]interface{}{"meeting_id": r.MeetingID}
+ if r.Error != "" {
+ row["status"] = "FAIL"
+ row["error"] = r.Error
+ } else {
+ row["status"] = "OK"
+ }
+ if r.NoteID != "" {
+ row["note_id"] = r.NoteID
+ }
+ if r.MinuteToken != "" {
+ row["minute_token"] = r.MinuteToken
+ }
+ row["topic"] = r.Topic
+ if r.Hint != "" {
+ row["hint"] = r.Hint
+ }
+ rows = append(rows, row)
+ }
+ output.PrintTable(w, rows)
+ fmt.Fprintf(w, "\n%d meeting(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
+ })
+ return nil
+ },
+}
diff --git a/shortcuts/vc/vc_detail_test.go b/shortcuts/vc/vc_detail_test.go
new file mode 100644
index 00000000..4440a78c
--- /dev/null
+++ b/shortcuts/vc/vc_detail_test.go
@@ -0,0 +1,282 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package vc
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/httpmock"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+// ---------------------------------------------------------------------------
+// Validation tests
+// ---------------------------------------------------------------------------
+
+func TestDetail_Validation_MissingMeetingIDs(t *testing.T) {
+ f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
+ err := mountAndRun(t, VCDetail, []string{"+detail", "--as", "user"}, f, nil)
+ if err == nil {
+ t.Fatal("expected validation error for missing --meeting-ids")
+ }
+ if !strings.Contains(err.Error(), "meeting-ids") {
+ t.Errorf("unexpected error message: %v", err)
+ }
+}
+
+func TestDetail_Validation_BatchLimit(t *testing.T) {
+ f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
+ ids := make([]string, 51)
+ for i := range ids {
+ ids[i] = fmt.Sprintf("m%d", i)
+ }
+ err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
+ if err == nil {
+ t.Fatal("expected batch limit error")
+ }
+ if !strings.Contains(err.Error(), "too many IDs") {
+ t.Errorf("expected 'too many IDs' error, got: %v", err)
+ }
+ 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 = %q, want SubtypeInvalidArgument", ve.Subtype)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// DryRun tests
+// ---------------------------------------------------------------------------
+
+func TestDetail_DryRun(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
+ err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m001", "--dry-run", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(stdout.String(), "/open-apis/vc/v1/meetings/") {
+ t.Errorf("dry-run should show meeting API path, got: %s", stdout.String())
+ }
+ if !strings.Contains(stdout.String(), "recording") {
+ t.Errorf("dry-run should show recording API path, got: %s", stdout.String())
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Execute tests with mocked HTTP
+// ---------------------------------------------------------------------------
+
+func TestDetail_Execute_Success(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(meetingGetStub("m_detail1", "note_001"))
+ reg.Register(recordingOKStub("m_detail1", "https://meetings.feishu.cn/minutes/obc_detail1"))
+
+ err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_detail1", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var resp map[string]any
+ if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse output: %v", err)
+ }
+ data, _ := resp["data"].(map[string]any)
+ meetings, _ := data["meetings"].([]any)
+ if len(meetings) != 1 {
+ t.Fatalf("expected 1 meeting, got %d", len(meetings))
+ }
+ m, _ := meetings[0].(map[string]any)
+ if m["meeting_id"] != "m_detail1" {
+ t.Errorf("meeting_id = %v, want m_detail1", m["meeting_id"])
+ }
+ if m["note_id"] != "note_001" {
+ t.Errorf("note_id = %v, want note_001", m["note_id"])
+ }
+ if m["minute_token"] != "obc_detail1" {
+ t.Errorf("minute_token = %v, want obc_detail1", m["minute_token"])
+ }
+}
+
+func TestDetail_Execute_NoNoteNoMinute(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(meetingGetStub("m_nonote", ""))
+ reg.Register(recordingErrStub("m_nonote", 121004, "not found"))
+
+ err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_nonote", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Verify hint is present for empty note_id and missing recording
+ var resp map[string]any
+ if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse output: %v", err)
+ }
+ data, _ := resp["data"].(map[string]any)
+ meetings, _ := data["meetings"].([]any)
+ m, _ := meetings[0].(map[string]any)
+ if hint, _ := m["hint"].(string); !strings.Contains(hint, "note_id") || !strings.Contains(hint, "no minute file for this meeting") {
+ t.Errorf("hint should mention note_id and minute file missing, got: %v", hint)
+ }
+ if errMsg, _ := m["error"].(string); errMsg != "" {
+ t.Errorf("error should be empty, got: %v", errMsg)
+ }
+}
+
+func TestDetail_Execute_MeetingNotFound(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/vc/v1/meetings/m_bad",
+ Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
+ })
+
+ err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_bad", "--as", "user"}, f, stdout)
+ if err == nil {
+ t.Fatal("expected partial failure error")
+ }
+}
+
+func TestDetail_Execute_Batch(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ // m1 succeeds with note and minute
+ reg.Register(meetingGetStub("m_batch1", "note_b1"))
+ reg.Register(recordingOKStub("m_batch1", "https://meetings.feishu.cn/minutes/obc_b1"))
+ // m2 has no note_id but has minute
+ reg.Register(meetingGetStub("m_batch2", ""))
+ reg.Register(recordingOKStub("m_batch2", "https://meetings.feishu.cn/minutes/obc_b2"))
+
+ err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_batch1,m_batch2", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var resp map[string]any
+ if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse output: %v", err)
+ }
+ data, _ := resp["data"].(map[string]any)
+ meetings, _ := data["meetings"].([]any)
+ if len(meetings) != 2 {
+ t.Fatalf("expected 2 meetings, got %d", len(meetings))
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Pure function tests
+// ---------------------------------------------------------------------------
+
+func TestFetchMeetingDetail_MeetingWithNoteAndMinute(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(meetingGetStub("m_fn", "note_fn"))
+ reg.Register(recordingOKStub("m_fn", "https://meetings.feishu.cn/minutes/obc_fn"))
+
+ if err := botExec(t, "detail-fn", f, func(_ context.Context, rctx *common.RuntimeContext) error {
+ result := fetchMeetingDetail(context.Background(), rctx, "m_fn")
+ if result.MeetingID != "m_fn" {
+ t.Errorf("meeting_id = %v, want m_fn", result.MeetingID)
+ }
+ if result.NoteID != "note_fn" {
+ t.Errorf("note_id = %v, want note_fn", result.NoteID)
+ }
+ if result.MinuteToken != "obc_fn" {
+ t.Errorf("minute_token = %v, want obc_fn", result.MinuteToken)
+ }
+ if result.Error != "" {
+ t.Errorf("unexpected error: %v", result.Error)
+ }
+ return nil
+ }); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestFetchMeetingDetail_MeetingNotFound(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/vc/v1/meetings/m_nf",
+ Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
+ })
+
+ if err := botExec(t, "detail-nf", f, func(_ context.Context, rctx *common.RuntimeContext) error {
+ result := fetchMeetingDetail(context.Background(), rctx, "m_nf")
+ if result.Error == "" {
+ t.Error("expected error for meeting not found")
+ }
+ // note_id and minute_token should still be present (empty)
+ if result.NoteID != "" {
+ t.Errorf("note_id = %q, want empty", result.NoteID)
+ }
+ if result.MinuteToken != "" {
+ t.Errorf("minute_token = %q, want empty", result.MinuteToken)
+ }
+ return nil
+ }); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestFetchMeetingDetail_RecordingFailsButNoteOK(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(meetingGetStub("m_partial", "note_partial"))
+ reg.Register(recordingErrStub("m_partial", 121004, "not found"))
+
+ if err := botExec(t, "detail-partial", f, func(_ context.Context, rctx *common.RuntimeContext) error {
+ result := fetchMeetingDetail(context.Background(), rctx, "m_partial")
+ if result.NoteID != "note_partial" {
+ t.Errorf("note_id = %v, want note_partial", result.NoteID)
+ }
+ if result.MinuteToken != "" {
+ t.Errorf("minute_token = %q, want empty", result.MinuteToken)
+ }
+ if result.Error != "" {
+ t.Errorf("error = %q, want empty", result.Error)
+ }
+ if !strings.Contains(result.Hint, "no minute file for this meeting") {
+ t.Errorf("hint = %q, want contains 'no minute file for this meeting'", result.Hint)
+ }
+ return nil
+ }); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestFetchMeetingDetail_RecordingAPIErrorButNoteOK(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(meetingGetStub("m_api_err", "note_apierr"))
+ reg.Register(recordingErrStub("m_api_err", 99999, "weird API error"))
+
+ if err := botExec(t, "detail-apierr", f, func(_ context.Context, rctx *common.RuntimeContext) error {
+ result := fetchMeetingDetail(context.Background(), rctx, "m_api_err")
+ if result.NoteID != "note_apierr" {
+ t.Errorf("note_id = %v, want note_apierr", result.NoteID)
+ }
+ if result.MinuteToken != "" {
+ t.Errorf("minute_token = %q, want empty", result.MinuteToken)
+ }
+ if !strings.Contains(result.Error, "failed to query minutes") || !strings.Contains(result.Error, "weird API error") {
+ t.Errorf("error = %q, want contains 'failed to query minutes' and 'weird API error'", result.Error)
+ }
+ if strings.Contains(result.Hint, "minute_token") {
+ t.Errorf("hint = %q, should not mention minute_token when there is an error", result.Hint)
+ }
+ return nil
+ }); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
diff --git a/shortcuts/vc/vc_meeting_events_test.go b/shortcuts/vc/vc_meeting_events_test.go
index 2e9e7354..19849873 100644
--- a/shortcuts/vc/vc_meeting_events_test.go
+++ b/shortcuts/vc/vc_meeting_events_test.go
@@ -838,7 +838,7 @@ func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
for _, shortcut := range got {
commands = append(commands, shortcut.Command)
}
- want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events"}
+ want := []string{"+search", "+notes", "+recording", "+detail", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events"}
if !reflect.DeepEqual(commands, want) {
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
}
diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go
index 30b66856..555acc70 100644
--- a/shortcuts/vc/vc_notes.go
+++ b/shortcuts/vc/vc_notes.go
@@ -263,42 +263,35 @@ func asStringSlice(v any) []string {
}
// fetchMeetingMinuteToken queries the recording API of a meeting and returns
-// the associated minute_token (parsed from the recording URL) and an
-// optional human-friendly error message. On success token is non-empty and
-// errMsg is empty; on failure token is empty and errMsg describes the cause:
-// - 121004: meeting has no minute file
-// - 121005: caller has no permission for the meeting recording
-// - 124002: recording / minute file is still being generated
-//
-// Other failures fall back to the raw API error description so Agents can
-// still parse the underlying cause.
-func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (token, errMsg string) {
- data, err := runtime.CallAPITyped(http.MethodGet,
+// the associated minute_token (parsed from the recording URL), an optional
+// hint for expected missing states, and an error for unexpected failures.
+func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (token, hint string, err error) {
+ data, apiErr := runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)),
nil, nil)
- if err != nil {
- if p, ok := errs.ProblemOf(err); ok {
+ if apiErr != nil {
+ if p, ok := errs.ProblemOf(apiErr); ok {
switch p.Code {
case recordingNotFoundCode:
- return "", "no minute file for this meeting"
+ return "", "no minute file for this meeting", nil
case recordingNoPermissionCode:
- return "", "no permission to access this meeting's minute; ask the meeting owner to share the minute"
+ return "", "no permission to access this meeting's minute; ask the meeting owner to share the minute", nil
case recordingGeneratingCode:
- return "", "minute file is still being generated; please retry later"
+ return "", "minute file is still being generated; please retry later", nil
}
}
- return "", fmt.Sprintf("failed to query recording: %v", err)
+ return "", "", apiErr
}
recording, _ := data["recording"].(map[string]any)
if recording == nil {
- return "", "no recording available for this meeting"
+ return "", "no recording available for this meeting", nil
}
recordingURL, _ := recording["url"].(string)
if t := extractMinuteToken(recordingURL); t != "" {
- return t, ""
+ return t, "", nil
}
- return "", "no minute_token found in recording URL"
+ return "", "no minute_token found in recording URL", nil
}
// fetchNoteByMeetingID queries notes via meeting_id and additionally fetches
@@ -321,7 +314,7 @@ func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, m
// Always attempt to query the meeting's minute_token via the recording API,
// regardless of whether the meeting has a note_id, so callers always see
// minute state for follow-up calls (e.g. `vc +notes --minute-tokens=...`).
- minuteToken, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
+ minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
var result map[string]any
var noteErr string
@@ -340,7 +333,13 @@ func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, m
if minuteToken != "" {
result["minute_token"] = minuteToken
}
- if combined := joinErrors(noteErr, minuteErr); combined != "" {
+ var minuteErrMsg string
+ if minuteHint != "" {
+ minuteErrMsg = minuteHint
+ } else if minuteErr != nil {
+ minuteErrMsg = minuteErr.Error()
+ }
+ if combined := joinErrors(noteErr, minuteErrMsg); combined != "" {
result["error"] = combined
}
return result
@@ -538,6 +537,7 @@ var VCNotes = common.Shortcut{
Risk: "read",
Scopes: []string{"vc:note:read"}, // minimum scope; additional per-flag scopes checked in Validate
AuthTypes: []string{"user"},
+ Hidden: true, // hidden from --help; prefer vc +detail, minutes +detail, or note +detail
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch"},
diff --git a/shortcuts/vc/vc_notes_test.go b/shortcuts/vc/vc_notes_test.go
index a5b55ca6..992c5f2d 100644
--- a/shortcuts/vc/vc_notes_test.go
+++ b/shortcuts/vc/vc_notes_test.go
@@ -792,12 +792,15 @@ func TestFetchMeetingMinuteToken_Success(t *testing.T) {
reg.Register(recordingOKStub("m_ok", "https://meetings.feishu.cn/minutes/obctoken_ok"))
if err := botExec(t, "fmmt-ok", f, func(_ context.Context, rctx *common.RuntimeContext) error {
- token, msg := fetchMeetingMinuteToken(rctx, "m_ok")
+ token, hint, err := fetchMeetingMinuteToken(rctx, "m_ok")
if token != "obctoken_ok" {
t.Errorf("token = %q, want obctoken_ok", token)
}
- if msg != "" {
- t.Errorf("errMsg = %q, want empty", msg)
+ if hint != "" {
+ t.Errorf("hint = %q, want empty", hint)
+ }
+ if err != nil {
+ t.Errorf("err = %v, want nil", err)
}
return nil
}); err != nil {
@@ -823,12 +826,15 @@ func TestFetchMeetingMinuteToken_KnownErrorCodes(t *testing.T) {
reg.Register(recordingErrStub(tt.meetingID, tt.code, "err"))
if err := botExec(t, "fmmt-"+tt.meetingID, f, func(_ context.Context, rctx *common.RuntimeContext) error {
- token, msg := fetchMeetingMinuteToken(rctx, tt.meetingID)
+ token, hint, err := fetchMeetingMinuteToken(rctx, tt.meetingID)
if token != "" {
t.Errorf("token = %q, want empty on error", token)
}
- if !strings.Contains(msg, tt.wantMsg) {
- t.Errorf("errMsg = %q, want contains %q", msg, tt.wantMsg)
+ if !strings.Contains(hint, tt.wantMsg) {
+ t.Errorf("hint = %q, want contains %q", hint, tt.wantMsg)
+ }
+ if err != nil {
+ t.Errorf("err = %v, want nil", err)
}
return nil
}); err != nil {
@@ -844,12 +850,15 @@ func TestFetchMeetingMinuteToken_GenericAPIError(t *testing.T) {
reg.Register(recordingErrStub("m_other", 99999, "weird"))
if err := botExec(t, "fmmt-generic", f, func(_ context.Context, rctx *common.RuntimeContext) error {
- token, msg := fetchMeetingMinuteToken(rctx, "m_other")
+ token, hint, err := fetchMeetingMinuteToken(rctx, "m_other")
if token != "" {
t.Errorf("token = %q, want empty", token)
}
- if !strings.Contains(msg, "failed to query recording") {
- t.Errorf("errMsg = %q, want contains 'failed to query recording'", msg)
+ if hint != "" {
+ t.Errorf("hint = %q, want empty", hint)
+ }
+ if err == nil || !strings.Contains(err.Error(), "weird") {
+ t.Errorf("err = %v, want contains 'weird'", err)
}
return nil
}); err != nil {
@@ -866,12 +875,15 @@ func TestFetchMeetingMinuteToken_NoRecording(t *testing.T) {
}))
if err := botExec(t, "fmmt-norec", f, func(_ context.Context, rctx *common.RuntimeContext) error {
- token, msg := fetchMeetingMinuteToken(rctx, "m_norec")
+ token, hint, err := fetchMeetingMinuteToken(rctx, "m_norec")
if token != "" {
t.Errorf("token = %q, want empty", token)
}
- if !strings.Contains(msg, "no recording available") {
- t.Errorf("errMsg = %q, want contains 'no recording available'", msg)
+ if err != nil {
+ t.Errorf("err = %v, want nil", err)
+ }
+ if !strings.Contains(hint, "no recording available") {
+ t.Errorf("hint = %q, want contains 'no recording available'", hint)
}
return nil
}); err != nil {
@@ -885,12 +897,15 @@ func TestFetchMeetingMinuteToken_URLWithoutToken(t *testing.T) {
reg.Register(recordingOKStub("m_notok", "https://example.com/no/minute/path"))
if err := botExec(t, "fmmt-notok", f, func(_ context.Context, rctx *common.RuntimeContext) error {
- token, msg := fetchMeetingMinuteToken(rctx, "m_notok")
+ token, hint, err := fetchMeetingMinuteToken(rctx, "m_notok")
if token != "" {
t.Errorf("token = %q, want empty", token)
}
- if !strings.Contains(msg, "no minute_token found") {
- t.Errorf("errMsg = %q, want contains 'no minute_token found'", msg)
+ if err != nil {
+ t.Errorf("err = %v, want nil", err)
+ }
+ if !strings.Contains(hint, "no minute_token found") {
+ t.Errorf("hint = %q, want contains 'no minute_token found'", hint)
}
return nil
}); err != nil {
@@ -983,7 +998,7 @@ func TestNotes_MeetingPath_OnlyMinuteFails_PartialSuccess(t *testing.T) {
t.Errorf("note_doc_token = %v, want doc_main", got)
}
assertNoteFieldAbsent(t, note, "minute_token")
- assertNoteError(t, note, "no permission to access this meeting's minute")
+ assertNoteError(t, note, "no permission to access this meeting's minute; ask the meeting owner to share the minute")
}
func TestNotes_MeetingPath_NoNote_ButMinuteOK(t *testing.T) {
@@ -1068,6 +1083,7 @@ func TestNotes_MeetingPath_NoteNoPermission_FriendlyHint(t *testing.T) {
assertNoteError(t, note,
"[121005]",
"no read permission for this meeting note",
+ "no permission to access this meeting's minute",
"; ", // note + minute causes joined with semicolon
)
}
diff --git a/shortcuts/vc/vc_search.go b/shortcuts/vc/vc_search.go
index 37d3685a..f0bac1ab 100644
--- a/shortcuts/vc/vc_search.go
+++ b/shortcuts/vc/vc_search.go
@@ -230,9 +230,16 @@ var VCSearch = common.Shortcut{
data = map[string]interface{}{}
}
items := common.GetSlice(data, "items")
+ // Strip avatar from meta_data — not useful for AI agents.
+ for _, raw := range items {
+ if m, ok := raw.(map[string]interface{}); ok {
+ if meta, ok := m["meta_data"].(map[string]interface{}); ok {
+ delete(meta, "avatar")
+ }
+ }
+ }
outData := map[string]interface{}{
"items": items,
- "total": data["total"],
"has_more": data["has_more"],
"page_token": data["page_token"],
}
diff --git a/skill-template/domains/approval.md b/skill-template/domains/approval.md
new file mode 100644
index 00000000..c79be9e4
--- /dev/null
+++ b/skill-template/domains/approval.md
@@ -0,0 +1,40 @@
+所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval..` 查参数结构,不要猜字段。
+
+## 选哪个命令
+
+| 想做什么 | 命令 |
+|---|---|
+| 搜可发起定义 | `approvals search` |
+| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
+| 发起原生审批实例 | `instances create` |
+| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)|
+| 看表单/进度/当前节点 | `instances get` |
+| 同意/拒绝 | `tasks approve` / `tasks reject` |
+| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
+| 催办 | `tasks remind` |
+| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
+
+处理链:
+
+- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
+- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作
+
+```bash
+lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
+lark-cli approval approvals get --params '{"approval_code":""}' --as user
+lark-cli approval instances create --data '{"approval_code":"","form":"[...]"}' --yes --as user
+lark-cli approval tasks query --params '{"topic":"1"}' --as user
+lark-cli approval tasks approve --data '{"instance_code":"","task_id":"","comment":"同意"}' --as user
+```
+
+## 发起原生审批
+
+发起审批属于高风险写操作,按下表处理:
+
+| 规则 | 处理 |
+|---|---|
+| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` |
+| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form`、`node_approver_list` 或 `node_cc_list` |
+| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 |
+| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) |
+| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code` 与 `instance_link` |
diff --git a/skill-template/domains/doc.md b/skill-template/domains/doc.md
index 9d8b42af..17cf03d0 100644
--- a/skill-template/domains/doc.md
+++ b/skill-template/domains/doc.md
@@ -86,8 +86,8 @@ Drive Folder (云空间文件夹)
## 重要说明:画板编辑
> **⚠️ lark-doc skill 不能直接编辑已有画板内容,但 `docs +update` 可以新建空白画板**
-### 场景 1:已通过 docs +fetch --api-version v2 获取到文档内容和画板 token
-如果用户已经通过 `docs +fetch --api-version v2` 拉取了文档内容,并且文档中已有画板(返回的 markdown 中包含 ` ` 标签),请引导用户:
+### 场景 1:已通过 docs +fetch 获取到文档内容和画板 token
+如果用户已经通过 `docs +fetch` 拉取了文档内容,并且文档中已有画板(返回的 markdown 中包含 ` ` 标签),请引导用户:
1. 记录画板的 token
2. 查看 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md) 了解如何编辑画板内容
### 场景 2:刚创建画板,需要编辑
diff --git a/skill-template/domains/drive.md b/skill-template/domains/drive.md
index fbaf4e8b..99fbabce 100644
--- a/skill-template/domains/drive.md
+++ b/skill-template/domains/drive.md
@@ -111,7 +111,7 @@ Drive Folder (云空间文件夹)
| 操作 | 需要的 Token | 说明 |
|------|-------------|------|
-| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
+| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id,`sheet` 使用 `!`,`slides` 使用 `!`;Base / bitable 只有记录局部评论,定位为 file_token(base token) + `--block-id !!` |
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
diff --git a/skills/lark-approval/SKILL.md b/skills/lark-approval/SKILL.md
index 8bf2e7d6..04b9403c 100644
--- a/skills/lark-approval/SKILL.md
+++ b/skills/lark-approval/SKILL.md
@@ -1,7 +1,7 @@
---
name: lark-approval
-version: 1.1.0
-description: "飞书审批:当前用户审批的查询与全部处理操作,覆盖待本人审批的任务与本人发起的实例。审批待办不是飞书任务(任务类待办走 lark-task);不负责创建审批定义和发起新审批。"
+version: 1.2.0
+description: "飞书审批:查询和处理审批待办/已办/实例,搜索可发起审批定义、查看定义详情并发起原生审批实例。当用户要处理审批任务、查看审批实例、搜索或发起审批时使用。审批待办不是飞书任务;非审批类待办走 lark-task。不负责创建审批定义;三方审批定义不走原生提单。"
metadata:
requires:
bins: ["lark-cli"]
@@ -16,6 +16,9 @@ metadata:
| 想做什么 | 命令 |
|---|---|
+| 搜可发起定义 | `approvals search` |
+| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
+| 发起原生审批实例 | `instances create` |
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)|
| 看表单/进度/当前节点 | `instances get` |
| 同意/拒绝 | `tasks approve` / `tasks reject` |
@@ -23,13 +26,31 @@ metadata:
| 催办 | `tasks remind` |
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
-处理链:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作。
+处理链:
+
+- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
+- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作
```bash
+lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
+lark-cli approval approvals get --params '{"approval_code":""}' --as user
+lark-cli approval instances create --data '{"approval_code":"","form":"[...]"}' --yes --as user
lark-cli approval tasks query --params '{"topic":"1"}' --as user
lark-cli approval tasks approve --data '{"instance_code":"","task_id":"","comment":"同意"}' --as user
```
+## 发起原生审批
+
+发起审批属于高风险写操作,按下表处理:
+
+| 规则 | 处理 |
+|---|---|
+| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` |
+| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form`、`node_approver_list` 或 `node_cc_list` |
+| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 |
+| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) |
+| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code` 与 `instance_link` |
+
## 不在本 skill 范围
-创建审批定义/发起新审批(走飞书客户端或审批管理后台);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
+创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
diff --git a/skills/lark-approval/references/lark-approval-initiate.md b/skills/lark-approval/references/lark-approval-initiate.md
new file mode 100644
index 00000000..d5621a2c
--- /dev/null
+++ b/skills/lark-approval/references/lark-approval-initiate.md
@@ -0,0 +1,196 @@
+# 审批提单工作流
+
+## 执行摘要
+
+- **原生审批提单必须固定走 `approvals.search` -> `approvals.get` -> `instances.create`。** 不要跳过 `get` 直接拼请求。
+- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances.create`,应优先使用 `create_link`。
+- **所有人员类参数默认使用 `open_id`。** 若用户给的是姓名、邮箱或其他身份,先用 [`../../lark-contact/SKILL.md`](../../lark-contact/SKILL.md) 解析。
+- **先读控件参数 reference 和值来源 reference,再看 `schema`。** 提单前必须先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create`。
+- **`approvals.get.form` 不是创建 payload 的原样模板。** 它主要用于识别控件 `id`、`type`、选项值范围和明细子控件结构;真正的 `instances.create.data.form` 中,请求字段与节点字段以 `schema` / `meta` 为准,控件 `value` 结构以 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 为准。
+- **节点参数只从 `node_list` 和 `schema` / `meta` 里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,要先与当前 `schema` 字段名和 ID 口径对齐,不要混用姓名或其他身份标识。
+- **看到 `need_approver=true` 就说明该节点需要发起人补充审批人。** 如果 `approver_chosen_multi=false`,该节点只允许一个 `open_id`。
+- **创建实例前先确认。** `approval instances create` 是写操作,真正执行时显式传 `--yes`。
+
+## 适用场景
+
+- “帮我提交一个请假审批”
+- “帮我发起报销审批”
+- “我想提一个出差审批”
+- “先搜可发起的审批,再帮我提单”
+
+## 严禁行为
+
+- **严禁在未先查看 `schema` 的情况下猜测 `--data` 结构。**
+- **严禁在未先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)、[`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 且未先查看 `schema` 的情况下直接提单。**
+- **严禁跳过 `approvals.get`。** 未拿到 `form` 和 `node_list` 前,不得调用 `instances.create`。
+- **严禁把姓名直接写进 `node_approver_list`、`node_cc_list` 或表单人员控件。** 必须先转成 `open_id`。
+- **严禁对三方定义调用 `instances.create`。**
+- **严禁对 API 不支持的控件硬提单。** 如果目标定义包含创建实例 API 不支持的控件,应明确告诉用户该定义不能仅通过 API 完整发起。
+- **严禁把 `approvals.get.form` 当成可直接提交的原样模板。**
+- **严禁在未得到用户确认前直接执行真实提单。**
+
+## 工作流
+
+### 1. 搜索可发起审批定义
+
+先用 `schema` 看参数,再搜索定义:
+
+```bash
+lark-cli schema approval.approvals.search
+lark-cli approval approvals search --data '{"keyword":"请假"}'
+```
+
+处理规则:
+
+- 若结果为空,告诉用户当前关键词下没有可发起定义。
+- 若命中多个定义,必须把候选项列给用户选择,不要自行猜测。
+- 若目标定义 `is_external=true`,优先返回 `create_link`,说明这是三方定义,不能走原生 `instances.create`。
+- 只有 `is_external=false` 的原生定义才继续下一步。
+
+### 2. 获取审批定义详情
+
+拿到 `approval_code` 后,读取定义详情:
+
+```bash
+lark-cli schema approval.approvals.get
+lark-cli approval approvals get \
+ --params '{"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85"}'
+```
+
+重点关注返回:
+
+- `approval_name`: 当前发起的是哪个审批定义。
+- `form`: 表单定义快照,用于识别控件 `id`、`type`、选项值范围以及明细子控件结构;不是创建实例时可直接原样提交的 payload 模板。
+- `node_list`: 流程节点信息,是后续 `node_approver_list` / `node_cc_list` 的唯一可靠来源。
+
+### 3. 组装 `form`
+
+`instances.create.data.form` 是一个 JSON 数组字符串。组装原则:
+
+- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按 `schema` / `meta` 与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
+- 提交时必须至少保证每个控件的 `id`、`type` 与 `value` 符合当前 `schema` 要求;不要假设定义快照里出现的其他字段都能直接照搬。
+- 如果用户提供的是人员信息,优先转换成 `open_id` 后再写入对应控件。
+- 单选/多选控件提交的是选项 `value`,该值可从 `approvals.get.form` 的选项定义中取得。
+- `contact`、`department`、`fieldList`、`dateInterval`、`amount`、`telephone`、`document` 等控件的 `value` 结构各不相同,必须按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 单独组装,不要套用文本控件的写法。
+- 值本身从哪里拿,优先按 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 处理;不要把“知道结构”误当成“已经拿到可提交值”。
+- 若 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 标明某控件不支持通过创建实例 API 提交,则不要硬猜绕过;应明确告诉用户该定义当前无法仅通过 API 提单。
+- 若遇到当前 skill 未明确覆盖的复杂控件,不要硬猜;先依据 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 判断支持性与传值结构,再向用户确认。
+
+## API 不支持的控件
+
+根据 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md),创建审批实例 API 不支持的控件至少包括:
+
+- `text`
+- `mutableGroup`
+- `account`
+- `serialNumber`
+- `tripGroup`
+- `apaascorehrOnboardingGroup`
+- `apaascorehrRegularateGroup`
+- `remedyGroupV2`
+- `apaascorehrJobAdjustGroup`
+- `apaascorehrOffboardingGroup`
+
+如果目标审批定义包含上述控件,不要继续硬拼 `form`;应直接告诉用户该定义不能仅通过当前 API 完整提单。
+
+## 高频控件速查
+
+优先按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 组装,下面只保留最常用、最容易出错的格式:
+
+- `input` / `textarea`: `value` 是字符串
+- `date`: `value` 是 RFC3339 时间字符串
+- `dateInterval`: `value` 是对象,包含 `start` / `end` / `interval`
+- `radio` / `radioV2`: `value` 是单个选项值,取自定义详情里的 option.value;关联外部选项时传 `options.id`
+- `checkbox` / `checkboxV2`: `value` 是选项值数组
+- `number`: `value` 是数字
+- `amount`: `value` 是数字,还要带 `currency`
+- `formula`: `value` 必须与定义中的公式结果匹配,否则会报错
+- `contact`: 只推荐写 `open_ids`,由人员信息先转换成 `open_id`
+- `connect`: `value` 是关联审批实例 `instance_code` 数组,当前默认要求用户直接提供 `instance_code`
+- `document`: `value` 是对象,至少含 `token` 和 `type=docx`
+- `attachmentV2` / `image` / `imageV2`: `value` 是 file code 数组,当前默认要求用户直接提供
+- `fieldList`: `value` 是二维数组,子项继续按各自控件类型组装
+- `department`: `value` 是对象数组,元素字段名为 `open_id`,其值填写部门的 `open_department_id`
+- `telephone`: `value` 是对象,包含 `countryCode` 和 `nationalNumber`
+- `address`: `value` 是对象数组,至少包含地理库 `id`,可选 `detailAddress`;当前默认要求用户直接提供该 `id`
+
+## 特殊控件组
+
+[`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 还明确给出了若干特殊控件组的提单格式,至少包括:
+
+- `leaveGroupV2`
+- `workGroup`
+- `outGroup`
+- `shiftGroup`
+
+这类控件组不是简单文本控件,通常内部还嵌套 `radioV2`、`date`、`fieldList`、`image`、`contact` 等子控件。遇到这些控件组时:
+
+- 先从 `approvals.get.form` 找到控件组及其子控件 ID
+- 再严格按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 的示例组装 `value`
+- 不要把控件组整体当成普通字符串或扁平对象提交
+
+### 4. 组装节点参数
+
+从 `node_list` 推导节点参数:
+
+- 若某节点 `need_approver=true`,则必须在 `node_approver_list` 中补该节点的审批人。
+- `key` 优先取 `custom_node_id`;若不存在,再用 `node_id`。
+- `value` 是审批人 `open_id` 列表。
+- 若 `approver_chosen_multi=false`,该节点只允许一个审批人 `open_id`。
+- `node_cc_list` 仅在用户明确需要补充节点抄送人时才填写;其 `key/value` 规则与 `node_approver_list` 相同。
+
+### 5. 创建审批实例
+
+先看 `schema`,确认最终结构后再执行:
+
+```bash
+lark-cli schema approval.instances.create
+
+lark-cli approval instances create \
+ --data '{
+ "approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85",
+ "form":"[{\"id\":\"widget1\",\"type\":\"input\",\"value\":\"请假半天\"}]",
+ "node_approver_list":[
+ {
+ "key":"manager_node_id",
+ "value":["ou_xxx"]
+ }
+ ]
+ }' \
+ --yes
+```
+
+执行规则:
+
+- 执行前先向用户确认:目标审批定义、核心表单值、节点审批人/抄送人。
+- 若需要幂等,可补 `uuid`。
+- 成功后回报 `instance_code` 与 `instance_link`。
+
+## 组装时优先依据的资料
+
+优先级固定如下:
+
+1. `lark-cli schema approval.instances.create` 与对应 `meta`:决定创建请求体有哪些字段、节点参数怎么传。
+2. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md):决定每种控件的 `value` 结构与支持范围。
+3. [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md):决定每类值应该从哪里拿,以及当前哪些值必须由用户直接提供。
+4. `approvals.get.form`:提供当前审批定义里实际有哪些控件、控件 `id`、控件 `type`、选项值范围、明细子控件结构。
+5. `approvals.get.node_list`:提供节点 key 与是否需要补充审批人/抄送人的线索。
+
+不要反过来把 `approvals.get.form` 当成第一优先级,更不要把它当成可直接提交的 JSON 模板。
+
+## 最小判断表
+
+| 你手上有什么 | 下一步 |
+|---|---|
+| 只有口语需求,比如“帮我提个请假审批” | 先 `approvals.search` |
+| 已经拿到 `approval_code` | 直接 `approvals.get` |
+| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances.create` |
+| `is_external=true` | 返回 `create_link`,不要调 `instances.create` |
+
+## 返回结果
+
+完成创建后,至少向用户返回:
+
+- `approval_name`
+- `instance_code`
+- `instance_link`
diff --git a/skills/lark-approval/references/lark-approval-instance-form-control-parameters.md b/skills/lark-approval/references/lark-approval-instance-form-control-parameters.md
new file mode 100644
index 00000000..9ea936f3
--- /dev/null
+++ b/skills/lark-approval/references/lark-approval-instance-form-control-parameters.md
@@ -0,0 +1,606 @@
+# 审批实例表单控件参数
+
+> 说明:本文尽量保留上游参数文档的原始结构与示例,用于回答“控件 `value` 长什么样”。
+> 当前 `lark-cli` 的推荐取值口径以 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 为准;如果两份文档在“值从哪里拿”上存在差异,以后者为准。
+
+在调用创建审批实例接口时需要使用表单控件参数,你可以通过本文了解审批实例内各表单控件的参数说明。
+
+## 准备工作
+
+审批实例的表单控件参数依据审批定义表单来配置,例如,审批定义的表单设计包括了 **单行文本** 和 **日期区间** 控件,则审批实例的表单控件参数就需要为 **单行文本** 和 **日期区间** 控件进行赋值。因此,在操作审批实例表单的控件参数前,应先通过审批定义详情确认表单控件结构。
+
+## 审批实例 API 不支持的控件
+
+创建审批实例 API 未完全支持所有的审批表单控件,不支持的控件如下表所示。如果你必须使用 API 不支持的控件,则不能仅通过当前 API 完成提单。
+
+**控件/控件组** | **Type** |
+| ---------- | --------------------------- |
+| 说明 | text |
+| 引用多维表格 | mutableGroup |
+| 收款账户 | account |
+| 流水号 | serialNumber |
+| 出差控件组 | tripGroup |
+| 录用控件组 | apaascorehrOnboardingGroup |
+| 转正控件组 | apaascorehrRegularateGroup |
+| 补卡控件组 | remedyGroupV2 |
+| 调岗控件组 | apaascorehrJobAdjustGroup |
+| 离职控件组 | apaascorehrOffboardingGroup
+
+## 通用参数
+
+审批实例的表单控件均包含的参数如下表所示。
+
+参数 | 类型 | 是否必填 | 描述
+---|---|---|---
+id | string | 是 | 控件的 ID,需要与审批定义中的控件 ID 保持一致。
+type | string | 是 | 控件类型。各控件类型取值参见下文 **不同控件的参数** 章节。
+value | 不同控件的类型不同 | 是 | 控件的取值。不同控件 value 数据类型也不同,例如单行文本控件的 value 为字符串、联系人的 value 为数组。详情参见下文 **不同控件的参数** 章节。
+
+## 不同控件的参数
+
+本章节提供不同控件的 type 参数值、JSON 示例以及非通用参数说明。
+
+### 单行文本
+
+控件 type 为 input,JSON 数据示例:
+
+```json
+{
+ "id": "widget1",
+ "type": "input",
+ "value": "data" // string 类型
+}
+```
+
+### 多行文本
+
+控件 type 为 textarea,JSON 数据示例:
+
+```json
+{
+ "id": "widget1",
+ "type": "textarea",
+ "value": "data" // string 类型
+}
+```
+
+### 日期
+
+控件 type 为 date,JSON 数据示例:
+
+```json
+{
+ "id": "widget1",
+ "type": "date",
+ "value": "2019-10-01T08:12:01+08:00" // 需满足 RFC3339 格式的 string 类型
+}
+```
+
+### 日期区间
+
+控件 type 为 dateInterval,JSON 数据示例:
+
+```json
+{
+ "id": "widget1",
+ "type": "dateInterval",
+ "value": {
+ "start":"2019-10-01T08:12:01+08:00",
+ "end":"2019-10-02T08:12:01+08:00",
+ "interval": 1.0
+ }
+}
+```
+
+value 参数为 object 类型,包含参数说明:
+
+参数 | 类型 | 是否必填 | 描述
+---|---|---|---
+start | string | 是 | 开始时间,需满足 RFC3339 格式。
+end | string | 是 | 结束时间,需满足 RFC3339 格式。
+interval | float | 是 | 时长(天)。
+
+### 单选
+
+控件 type 为 radio/radioV2,JSON 数据示例:
+
+```json
+{
+ "id": "widget1",
+ "type": "radioV2",
+ "value": "k2b8mkx0-h71x5gl1234-1" // string 类型
+}
+```
+
+其中, value 表示选项值,取值范围需要参考相应审批定义中 **单选** 控件 option 的 value 参数。你可以通过审批定义详情返回的 `form` 参数,获取单选控件 option 的 value 取值。如果控件关联了外部选项,则 value 需要传入外部选项的 `options.id`。
+
+### 多选
+
+控件 type 为 checkbox/checkboxV2,JSON数据示例:
+
+```json
+{
+ "id":"widget1",
+ "type":"checkboxV2",
+ "value": ["k2b8mkx0-h71x5gl4321-1"] // string 类型的数组
+}
+```
+其中, value 表示选项值,取值范围需要参考相应审批定义中 **多选** 控件 option 的 value 参数。你可以通过审批定义详情返回的 `form` 参数,获取多选控件 option 的 value 取值。如果控件关联了外部选项,则 value 需要传入外部选项的 `options.id`。
+
+### 数字
+
+控件 type 为 number,JSON 数据示例:
+
+```json
+{
+ "id": "widget1",
+ "type": "number",
+ "value": 1234.5678 // float 类型
+}
+```
+
+### 金额
+
+控件 type 为 amount,JSON 数据示例:
+
+```json
+{
+ "id": "widget1",
+ "type": "amount",
+ "value": 1234.5678, // float 类型
+ "currency":"USD"
+}
+```
+
+其中,currency 表示货币种类,取值范围需要参考相应审批定义中 **金额** 控件的 value 参数。你可以通过审批定义详情返回的 `form` 参数,获取金额控件可设置的货币种类。
+
+### 计算公式
+
+控件 type 为 formula,JSON 数据示例:
+
+```json
+{
+ "id": "widget1",
+ "type": "formula",
+ "value": 1234.5678 // 该值由审批定义内配置的公式计算出取值,若不匹配则返回报错。
+}
+```
+
+### 联系人
+
+控件 type 为 contact,JSON 数据示例:
+
+```json
+{
+ "id":"widget1",
+ "type":"contact",
+ "value": ["f8ca557e"], // string 类型的数组
+ "open_ids": ["ou_12345"] // string 类型的数组
+}
+```
+其中,value 包含的是用户 `user_id`;open_ids 包含的是用户 `open_id`。
+
+### 关联审批
+
+控件 type 为 connect,JSON 数据示例:
+
+```json
+{
+ "id":"widget1",
+ "type":"connect",
+ "value": ["19EAC829-F1CB-527F-BE2A-1330422E60C0"] // string 类型的数组
+}
+```
+其中,value 包含的是被关联的审批实例 Code,你可以通过审批实例详情能力根据实例 Code 获取实例详情。
+
+### 文档控件
+
+控件 type 为 document,JSON 数据示例:
+
+```json
+{
+ "id": "widget1",
+ "type": "document",
+ "value": {
+ "token":"TLLKdcpDro9ijQxA33ycNMabcef",
+ "type":"docx",
+ }
+}
+```
+
+value 参数为 object 类型,包含参数说明:
+
+参数 | 类型 | 是否必填 | 描述
+---|---|---|---
+token | string | 是 | 文档的 document_id。
+type | string | 是 | 文档类型,支持 `docx`。
+
+### 附件
+
+控件 type 为 attachmentV2,JSON 数据示例:
+
+```json
+{
+ "id":"widget1",
+ "type":"attachmentV2",
+ "value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"] // string 类型的数组
+}
+```
+其中,value 包含的是上传文件后返回的文件 code。
+
+### 图片
+
+控件 type 为 image/imageV2,JSON 数据示例:
+
+```json
+{
+ "id":"widget1",
+ "type":"image",
+ "value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"] // string 类型的数组
+}
+```
+
+其中,value 包含的是上传文件后返回的文件 code。
+
+### 明细/表格
+
+控件 type 为 fieldList,JSON 格式示例:
+
+```json
+{
+ "id": "widget1",
+ "type": "fieldList",
+ "value": [
+ [
+ {
+ "id": "widget1",
+ "type": "checkbox",
+ "value": ["jxpsebqp-0"]
+ }
+ ]
+ ]
+}
+```
+
+其中 value 是二维数组,根据审批定义内 **明细/表格** 控件所包含的控件,依次设置控件 JSON 值。
+
+### 部门
+
+控件 type 为 department,JSON 数据示例:
+
+```json
+{
+ "id":"widget1",
+ "type":"department",
+ "value":[
+ {
+ "open_id": "od-xxx"
+ }
+ ]
+}
+```
+
+其中 value 为对象数组,通过 open_id 设置部门的 open_department_id。
+
+### 电话
+
+控件 type 为 telephone,JSON 数据示例:
+
+```json
+{
+ "id":"widget1",
+ "type":"telephone",
+ "value": {
+ "countryCode":"+86",
+ "nationalNumber":"13122222222"
+ }
+}
+```
+
+value 参数为 object 类型,包含参数说明:
+
+参数 | 类型 | 是否必填 | 描述
+---|---|---|---
+countryCode | string | 是 | 区号。
+nationalNumber | string | 是 | 电话号。
+
+### 地址
+控件 type 为 address,JSON 数据示例:
+
+```json
+{
+ "id": "widget1",
+ "type": "address",
+ "value": [{
+ "id": "290557",
+ "detailAddress": "详细的地址"
+ }]
+}
+```
+
+value 参数为 []object 类型,参数说明如下:
+
+参数 | 类型 | 是否必填 | 描述
+---|---|---|---
+value | []object | 是 | 非出差控件组场景地址控件仅支持单个地址,传入多个时默认只取第一个
+└ id | string | 是 | 区域ID, 可通过审批的地理库接口获取
+└ detailAddress | string | 否 | 详细的地址,若表单配置中未开启填写详细地址,则会忽略该参数,即使传入也不会生效
+
+### 换班控件组
+
+控件 type 为 shiftGroup,JSON 数据示例:
+
+```json
+{
+ "id": "widget1",
+ "type": "shiftGroup",
+ "value": {
+ "shiftTime": "2019-10-01T08:12:01+08:00",
+ "returnTime": "2019-10-02T08:12:01+08:00",
+ "reason": "ask for leave"
+ }
+}
+```
+
+value 参数为 object 类型,包含参数说明:
+
+参数 | 类型 | 是否必填 | 描述
+---|---|---|---
+shiftTime | string | 是 | 换班时间,需满足 RFC3339 格式。
+returnTime | string | 是 | 对调日期,需满足 RFC3339 格式。
+reason | string | 是 | 换班原因。
+
+### 请假控件组
+
+**请假控件组请求示例**
+```json
+{
+ "id": "widgetLeaveGroupV2",
+ "type": "leaveGroupV2",
+ "value": [
+ {
+ "id": "widgetLeaveGroupType",
+ "type": "radioV2",
+ "value": "7488925543484620819"
+ },
+ {
+ "id": "widgetLeaveGroupStartTime",
+ "type": "date",
+ "value": "2025-08-25T11:30:00+08:00"
+ },
+ {
+ "id": "widgetLeaveGroupEndTime",
+ "type": "date",
+ "value": "2025-08-26T11:35:00+08:00"
+ },
+ {
+ "id": "widgetLeaveGroupReason",
+ "type": "textarea",
+ "value": "123123"
+ },
+ {
+ "id": "widgetLeaveCertification",
+ "type": "image",
+ "value": [
+ "B69F8E26-0EAA-4A92-9B80-DA613CD36136"
+ ]
+ },
+ {
+ "id":"widgetLeaveCertification",
+ "type":"image",
+ "value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"]
+ },
+ {
+ "id": "widgetLeaveGroupFeedingArrivingLate",
+ "type": "radioV2",
+ "value": "30"
+ },
+ {
+ "id": "widgetLeaveGroupFeedingOffLeaveEarly",
+ "type": "radioV2",
+ "value": "30"
+ }
+ ]
+}
+```
+
+**请假控件组包含参数说明:**
+
+id | 类型 | JSON示例 | 描述
+---|---|---|---
+id | string | 是 | 控件组ID,固定为widgetLeaveGroupV2
+type | string | 是 | 控件组类型,固定为leaveGroupV2
+value | object[] | 是 | 控件组的值,值为多个子控件值的列表
+
+value中包含的子控件值说明:
+
+id | 类型 | JSON示例 | 描述
+---|---|---|---
+widgetLeaveGroupType | radioV2 | ``` { "id": "widgetLeaveGroupType", "type": "radioV2", "value": "7488925543484620819" } ``` | 假期类型,具体格式可参考单选控件,选项由假勤接口获取,提单时必须包含该控件
+widgetLeaveGroupStartTime | date | ``` { "id": "widgetLeaveGroupStartTime", "type": "date", "value": "2019-10-01T08:12:01+08:00", // 需满足 RFC3339 格式的 string 类型 } ``` | 请假开始时间,具体格式可参考日期控件,会根据假期类型自动取整,其中半天假小于12点则认为是上午,小时假则以半小时为粒度向前取整, 提单时必须包含该控件
+widgetLeaveGroupEndTime | date | ``` { "id": "widgetLeaveGroupEndTime", "type": "date", "value": "2019-10-01T08:12:01+08:00", // 需满足 RFC3339 格式的 string 类型 } ``` | 请假结束时间,具体格式可参考日期控件,会根据假期类型自动取整,其中半天假小于12点则认为是上午,小时假则以半小时为粒度向后取整
+widgetLeaveGroupReason | textarea | ``` { "id": "widgetLeaveGroupReason", "type": "textarea", "value": "123123" } ``` | 请假事由,具体格式可参考多行文本控件,哺乳假无需填写,其他情况则根据控件组配置中该控件是否可见以及必填判断
+widgetLeaveCertification | image | ``` { "id":"widgetLeaveCertification", "type":"image", "value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"] } ``` | 请假证明,具体格式可参考图片控件,如果所选假期类型配置要求补充证明则必须传递该值,缺失会报错
+widgetLeaveGroupFeedingArrivingLate | radioV2 | ``` { "id": "widgetLeaveGroupFeedingArrivingLate", "type": "radioV2", "value": "30" } ``` | 上班晚到的分钟数,具体格式可参考单选控件,仅哺乳假需要填写,取值范围是0-120分钟,粒度是15分钟,选项从审批定义中该控件的option中获取
+widgetLeaveGroupFeedingOffLeaveEarly | radioV2 | ``` { "id": "widgetLeaveGroupFeedingOffLeaveEarly", "type": "radioV2", "value": "30" } ``` | 下班早走的分钟数,具体格式可参考单选控件,仅哺乳假需要填写,取值范围是0-120分钟,粒度是15分钟,选项即是分钟对应的字符串
+
+**特殊的参数校验报错信息**
+message | 说明 |
+| -------------------------------------------------- | ---------------------------- |
+| leave type id parse error | 请假类型不是int64 |
+| group value is invalid | 当前控件组的值无效,请校验是否为空或者校验类型是否为数组 |
+| start time format is not RFC3339 | 开始时间日期格式非*RFC3339格式* |
+| end time format is not RFC3339 | 结束时间日期格式非*RFC3339格式* |
+| start time is after end time | 开始时间晚于结束时间 |
+| user not in gray | 申请用户不在假勤灰度内 |
+| leave type not found | 请假类型不存在 |
+| reason is required | 请假原因未填写 |
+| leave quote should be bigger than 0 | 请假时长需要大于0 |
+| leave is conflict | 所选时间内已有请假记录,请选择其他时间 |
+| balance is not enough | 当前假期类型下假期余额不足 |
+| certification is required | 需要上传请假证明 |
+| arriving late is required | 哺乳假需要填写上班晚到时长 |
+| arriving late value is not in the optional items | 晚到时间不在可选范围内 |
+| leaving early is required | 哺乳假需要填写下班提前时长 |
+| leaving early value is not in the optional items | 下班提前时间不在可选范围内 |
+| feeding rest daily is 0 | 哺乳假每日休息时长为0,请重新选择 |
+| the operation is prohibited by the workforce rules | 当前账户已在假勤侧封账,无法提交
+
+### 加班控件组
+
+**加班控件组请求示例**
+```json
+{
+ "id": "widgetWorkGroup",
+ "type": "workGroup",
+ "value":[
+ {
+ "id":"widgetWorkGroupOvertimeWorkers",
+ "type":"contact",
+ "value": ["f8ca557e"],
+ "open_ids": ["ou_12345"]
+ },
+ {
+ "id": "widgetWorkGroupType",
+ "type": "radioV2",
+ "value": "7259635026038505475"
+ },
+ {
+ "id":"widgetWorkGroupTimeRangeFieldList",
+ "type":"fieldList",
+ "value":[
+ [
+ {
+ "id":"widgetWorkGroupStartTime",
+ "type":"date",
+ "value":"2019-10-01T08:12:01+08:00"
+ },
+ {
+ "id":"widgetWorkGroupEndTime",
+ "type":"date",
+ "value":"2019-10-01T08:12:01+08:00"
+ }
+ ]
+ ]
+ },
+ {
+ "id": "widgetWorkGroupReason",
+ "type": "textarea",
+ "value": "111"
+ }
+ ]
+}
+
+```
+
+**加班控件组参数说明:**
+
+参数 | 类型 | 是否必填 | 描述
+---|---|---|---
+id | string | 是 | 控件组ID,固定为widgetWorkGroup
+type | string | 是 | 控件组类型,固定为workGroup
+value | object[] | 是 | 控件组的值,值为多个子控件值的列表
+
+value中包含的子控件值说明:
+
+id | 类型 | JSON示例 | 描述
+---|---|---|---
+widgetWorkGroupOvertimeWorkers | contact | ``` { "id":"widgetWorkGroupOvertimeWorkers", "type":"contact", "value": ["f8ca557e"], "open_ids": ["ou_12345"] } ``` | 加班人员列表,具体格式可参考联系人控件,如果定义中配置「允许代多人提交」则该字段必填,如果是提交人给自己提交需填写提交人的ID
+widgetWorkGroupType | radioV2 | ``` { "id": "widgetWorkGroupType", "type": "radioV2", "value": "7259635026038505475" // 对应的类型选项ID } ``` | 加班类型,具体格式可参考单选控件,如果定义中关闭「关联加班规则」则需要填写该字段
+widgetWorkGroupTimeRangeFieldList | fieldList | ``` { "id":"widgetWorkGroupTimeRangeFieldList", "type":"fieldList", "value":[ [ { "id":"widgetWorkGroupStartTime", "type":"date", "value":"2019-10-01T08:12:01+08:00" }, { "id":"widgetWorkGroupEndTime", "type":"date", "value":"2019-10-01T08:12:01+08:00" } ] ] } ``` | 加班时段,具体格式可参考明细控件,如果定义中打开「允许提交多个加班时段」则可以传多个,最多支持30个,否则只会取第一个,单次加班时长不可超过两天
+widgetWorkGroupReason | textarea | ``` { "id": "widgetWorkGroupReason", "type": "textarea", "value": "111" } ``` | 加班事由,如果定义中配置了「加班事由」必填,则必须填写该字段
+
+**特殊的参数校验报错信息**
+message | 说明 |
+| ---------------------------------------------------------------------------------- | ---------------------------- |
+| the time range list has more than 30 items | 加班时段数量超过30 |
+| group value is invalid | 当前控件组的值无效,请校验是否为空或者校验类型是否为数组 |
+| overtime type is required | 未关联加班规则时,加班类型必填 |
+| work time range is required | 至少需要一个加班时段 |
+| start time is after end time | 开始时间晚于结束时间 |
+| start time or end time of range is required | 加班时间段的开始时间和结束时间必填 |
+| overtime duration is over 2 days | 单次加班时长不可超过两天 |
+| overtime date time zone not support | 加班时段的日期时区信息无法识别 |
+| {date} can not apply overtime | 所选时间不可申请加班 |
+| {date} already apply overtime | 所选时间已经有加班记录 |
+| {date} no need approval | 所选日期加班无需申请 |
+| apply reason is required | 定义中设置了加班事由为必填,不可为空 |
+| {users} user follow different overtime rules, cannot be submitted in the same form | 所选加班人不在同一个考勤组内,无法同时提交加班 |
+| invalid overtime work application | 没有有效的加班申请,请重新选择加班日期 |
+| the overtime duration cannot be 0 | 加班时长不能是0 |
+| the number of apply workers cannot exceed 50 | 单次申请加班人数量不可大于50 |
+| apply worker is required | 必须有加班人,配置置可代多人提交时必须指定加班人 |
+| resigned worker can not apply | 离职人员不可申请加班 |
+| overtime duration is over limit | 加班时长超过限制
+
+### 外出控件组
+
+**外出控件组请求体示例**
+```json
+{
+ "id": "widgetOutGroup",
+ "type": "outGroup",
+ "value":[
+ {
+ "id": "widgetOutGroupType",
+ "type": "radioV2",
+ "value": "me15yqrf-gmjgbml2vhp-0"
+ },
+ {
+ "id": "widgetOutGroupStartTime",
+ "type": "date",
+ "value":"2019-10-01T08:12:01+08:00"
+ },
+ {
+ "id": "widgetOutGroupEndTime",
+ "type": "date",
+ "value":"2019-10-01T08:12:01+08:00"
+ },
+ {
+ "id": "widgetOutGroupReason",
+ "type": "textarea",
+ "value":"123213"
+ },
+ {
+ "id":"widgetOutGroupImage",
+ "type":"image",
+ "value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"]
+ }
+ ]
+}
+
+```
+
+**外出控件参数说明**
+
+参数 | 类型 | 是否必填 | 描述
+---|---|---|---
+id | string | 是 | 控件组ID,固定为widgetOutGroup
+type | string | 是 | 控件组Type,固定为outGroup
+value | object[] | 是 | 控件组的值,值为多个子控件值的列表
+
+value中包含的子控件值说明:
+
+id | 类型 | JSON示例 | 描述
+---|---|---|---
+widgetOutGroupType | radioV2 | ``` { "id": "widgetOutGroupType", "type": "radioV2", "value": "me15yqrf-gmjgbml2vhp-0" } ``` | 外出类型,具体格式可参考单选控件,如果配置了「外出类型」则必填,外出时长单位会选取所选外出类型关联的单位,如果没有配置「外出类型」,则该字段无需填写,计算外出时长时会选取「外出时长」配置的单位
+widgetOutGroupStartTime | date | ``` { "id": "widgetOutGroupStartTime", "type": "date", "value":"2019-10-01T08:12:01+08:00" } ``` | 外出开始时间,具体格式可参考日期控件,如果外出时长单位是半天假,则小于12点则认为是上午,否则认为是下午;如果单位是小时,则会按半小时的粒度向前取整
+widgetOutGroupEndTime | date | ``` { "id": "widgetOutGroupEndTime", "type": "date", "value":"2019-10-01T08:12:01+08:00" } ``` | 外出结束时间,具体格式可参考日期控件,如果外出时长单位是半天假,则小于12点则认为是上午,否则认为是下午;如果单位是小时,则会按半小时的粒度向后取整
+widgetOutGroupReason | textarea | ``` { "id": "widgetOutGroupReason", "type": "textarea", "value":"123213" } ``` | 外出事由,具体格式可参考多行文本控件,如果定义中「外出事由」必填,则必须填写该控件,如果定义配置无需填写,则无需填写该控件
+widgetOutGroupImage | image | ``` { "id":"widgetOutGroupImage", "type":"image", "value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"] } ``` | 外出证明,具体格式可参考图片控件,如果定义中「外出拍照」必填,则必须填写该控件,如果定义配置无需填写,则无需填写该控件
+
+**特殊的参数校验报错信息**
+
+message | 说明 |
+| ----------------------------------------------------- | ---------------------------- |
+| group value is invalid | 当前控件组的值无效,请校验是否为空或者校验类型是否为数组 |
+| start time format is not RFC3339 | 开始时间日期格式非*RFC3339格式* |
+| end time format is not RFC3339 | 结束时间日期格式非*RFC3339格式* |
+| start time and end time must be in the same time zone | 开始时间与结束时间必须是同一时区 |
+| out type is required | 如果定义中设定了「外出类型」,则外出类型必填 |
+| out start time is required | 外出开始时间必填 |
+| out end time is required | 外出结束时间必填 |
+| out duration must be greater than 0 | 外出间隔不能为0,请检查起止时间并重新选择 |
+| out reason is empty | 如果定义中勾选「外出事由」同时设定必填,则该字段必填 |
+| photo is required | 如果定义中勾选「外出拍照」同时设定必填,则该字段必填 |
+| out time is conflict | 外出时间有冲突,请确认是否已在该时段申请外出
diff --git a/skills/lark-approval/references/lark-approval-instance-value-sourcing.md b/skills/lark-approval/references/lark-approval-instance-value-sourcing.md
new file mode 100644
index 00000000..30d7d9c3
--- /dev/null
+++ b/skills/lark-approval/references/lark-approval-instance-value-sourcing.md
@@ -0,0 +1,108 @@
+# 审批提单值来源
+
+## 目的
+
+本文用于回答一个固定问题:在调用 `approval instances create` 发起原生审批实例时,**每个要填写的值从哪里拿**。
+
+阅读顺序固定如下:
+
+1. `lark-cli schema approval.instances.create`
+2. `approval approvals get` 返回的 `form` / `node_list`
+3. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)
+4. 本文
+
+## 总原则
+
+- `schema` / `meta` 决定请求字段名、字段层级、节点参数结构。
+- `approvals.get.form` 决定控件 `id`、`type`、选项值范围、子控件结构。
+- `approvals.get.node_list` 决定节点 key、是否必须补审批人、是否允许多人。
+- [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 决定各控件 `value` 的最终结构。
+- 除非本文明确允许,否则不要猜值来源,不要把展示文案直接当成可提交值。
+
+## 默认来源
+
+- 审批定义、`approval_code`、`is_external`、`create_link` 等基础信息,默认从 `approval approvals search` 获取。
+- 控件 `id`、`type`、选项值、子控件结构,默认从 `approval approvals get.form` 获取。
+- 节点 key、`need_approver`、`approver_chosen_multi` 等节点信息,默认从 `approval approvals get.node_list` 获取。
+- 本文只补充 **这些默认来源之外** 的取值规则,以及当前必须由用户直接提供的值。
+
+## 控件值来源规则
+
+### 联系人 `contact`
+
+- 只推荐写 `open_ids`。
+- 不再推荐双写 `value(user_id)` + `open_ids`,避免复杂度继续上升。
+- 如果用户给的是姓名、邮箱或账号,先用 `lark-contact` 解析成 `open_id`。
+
+### 部门 `department`
+
+- 最优先:用户直接提供 `open_department_id`。
+- 若用户说“我的部门”或“张三的部门”,先用 `lark-contact` 查询对应人员信息,再取其所属部门里的 `open_department_id`。
+- 如果查到该人员只有一个部门,可直接使用。
+- 如果查到多个部门,不自动猜,必须让用户明确选一个,或直接输入 `open_department_id`。
+- 如果仍无法确定,则明确告知当前不支持自动决定部门值。
+
+### 附件 `attachmentV2`
+
+- 当前 `lark-approval` 不负责上传文件。
+- 用户必须直接提供 file code。
+- 如果用户无法提供 file code,应明确告知当前无法仅通过 `lark-approval` 完成该控件提单。
+
+### 图片 `image` / `imageV2`
+
+- 当前 `lark-approval` 不负责上传图片。
+- 用户必须直接提供 file code。
+- 如果用户无法提供 file code,应明确告知当前无法仅通过 `lark-approval` 完成该控件提单。
+
+### 文档 `document`
+
+- 用户可直接提供 `token` / `document_id`。
+- 如果用户给的是飞书文档链接,应先尝试从链接中提取 token。
+- 若链接提取失败,再要求用户手动输入 token。
+
+### 关联审批 `connect`
+
+- 用户直接提供目标审批实例的 `instance_code`。
+- 当前不默认做“搜索关联实例再反查 code”的自动流程。
+
+### 地址 `address`
+
+- 用户直接提供地理库 `id`。
+- 若用户无法提供该 `id`,当前不支持自动取值。
+
+## 特殊控件组
+
+以下控件组的结构仍按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 组装:
+
+- `leaveGroupV2`
+- `workGroup`
+- `outGroup`
+- `shiftGroup`
+
+补充规则:
+
+- 控件组自身和子控件的 `id` / `type` 从 `approval approvals get.form` 中识别。
+- 组内单选/多选或业务枚举值,优先从 `approval approvals get.form` 返回的选项结构中取。
+- 不要把控件组整体当成普通字符串或扁平对象提交。
+
+## 不支持自动准备的值
+
+以下值当前不建议由 `lark-approval` 自动准备:
+
+- 文件上传后的 file code
+- 图片上传后的 file code
+- 地址控件的地理库 `id`
+- 无法唯一确定的部门 `open_department_id`
+
+遇到这类值时,应明确告诉用户需要提供什么,而不是继续猜测。
+
+## 最小决策表
+
+| 场景 | 处理 |
+|---|---|
+| 用户说“找张三当审批人” | 用 `lark-contact` 解析张三,取 `open_id` |
+| 用户说“我的部门” | 先查当前用户部门;若多个部门,让用户选 |
+| 用户给了文档链接 | 先尝试提取 token |
+| 用户要填图片/附件 | 要求直接提供 file code |
+| 用户要填关联审批 | 要求直接提供 `instance_code` |
+| 用户要填地址 | 要求直接提供地理库 `id` |
diff --git a/skills/lark-calendar/SKILL.md b/skills/lark-calendar/SKILL.md
index 08e2c5f5..c5abe011 100644
--- a/skills/lark-calendar/SKILL.md
+++ b/skills/lark-calendar/SKILL.md
@@ -31,6 +31,8 @@ lark-cli calendar +agenda --as user
| Shortcut | 说明 |
|----------|------|
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
+| [`+search-event`](references/lark-calendar-search-event.md) | 按关键词、时间范围和参会人搜索日程, 仅返回 日程ID/主题/时间等信息,详情需走 `events get` |
+| [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息(meeting_id、meeting_note),日程开过视频会议才会有meeting_id |
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) |
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
@@ -53,6 +55,7 @@ lark-cli calendar +agenda --as user
- **全天日程(All-day Event)**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
- **会议室(Room)**:"room"不是"房间",是"会议室"。会议室是日程的一种参与人(resource attendee),不能脱离日程单独预定。
+- **日程会议 ID(Meeting ID)**:日程的历史视频会议 ID,在日程上开过视频会议才会有。
## 术语映射
@@ -64,6 +67,9 @@ lark-cli calendar +agenda --as user
|----------|--------|
| 查询过去的会议("昨天的会议""上周的会") | [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md)(会议数据含即时会议,仅查日程会遗漏) |
| 查询日历/日程或未来时间的会议 | 本 skill |
+| 按关键词搜索日程 | 本 skill(`+search-event`) |
+| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill(`+meeting`) |
+| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting` 取 `meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
## 任务类型分流
@@ -115,7 +121,6 @@ lark-cli calendar [flags]
- `get` — 获取日程
- `instance_view` — 查询日程视图
- `patch` — 更新日程
- - `search_event` — 搜索日程(仅返回 日程ID/主题/时间,详情需走 `events get`)
- `share_info` — 获取日程分享链接
### freebusys
diff --git a/skills/lark-calendar/references/lark-calendar-meeting.md b/skills/lark-calendar/references/lark-calendar-meeting.md
new file mode 100644
index 00000000..9dfac260
--- /dev/null
+++ b/skills/lark-calendar/references/lark-calendar-meeting.md
@@ -0,0 +1,40 @@
+
+# calendar +meeting
+
+通过日程 ID(`event_id`) 获取关联的视频会议信息(`meeting_id`、`meeting_note`)。只读。
+
+## 命令
+
+```bash
+# 单个 / 批量(逗号分隔,最多 50 个)
+lark-cli calendar +meeting --event-ids ,
+
+# 默认使用主日历,需要时显式传 --calendar-id
+lark-cli calendar +meeting --event-ids --calendar-id
+```
+
+## 输出字段
+
+| 字段 | 说明 |
+|------|------|
+| `event_id` | 日程 ID |
+| `meeting_id` | 关联的视频会议 ID |
+| `meeting_note` | 用户主动绑定到日程的纪要文档 Token(`MeetingNotes`,由用户在日程页手动添加;)。**与会中产生的 AI 智能纪要 `note_doc_token` 是两份不同文档**,要拿 AI 纪要请继续走 `vc +detail` → `note +detail`。 |
+
+## 下游链路
+
+`calendar +meeting` 只把日程 ID 翻译为 `meeting_id` / `meeting_note`,要拿会中产生的产物(AI 智能纪要、逐字稿、妙记)需继续调用:
+
+```bash
+# 1. meeting_id → note_id + minute_token(同一会议两份产物,可能各自为空)
+lark-cli vc +detail --meeting-ids
+
+# 2a. note_id → 纪要文档 token(note_doc_token / verbatim_doc_token / shared_doc_tokens)
+lark-cli note +detail --note-id
+
+# 2b. minute_token → 妙记 AI 产物(按需获取,不传不返回任何 AI 内容)
+lark-cli minutes +detail --minute-tokens --summary --todo --chapter --keyword --transcript
+
+# 3. 任意文档 token(meeting_note / note_doc_token / verbatim_doc_token / shared_doc_token)→ 正文
+lark-cli docs +fetch --api-version v2 --doc --doc-format markdown
+```
\ No newline at end of file
diff --git a/skills/lark-calendar/references/lark-calendar-schedule-meeting.md b/skills/lark-calendar/references/lark-calendar-schedule-meeting.md
index 06eb4a66..57187ce9 100644
--- a/skills/lark-calendar/references/lark-calendar-schedule-meeting.md
+++ b/skills/lark-calendar/references/lark-calendar-schedule-meeting.md
@@ -75,7 +75,7 @@
定位规则:
-- 优先利用用户给出的标题、日期、时间范围、`这个日程/这场会` 等锚点,通过 `+agenda`、`events search_event` 或实例视图缩小范围。
+- 优先利用用户给出的标题、日期、时间范围、`这个日程/这场会` 等锚点,通过 `+agenda`、`+search-event` 或实例视图缩小范围。
- 如果命中多个候选日程,必须向用户展示候选项并要求确认,禁止自行猜测。
- 如果是重复性日程的某一次实例,必须继续定位到该次实例的 `event_id`。
diff --git a/skills/lark-calendar/references/lark-calendar-search-event.md b/skills/lark-calendar/references/lark-calendar-search-event.md
new file mode 100644
index 00000000..e2addb5c
--- /dev/null
+++ b/skills/lark-calendar/references/lark-calendar-search-event.md
@@ -0,0 +1,29 @@
+
+# calendar +search-event
+
+按关键词、时间范围和参会人搜索日历日程。只读。
+
+## 命令
+
+```bash
+# 按关键词
+lark-cli calendar +search-event --query "周会"
+
+# 按时间范围(ISO 8601 或 YYYY-MM-DD)
+lark-cli calendar +search-event --start "2026-04-20T00:00:00+08:00" --end "2026-04-27T23:59:59+08:00"
+
+# 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)
+lark-cli calendar +search-event --attendee-ids "ou_user1,oc_chat1,omm_room1"
+
+# 组合
+lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1"
+```
+
+## 输出字段
+
+`items` 列表每条返回 `event_id` / `summary` / `start` / `end` / `is_all_day` / `app_link`;外层有 `has_more`、`page_token`。**仅返回基础字段,要拿日程详情用 `calendar events get`。**
+
+## 注意事项
+
+- 分页:`has_more=true` 时持续用 `page_token` 翻页直到 false,不要遗漏;`page-size` 最大 30。
+- 已结束的会议优先用 `vc +search`——日历不收录"即时会议",只查日程会漏。
diff --git a/skills/lark-calendar/references/lark-calendar-update.md b/skills/lark-calendar/references/lark-calendar-update.md
index 4fdd8db1..834ed9ac 100644
--- a/skills/lark-calendar/references/lark-calendar-update.md
+++ b/skills/lark-calendar/references/lark-calendar-update.md
@@ -65,7 +65,7 @@ lark-cli calendar +update \
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids` 或 `--remove-attendee-ids`。
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`。
- 会议室是 resource attendee,必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
-- 更新重复性日程的某一次实例时,必须先通过 `+agenda`、`events search_event` 或实例视图定位该实例的 `event_id`。
+- 更新重复性日程的某一次实例时,必须先通过 `+agenda`、`+search-event` 或实例视图定位该实例的 `event_id`。
- 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md
index 8d726e98..de803512 100644
--- a/skills/lark-doc/SKILL.md
+++ b/skills/lark-doc/SKILL.md
@@ -1,33 +1,30 @@
---
name: lark-doc
version: 2.0.0
-description: "飞书云文档(Docx / Wiki 文档,v2 API):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。"
+description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。"
metadata:
requires:
bins: ["lark-cli"]
- cliHelp: "lark-cli docs --api-version v2 --help; lark-cli docs +create --api-version v2 --help; lark-cli docs +fetch --api-version v2 --help; lark-cli docs +update --api-version v2 --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help"
+ cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help"
---
-# docs (v2)
+# docs
**身份:文档操作默认使用 `--as user`。首次使用前执行 `lark-cli auth login`。**
-> **CRITICAL — API 版本:本 skill 使用 v2 API。执行 `docs +create`、`docs +fetch`、`docs +update` 时必须显式传入 `--api-version v2`。**
-
```bash
# 常用示例
-lark-cli docs +fetch --api-version v2 --doc "文档URL或token"
-lark-cli docs +create --api-version v2 --content '标题 内容 '
-lark-cli docs +update --api-version v2 --doc "文档URL或token" --command append --content '内容 '
+lark-cli docs +fetch --doc "文档URL或token"
+lark-cli docs +create --content '标题 内容 '
+lark-cli docs +update --doc "文档URL或token" --command append --content '内容 '
```
## 前置条件 — 执行操作前必读
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
-2. **读取文档(`docs +fetch --api-version v2`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`` / `` 输出结构)
-3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
-4. **需要使用 callout、grid、table、whiteboard 等富 block 时** → 参考 [`lark-doc-style.md`](references/style/lark-doc-style.md) 的元素能力说明。该文件不是固定模板或强制排版规范;除非用户明确要求美化、重排版或特定风格,不要为了“达标”主动套用固定结构。
+2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`` / `` 输出结构)
+3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和 [`lark-doc-style.md`](references/style/lark-doc-style.md)(元素选择、丰富度规则、颜色语义);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
@@ -58,7 +55,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
| `` | 同 `` | [`lark-sheets`](../lark-sheets/SKILL.md) |
| `` | 同 `` | [`lark-base`](../lark-base/SKILL.md) |
| `` | `vc-node-id` -> note_id | [`lark-note`](../lark-note/SKILL.md):先 `note +detail --note-id ` |
-| `` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch --api-version v2` 读取 src-token 文档,定位 block |
+| `` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch` 读取 src-token 文档,定位 block |
## Shortcuts(推荐优先使用)
@@ -67,7 +64,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs + [flags]`)
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
-| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown) |
+| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
diff --git a/skills/lark-doc/references/lark-doc-create.md b/skills/lark-doc/references/lark-doc-create.md
index f0f6e548..17fa6bc2 100644
--- a/skills/lark-doc/references/lark-doc-create.md
+++ b/skills/lark-doc/references/lark-doc-create.md
@@ -2,9 +2,8 @@
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
-> 2. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
->
-> **需要富 block 或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。**
+> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
+> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
>
> **未读完以上文件就生成内容会导致格式错误。**
@@ -16,16 +15,10 @@
```bash
# 创建 XML 文档(默认格式,推荐)
-lark-cli docs +create --api-version v2 --content '项目计划 目标 '
+lark-cli docs +create --content '项目计划 目标 记录本周重点。 '
-# 创建到指定文件夹(XML)
-lark-cli docs +create --api-version v2 --parent-token fldcnXXXX --content '标题 首段内容 '
-
-# 创建到个人知识库(XML)
-lark-cli docs +create --api-version v2 --parent-position my_library --content '标题 内容 '
-
-# 仅当用户明确要求时才使用 Markdown;文档标题必须是开头唯一的一级标题,正文从二级标题开始
-lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项目计划\n\n## 目标\n\n- 目标 1\n- 目标 2'
+# 仅当用户明确要求导入 Markdown 时才使用;文档标题用 --title,正文标题按内容自然组织
+lark-cli docs +create --doc-format markdown --title "项目计划" --content $'## 目标\n\n- 明确重点\n- 记录待办'
```
## 返回值
@@ -36,9 +29,9 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
"identity": "user",
"data": {
"document": {
- "document_id": "doxcnXXXXXXXXXXXXXXXXXXX",
+ "document_id": "docx_token",
"revision_id": 1,
- "url": "https://xxx.feishu.cn/docx/doxcnXXXXXXXXXXXXXXXXXXX",
+ "url": "https://xxx.feishu.cn/docx/docx_token",
"new_blocks": [
{ "block_id": "blkcnXXXX", "block_type": "whiteboard", "block_token": "boardXXXX" }
]
@@ -65,16 +58,15 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
| 参数 | 必填 | 说明 |
| ------------------- | -- |---------------------------------------------|
-| `--api-version` | 是 | 固定传 `v2` |
-| `--content` | 是 | 文档内容(XML 或 Markdown 格式) |
+| `--title` | 否 | 文档标题,Markdown 导入时使用;XML 创建推荐在 `--content` 开头写 `... `;多个标题仅保留第一个并在 `warnings` / `degrade_details` 提示 |
+| `--content` | 视情况 | 文档内容(XML 或 Markdown 格式);不传 `--content` 时必须传 `--title` |
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
| `--parent-token` | 否 | 父文件夹或知识库节点 token(与 `--parent-position` 互斥) |
| `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) |
## 最佳实践
-- 文档标题从内容中自动提取:XML 使用 ``;Markdown 使用文档开头唯一的一级标题(`# 标题`),正文从 `##` 开始。不要在内容开头重复写标题,也不要在 Markdown 正文中使用多个一级标题。
-- **较长文档**:参考 [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) 先建骨架再分段写入;短文档可一次写完整内容。
+- **较长文档**:参考 [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) 先建骨架再分段写入;短文档可一次写完整内容
- **表达形式**:由用户目标和内容决定。需要结构化表达时可参考 [`lark-doc-style.md`](style/lark-doc-style.md),但不要默认套用固定开头、固定富 block 比例或固定图表
## 参考
diff --git a/skills/lark-doc/references/lark-doc-fetch.md b/skills/lark-doc/references/lark-doc-fetch.md
index 911a390a..b0ffc87e 100644
--- a/skills/lark-doc/references/lark-doc-fetch.md
+++ b/skills/lark-doc/references/lark-doc-fetch.md
@@ -5,27 +5,27 @@
```bash
# 获取文档(默认 XML,simple)
-lark-cli docs +fetch --api-version v2 --doc "https://xxx.feishu.cn/docx/Z1Fj...tnAc"
+lark-cli docs +fetch --doc "https://xxx.feishu.cn/docx/Z1Fj...tnAc"
# Markdown 格式
-lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --doc-format markdown
+lark-cli docs +fetch --doc Z1Fj...tnAc --doc-format markdown
# 带 block ID(用于后续 block 级更新)
-lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --detail with-ids
+lark-cli docs +fetch --doc Z1Fj...tnAc --detail with-ids
# 只拿目录
-lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --scope outline --max-depth 3
+lark-cli docs +fetch --doc Z1Fj...tnAc --scope outline --max-depth 3
# 按 block id 区间精读
-lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
+lark-cli docs +fetch --doc Z1Fj...tnAc \
--scope range --start-block-id blkA --end-block-id blkB --detail with-ids
# 读整个章节(以标题 id 为锚点,自动展开到下一个同级/更高级标题前)
-lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
+lark-cli docs +fetch --doc Z1Fj...tnAc \
--scope section --start-block-id <标题id> --detail with-ids
# 按关键词定位(多关键词用 | 分隔,任一命中即返回)
-lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
+lark-cli docs +fetch --doc Z1Fj...tnAc \
--scope keyword --keyword "部署|发布|上线"
```
@@ -91,15 +91,14 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
}
```
-`content` 的格式由 `--doc-format` 决定。设置 `--scope` 时会被 `` 包裹,详见上文"局部读取的输出结构"。
+`content` 的格式由 `--doc-format` 决定;`im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用。设置 `--scope` 时会被 `` 包裹,详见上文"局部读取的输出结构"。
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
-| `--api-version` | 是 | 固定传 `v2` |
| `--doc` | 是 | 文档 URL 或 token(支持 `/docx/` 和 `/wiki/`) |
-| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `text` |
+| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `im-markdown`(仅用于获取内容后在 `lark-im` 场景下使用) |
| `--detail` | 否 | `simple`(默认)\| `with-ids` \| `full` |
| `--revision-id` | 否 | 文档版本号,`-1` = 最新(默认) |
| `--scope` | 否 | `outline` \| `range` \| `keyword` \| `section`(省略 = 读整篇) |
@@ -128,7 +127,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
## 嵌入电子表格 / 多维表格
-返回中可能含 ``、``、``。内部数据无法通过 `docs +fetch --api-version v2` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。
+返回中可能含 ``、``、``。内部数据无法通过 `docs +fetch` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。
## 参考
diff --git a/skills/lark-doc/references/lark-doc-md.md b/skills/lark-doc/references/lark-doc-md.md
index 4d267e7b..b8ae2d0a 100644
--- a/skills/lark-doc/references/lark-doc-md.md
+++ b/skills/lark-doc/references/lark-doc-md.md
@@ -1,10 +1,6 @@
# Markdown 格式参考
-`docs +fetch --api-version v2` / `docs +create --api-version v2` / `docs +update --api-version v2` 使用 `--doc-format markdown` 时适用。
-
-## 创建文档标题
-
-使用 `docs +create --doc-format markdown` 创建文档时,文档标题必须写成内容开头唯一的一级标题:`# 标题`。正文标题从 `##` 开始,不要使用多个一级标题;否则标题可能无法被提取并显示为 `Untitled`。
+`docs +fetch` / `docs +create` / `docs +update` 使用 `--doc-format markdown` 时适用;fetch 的 `--doc-format im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用,不作为 create/update 写入格式。
## 转义规则
@@ -38,14 +34,14 @@
- `$...$` 数学公式内部,符号为 LaTeX 语法,不受 Markdown 转义影响
**导出已转义,不要反转义:**
-`docs +fetch --api-version v2 --doc-format markdown` 导出的内容中,特殊字符**已经被转义过了**(例如 `\[`、`\|`、`\\` 等)。这些 `\` 是有意义的——去掉会导致后续写入时字符被 Markdown 语法吞掉。**不要反转义或去掉 `\`。**
+`docs +fetch --doc-format markdown` 导出的内容中,特殊字符**已经被转义过了**(例如 `\[`、`\|`、`\\` 等)。这些 `\` 是有意义的——去掉会导致后续写入时字符被 Markdown 语法吞掉。**不要反转义或去掉 `\`。**
**写入时必须转义:**
使用 `docs +create` 或 `docs +update` 的 `--doc-format markdown` 写入内容时,字面文本中的特殊字符同样必须转义。`--pattern` 参数中也必须使用转义形式才能正确匹配。
**导出 → 更新 工作流示例:**
-1. `docs +fetch --api-version v2` 导出得到 `C:\\Users\\test\[1\]`
+1. `docs +fetch` 导出得到 `C:\\Users\\test\[1\]`
2. 用 `str_replace --pattern 'C:\\Users\\test\[1\]'` 匹配(直接使用导出的转义形式)
3. `--content` 中的替换内容也要保持转义:`C:\\Users\\prod\[2\]`
diff --git a/skills/lark-doc/references/lark-doc-media-insert.md b/skills/lark-doc/references/lark-doc-media-insert.md
index 9b557b70..ca541300 100644
--- a/skills/lark-doc/references/lark-doc-media-insert.md
+++ b/skills/lark-doc/references/lark-doc-media-insert.md
@@ -48,7 +48,7 @@ lark-cli docs +media-insert --doc doxcnXXX --from-clipboard
# 从本地文件插入
# 除了上传本地文件,还可以在 `docs +update` 时直接通过网络 URL 插入图片,无需先下载到本地:
-lark-cli docs +update --api-version v2 --doc "" --command block_insert_after \
+lark-cli docs +update --doc "" --command block_insert_after \
--block-id "目标 block_id" \
--content ' '
diff --git a/skills/lark-doc/references/lark-doc-update.md b/skills/lark-doc/references/lark-doc-update.md
index 537264de..62fc467a 100644
--- a/skills/lark-doc/references/lark-doc-update.md
+++ b/skills/lark-doc/references/lark-doc-update.md
@@ -3,9 +3,8 @@
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
-> 2. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
->
-> **需要富 block 或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。**
+> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
+> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
>
> **未读完以上文件就生成内容会导致格式错误。**
@@ -15,13 +14,12 @@
> - **局部精修**(`str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after`):优先使用 XML(默认)。XML 能稳定表达 block 结构和样式,精准编辑更可控;不要因为 Markdown 写起来更简单就自行切换。
> - **整段写入**(`append` / `overwrite`):XML 和 Markdown 都可以。用户提供 `.md` 本地文件或明确要求 Markdown 时直接用 Markdown;否则默认 XML。
>
-> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID,也无样式(颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --api-version v2 --detail with-ids` **配合 `--scope`(`outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后 `--content` 仍可用 Markdown,只是写入内容不带样式。
+> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID,也无样式(颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --detail with-ids` **配合 `--scope`(`outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后 `--content` 仍可用 Markdown,只是写入内容不带样式。
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
-| `--api-version` | 是 | 固定传 `v2` |
| `--doc` | 是 | 文档 URL 或 token |
| `--command` | 是 | 操作指令(见下方指令速查表) |
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
@@ -65,20 +63,20 @@
```bash
# 简单文本替换
-lark-cli docs +update --api-version v2 --doc "" --command str_replace \
+lark-cli docs +update --doc "" --command str_replace \
--pattern "张三" --content "李四"
# 替换为富文本(加粗 + 链接)
-lark-cli docs +update --api-version v2 --doc "" --command str_replace \
+lark-cli docs +update --doc "" --command str_replace \
--pattern "旧链接" --content '新链接 点击查看 '
# 仅当用户明确要求时才使用 Markdown
-lark-cli docs +update --api-version v2 --doc "" --command str_replace \
+lark-cli docs +update --doc "" --command str_replace \
--doc-format markdown --pattern "旧内容" --content "新内容"
# Markdown 模式下支持跨行匹配(--pattern 与 --content 都需要真实换行;"..."/'...' 里的 \n 是字面量)
# 多行内容推荐 heredoc 或 --content @file.md,避免 shell 转义踩坑
-lark-cli docs +update --api-version v2 --doc "" --command str_replace \
+lark-cli docs +update --doc "" --command str_replace \
--doc-format markdown \
--pattern "$(printf '## 旧标题\n\n第一段原文\n\n第二段原文')" \
--content - <<'EOF'
@@ -91,7 +89,7 @@ EOF
# Markdown 模式下使用 `前缀...后缀` 省略号匹配首尾特征明显的大段内容
# 下例会把「## 旧标题」到「结束语。」之间的所有内容整体替换
-lark-cli docs +update --api-version v2 --doc "" --command str_replace \
+lark-cli docs +update --doc "" --command str_replace \
--doc-format markdown \
--pattern "## 旧标题...结束语。" \
--content - <<'EOF'
@@ -103,14 +101,14 @@ lark-cli docs +update --api-version v2 --doc "" --command str_replace \
EOF
# 删除文本:--content 传空字符串即可
-lark-cli docs +update --api-version v2 --doc "" --command str_replace \
+lark-cli docs +update --doc "" --command str_replace \
--pattern "废弃的内容" --content ""
```
### block_insert_after — 在指定 block 之后插入
```bash
-lark-cli docs +update --api-version v2 --doc "" --command block_insert_after \
+lark-cli docs +update --doc "" --command block_insert_after \
--block-id "目标 block_id" \
--content '新章节 '
```
@@ -118,7 +116,7 @@ lark-cli docs +update --api-version v2 --doc "" --command block_insert_a
### block_replace — 替换指定 block
```bash
-lark-cli docs +update --api-version v2 --doc "" --command block_replace \
+lark-cli docs +update --doc "" --command block_replace \
--block-id "目标 block_id" \
--content '替换后的段落内容 '
```
@@ -127,14 +125,14 @@ lark-cli docs +update --api-version v2 --doc "" --command block_replace
```bash
# 删除多个块时用逗号 "," 分隔
-lark-cli docs +update --api-version v2 --doc "" --command block_delete \
+lark-cli docs +update --doc "" --command block_delete \
--block-id "block_id_1,block_id_2,block_id_3"
```
### overwrite — 全文覆盖
```bash
-lark-cli docs +update --api-version v2 --doc "" --command overwrite \
+lark-cli docs +update --doc "" --command overwrite \
--content '全新文档 概述 新的内容 '
```
@@ -143,7 +141,7 @@ lark-cli docs +update --api-version v2 --doc "" --command overwrite \
### append — 在文档末尾追加
```bash
-lark-cli docs +update --api-version v2 --doc "" --command append \
+lark-cli docs +update --doc "" --command append \
--content '新增章节 追加的内容 '
```
@@ -155,7 +153,7 @@ lark-cli docs +update --api-version v2 --doc "" --command append \
```bash
# 复制多个块(按顺序插入:anchor → a → b → c)
-lark-cli docs +update --api-version v2 --doc "" --command block_copy_insert_after \
+lark-cli docs +update --doc "" --command block_copy_insert_after \
--block-id "锚点 block_id" \
--src-block-ids "block_a,block_b,block_c"
```
@@ -166,7 +164,7 @@ lark-cli docs +update --api-version v2 --doc "" --command block_copy_ins
```bash
# 移动到页面末尾
-lark-cli docs +update --api-version v2 --doc "" --command block_move_after \
+lark-cli docs +update --doc "" --command block_move_after \
--block-id "-1表示末尾,page_id表示开头,blk" \
--src-block-ids "block_a,block_b"
```
@@ -204,7 +202,7 @@ lark-cli docs +update --api-version v2 --doc "" --command block_move_aft
1. **获取文档内容和 block ID**:
```bash
- lark-cli docs +fetch --api-version v2 --doc "" --detail with-ids
+ lark-cli docs +fetch --doc "" --detail with-ids
```
2. **定位目标 block**:从返回的 XML 中找到要修改的 block 及其 `id` 属性
@@ -212,11 +210,11 @@ lark-cli docs +update --api-version v2 --doc "" --command block_move_aft
3. **执行更新**:
```bash
# 替换特定 block
- lark-cli docs +update --api-version v2 --doc "" --command block_replace \
+ lark-cli docs +update --doc "" --command block_replace \
--block-id "blkcnXXXX" --content "新内容 "
# 在某 block 后插入
- lark-cli docs +update --api-version v2 --doc "" --command block_insert_after \
+ lark-cli docs +update --doc "" --command block_insert_after \
--block-id "blkcnXXXX" --content "追加的章节 "
```
@@ -225,13 +223,13 @@ lark-cli docs +update --api-version v2 --doc "" --command block_move_aft
不需要 block ID,直接匹配替换:
```bash
-lark-cli docs +update --api-version v2 --doc "" --command str_replace \
+lark-cli docs +update --doc "" --command str_replace \
--pattern "v1.0" --content "v2.0"
```
## 画板处理
-> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch --api-version v2` 取到 ``,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
+> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 ``,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节。
diff --git a/skills/lark-doc/references/lark-doc-whiteboard.md b/skills/lark-doc/references/lark-doc-whiteboard.md
index 1048cd95..6d16b74a 100644
--- a/skills/lark-doc/references/lark-doc-whiteboard.md
+++ b/skills/lark-doc/references/lark-doc-whiteboard.md
@@ -23,7 +23,7 @@
|-------------------------|-----------------------------------------------------------|
| 文档中需要思维导图、时序图、类图、饼图、甘特图 | 步骤 2A:使用 mermaid 插入图表 |
| 文档中需要插入其他图表/自定义图形 | 步骤 2B: 使用 SVG 插入图表 |
-| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3B |
+| 已有画板需要更新内容 | 先 `docs +fetch` 获取 `board_token`,跳至步骤 3B |
| 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 |
> [!IMPORTANT]
@@ -46,7 +46,7 @@ SubAgent 插入 SVG。
### 步骤 2B: SubAgent 使用 SVG 插入图表
-主 Agent 启动 SubAgent,让它用 `docs +create --api-version v2` / `docs +update --api-version v2` 插入:
+主 Agent 启动 SubAgent,让它用 `docs +create` / `docs +update` 插入:
```xml
diff --git a/skills/lark-doc/references/lark-doc-xml.md b/skills/lark-doc/references/lark-doc-xml.md
index 352619ec..7484f428 100644
--- a/skills/lark-doc/references/lark-doc-xml.md
+++ b/skills/lark-doc/references/lark-doc-xml.md
@@ -10,10 +10,6 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
| `` | 文档标题(每篇唯一)| `align` |
| `` | 待办项| `done="true"\|"false"` |
-## 创建文档标题
-
-使用 `docs +create` 创建 XML 文档时,文档标题必须写成 `标题 `,且每篇文档只写一个 ``。
-
## 容器标签
|标签|说明|关键属性|
|-|-|-|
diff --git a/skills/lark-doc/references/style/lark-doc-create-workflow.md b/skills/lark-doc/references/style/lark-doc-create-workflow.md
index 39a4d249..d845e91c 100644
--- a/skills/lark-doc/references/style/lark-doc-create-workflow.md
+++ b/skills/lark-doc/references/style/lark-doc-create-workflow.md
@@ -20,7 +20,7 @@
1. 分析用户需求:受众、目的、范围
2. 设计大纲:根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式;不要默认套固定章节、固定开头或固定富 block 配比
-3. `docs +create --api-version v2` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容
+3. `docs +create` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容
- ⚠️ 创建较长文档时,**不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。
- 完整内容留到步骤二,由各 Agent 用 `block_insert_after --block-id <章节标题 block_id>` 分段写入。
- ⚠️ **`@file` 路径限制**:`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理。
@@ -29,12 +29,12 @@
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
- 文档 token、负责的章节范围、用户目标、目标读者和已有风格线索
- - `lark-doc-xml.md` 的完整路径(Agent 须先读取);仅在需要使用富 block 或用户要求美化时提供 `lark-doc-style.md`
+ - `lark-doc-xml.md` 和 `lark-doc-style.md` 的完整路径(Agent 须先读取)
- 使用 `block_insert_after --block-id <章节标题 block_id>` 写入对应章节内容
### 步骤三:整合审查与画板识别(串行)
-5. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
+5. `docs +fetch --detail with-ids` 获取文档,审查整体效果
6. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
@@ -50,7 +50,7 @@
## Agent 子任务要求
-内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。只有在需要使用富 block 或用户要求美化时,才提供 `lark-doc-style.md` 路径。
+内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。
Mermaid 图由主 Agent 直接插入 `... `,无需 SubAgent。
diff --git a/skills/lark-doc/references/style/lark-doc-update-workflow.md b/skills/lark-doc/references/style/lark-doc-update-workflow.md
index 6ae8972a..9ff07c4d 100644
--- a/skills/lark-doc/references/style/lark-doc-update-workflow.md
+++ b/skills/lark-doc/references/style/lark-doc-update-workflow.md
@@ -19,10 +19,10 @@
### 步骤一:分析与画板识别(串行)
1. **选择读取范围**(节省上下文的关键):
- - 用户只改某一节 / 文档较大 → 先 `docs +fetch --api-version v2 --scope outline --max-depth 2` 拿目录,再 `docs +fetch --api-version v2 --scope section --start-block-id <目标标题id> --detail with-ids` 精读该节(`section` 会自动展开到下一个同级/更高级标题前,不用手动算结束 block id)
- - 需要精确跨节区间 → `docs +fetch --api-version v2 --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
- - 用户只给了模糊关键词 → `docs +fetch --api-version v2 --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
- - 用户明确要改整篇 → `docs +fetch --api-version v2 --detail with-ids`
+ - 用户只改某一节 / 文档较大 → 先 `docs +fetch --scope outline --max-depth 2` 拿目录,再 `docs +fetch --scope section --start-block-id <目标标题id> --detail with-ids` 精读该节(`section` 会自动展开到下一个同级/更高级标题前,不用手动算结束 block id)
+ - 需要精确跨节区间 → `docs +fetch --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
+ - 用户只给了模糊关键词 → `docs +fetch --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
+ - 用户明确要改整篇 → `docs +fetch --detail with-ids`
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope"
2. 系统性评估:用户想改什么、现有文档风格是什么、哪些内容需要保留、哪些问题影响理解
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段
@@ -44,7 +44,7 @@
## Agent 子任务要求
-内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。只有在需要使用富 block 或用户要求美化时,才提供 `lark-doc-style.md` 路径。
+内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。
Mermaid 图由主 Agent 直接插入 `... `,无需 SubAgent。
@@ -52,4 +52,4 @@ SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、
已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
-**上下文节省提示**:Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --api-version v2 --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。
+**上下文节省提示**:Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。
diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md
index 0afe985c..393e1381 100644
--- a/skills/lark-drive/SKILL.md
+++ b/skills/lark-drive/SKILL.md
@@ -69,7 +69,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
| 操作 | 需要的 Token | 说明 |
|------|-------------|------|
-| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
+| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id,`sheet` 使用 `!`,`slides` 使用 `!`;Base 只有记录局部评论,定位为 file_token(base_token) + `--block-id !!` |
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
@@ -144,6 +144,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`)
| [`+task_result`](references/lark-drive-task-result.md) | 查询 import/export/move/delete 等异步任务结果。 |
| [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical token;wiki URL 会自动解包到底层文档。 |
| [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 |
+| [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create,真实写入需要 `--yes`。 |
| [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 |
| [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 |
diff --git a/skills/lark-drive/references/lark-drive-add-comment.md b/skills/lark-drive/references/lark-drive-add-comment.md
index 8de1982c..17dc2d9f 100644
--- a/skills/lark-drive/references/lark-drive-add-comment.md
+++ b/skills/lark-drive/references/lark-drive-add-comment.md
@@ -35,7 +35,7 @@ lark-cli drive +add-comment \
--doc "" --type file \
--content '[{"type":"text","text":"请补充目录说明"}]'
-# 给 docx 文档的指定 block 添加局部评论(block_id 可通过 docs +fetch --api-version v2 --detail with-ids 获取)
+# 给 docx 文档的指定 block 添加局部评论(block_id 可通过 docs +fetch --detail with-ids 获取)
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/docx/" \
--block-id "" \
@@ -155,11 +155,11 @@ lark-cli drive +add-comment \
| `--type` | 裸 token 时必填 | 文档类型:`doc`、`docx`、`file`、`sheet`、`slides`、`bitable`、`base`;评论 Base 文档推荐传 `bitable`,`base` 仅作为兼容别名兜底。URL 输入时自动识别,无需传 |
| `--content` | 是 | `reply_elements` JSON 数组字符串。示例:`'[{"type":"text","text":"文本"},{"type":"mention_user","text":"ou_xxx"},{"type":"link","text":"https://example.com"}]'` |
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--block-id` 时也会默认走全文评论(仅适用于 doc/docx、白名单 Drive file,以及解析为这些类型的 wiki;不适用于 sheet、slides、Base / bitable) |
-| `--block-id` | 局部评论时必填 | 目标块 ID,可通过 `docs +fetch --api-version v2 --detail with-ids` 获取;sheet 用 `!`,slides 用 `!`,Base 用 `!! | | |