Compare commits

..

2 Commits

Author SHA1 Message Date
liuxin.0319
40a828cb5e test(slides): avoid credential-like history test text 2026-07-02 11:53:37 +08:00
liuxin.0319
c0a961dbc3 feat(slides): add history rollback shortcuts 2026-07-02 11:21:48 +08:00
217 changed files with 77437 additions and 13233 deletions

View File

@@ -2,28 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.64] - 2026-07-02
### Features
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
### Bug Fixes
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
- **cli**: Improve secure label error handling (#1707)
- **cli**: Reduce public content token false positives
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
- **doc**: Align word statistics compound tokens (#1706)
### Documentation
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
- **doc**: Support `reference_map` in docs (#1690)
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
## [v1.0.62] - 2026-07-01
### Features
@@ -1355,7 +1333,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60

View File

@@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) {
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
WithMissingScopes("mail:user_mailbox.message:send").
WithIdentity("user").
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send")
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
if got.Category != errs.CategoryAuthorization {
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
@@ -419,7 +419,7 @@ func TestBuilder_WireFormat(t *testing.T) {
WithHint("run lark-cli auth login --scope calendar:event:create").
WithMissingScopes("calendar:event:create").
WithIdentity("user").
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create")
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
buf, err := json.Marshal(e)
if err != nil {
@@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) {
"hint": "run lark-cli auth login --scope calendar:event:create",
"log_id": "20260520-0a1b2c3d",
"identity": "user",
"console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create",
"console_url": "https://open.feishu.cn/app/cli_xxx/auth",
"missing_scopes": []any{"calendar:event:create"},
}
for k, want := range wantFields {

View File

@@ -10,14 +10,12 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// ClassifyContext is the contextual data BuildAPIError uses to populate
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
// Brand through core.ParseBrand, so callers can pass a raw brand string without
// coupling this contract to core's brand enum.
// Identity is a plain string ("user" / "bot" / "") so this package does not
// depend on internal/core (which would create an import cycle).
type ClassifyContext struct {
Brand string // "feishu" | "lark" — drives console_url host
AppID string // placed in console_url
@@ -446,27 +444,28 @@ func extractMissingScopes(resp map[string]any) []string {
return out
}
// ConsoleURL composes the Feishu/Lark open-platform application-scope apply
// page URL (the official open-pages `/page/scope-apply` entry), suitable for
// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
// returns the page carrying only clientID; otherwise scopes are joined with
// commas in the `scopes` query parameter so the console can pre-select them.
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
// scopes list returns the bare /auth landing page; scopes are joined with
// commas in the `q` query parameter so the console can pre-select them.
//
// brand is "feishu" or "lark"; unknown values default to feishu.
func ConsoleURL(brand, appID string, scopes []string) string {
if appID == "" {
return ""
}
// QueryEscape both values — clientID and scopes both sit in the query
// string, and untrusted content must not be able to inject extra query
// parameters via `&`/`#`. The brand→host mapping is owned by core so the
// open-platform base URL stays a single source of truth.
base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
if len(scopes) == 0 {
return base
host := "open.feishu.cn"
if brand == "lark" {
host = "open.larksuite.com"
}
return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))
// PathEscape on appID — it sits in the URL path. QueryEscape on the
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
// content must not be able to inject extra query parameters via `&`/`#`.
pathID := url.PathEscape(appID)
if len(scopes) == 0 {
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
}
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
}
func intFromAny(v any) int {

View File

@@ -422,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
}
}
@@ -434,8 +434,8 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
}
}
@@ -485,35 +485,35 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
name: "ampersand in scope smuggles extra param",
appID: "cli_good",
scopes: []string{"scope&evil=injected"},
wantInURL: []string{"scopes=scope%26evil%3Dinjected"},
denyInURL: []string{"scopes=scope&evil=injected"},
wantInURL: []string{"q=scope%26evil%3Dinjected"},
denyInURL: []string{"q=scope&evil=injected"},
},
{
name: "hash in scope splits fragment",
appID: "cli_good",
scopes: []string{"scope#fragment"},
wantInURL: []string{"scopes=scope%23fragment"},
denyInURL: []string{"scopes=scope#fragment"},
wantInURL: []string{"q=scope%23fragment"},
denyInURL: []string{"q=scope#fragment"},
},
{
name: "question mark in appID prematurely opens query",
appID: "good?q=injected",
scopes: []string{"docx:document"},
wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
denyInURL: []string{"clientID=good?q=injected"},
wantInURL: []string{"/app/good%3Fq=injected/auth"},
denyInURL: []string{"/app/good?q=injected/auth"},
},
{
name: "hash in appID truncates URL",
appID: "good#fragment",
scopes: []string{"docx:document"},
wantInURL: []string{"clientID=good%23fragment"},
denyInURL: []string{"clientID=good#fragment"},
wantInURL: []string{"/app/good%23fragment/auth"},
denyInURL: []string{"/app/good#fragment/auth"},
},
{
name: "slash in appID does not open a new path segment",
name: "slash in appID escapes path segment",
appID: "good/extra/segment",
scopes: []string{"docx:document"},
wantInURL: []string{"clientID=good%2Fextra%2Fsegment"},
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
},
}
@@ -553,8 +553,8 @@ func TestPermissionError_NoViolations(t *testing.T) {
if pe.MissingScopes != nil {
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
}
if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") {
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL)
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
}
}
@@ -758,7 +758,7 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
// at the app level — re-authenticating cannot fix it. The hint must
// point to the developer console regardless of caller identity, or
// agents will loop on `auth login` forever.
consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact"
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact"
for _, identity := range []string{"user", "bot", ""} {
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
if !strings.Contains(got, "developer console") {

View File

@@ -10,20 +10,15 @@ import "github.com/larksuite/cli/errs"
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var driveCodeMeta = map[int]CodeMeta{
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
}
func init() { mergeCodeMeta(driveCodeMeta, "drive") }

View File

@@ -27,13 +27,6 @@ func TestLookupCodeMeta_DriveCodes(t *testing.T) {
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
// Secure label endpoint codes observed from drive +secure-label-update
// failure telemetry.
{1063001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{1063002, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
{1063013, errs.CategoryValidation, errs.SubtypeFailedPrecondition, false},
{99992402, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{9499, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {

View File

@@ -7,7 +7,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"math"
"path/filepath"
"sort"
"strings"
@@ -79,15 +78,12 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
}
for _, match := range credentialURLRE.FindAllString(line, -1) {
if isPlaceholderCredentialURL(file, match) {
if isPlaceholderCredentialURL(match) {
continue
}
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
}
for _, match := range privateIPv4RE.FindAllString(line, -1) {
if !warnForPrivateIPv4(file) {
continue
}
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
}
if source == "branch" && automationBranchRE.MatchString(line) {
@@ -134,9 +130,6 @@ func isCredentialAssignmentMatch(match string) bool {
if isBenignTokenField(name) && !credentialShapedValue(value) {
return false
}
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
return false
}
return isExplicitCredentialKey(name)
}
@@ -291,9 +284,6 @@ func tokenLikePlaceholderValue(key, value string) bool {
if normalized == "" || credentialShapedIdentifier(normalized) {
return false
}
if authCredentialTokenKey(key) {
return false
}
return resourceTokenPlaceholderValue(value) ||
maskedTokenFixturePlaceholderValue(key, normalized) ||
isPlaceholderValue(value) ||
@@ -323,109 +313,11 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
return stars >= 6 && alnum > 0
}
func isWeakTokenCredentialKey(key string) bool {
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
return false
}
return key == "token" ||
strings.HasSuffix(key, "_token") ||
strings.HasSuffix(key, "-token")
}
func isStrongTokenCredentialKey(key string) bool {
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
for _, phrase := range [][2]string{
{"access", "token"},
{"refresh", "token"},
{"auth", "token"},
{"bearer", "token"},
{"session", "token"},
{"service", "token"},
{"bot", "token"},
{"api", "token"},
{"secret", "token"},
} {
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
return true
}
}
return false
}
func weakTokenValueLooksCredentialLike(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
if normalized == "" ||
isNonSecretLiteralValue(value) ||
isPlaceholderValue(value) {
return false
}
candidate := unwrapCredentialValue(normalized)
return credentialShapedIdentifier(candidate) ||
highEntropyCredentialValue(candidate) ||
commandSubstitutionLooksCredentialLike(normalized) ||
(strings.Contains(normalized, "://") &&
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
}
func unwrapCredentialValue(value string) string {
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
}
value = strings.TrimPrefix(value, "$")
value = strings.Trim(value, "%")
return strings.TrimSpace(value)
}
func highEntropyCredentialValue(value string) bool {
if len(value) < 32 {
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 == '-' || r == '.' || r == '=':
default:
return false
}
}
return hasLetter && hasDigit && shannonEntropy(value) >= 3.5
}
func shannonEntropy(value string) float64 {
if value == "" {
return 0
}
counts := map[rune]int{}
for _, r := range value {
counts[r]++
}
var entropy float64
length := float64(len([]rune(value)))
for _, count := range counts {
p := float64(count) / length
entropy -= p * log2(p)
}
return entropy
}
func log2(value float64) float64 {
return math.Log(value) / math.Ln2
}
func authCredentialTokenKey(key string) bool {
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
case "access_token",
"api_token",
"bot_token",
"refresh_token",
"secret_token",
"session_token",
"service_token",
"bearer_token",
"auth_token",
"authorization_token",
@@ -952,7 +844,7 @@ func looksLikeEqualityComparison(value string) bool {
return strings.HasPrefix(strings.TrimSpace(value), "=")
}
func isPlaceholderCredentialURL(file, raw string) bool {
func isPlaceholderCredentialURL(raw string) bool {
userInfo, ok := credentialURLUserInfo(raw)
if !ok {
return false
@@ -961,8 +853,7 @@ func isPlaceholderCredentialURL(file, raw string) bool {
if !ok {
return false
}
return credentialURLPasswordPlaceholder(password) ||
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
return credentialURLPasswordPlaceholder(password)
}
func credentialURLPasswordPlaceholder(password string) bool {
@@ -976,46 +867,6 @@ func credentialURLPasswordPlaceholder(password string) bool {
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
}
func credentialURLPasswordFixture(password string) bool {
normalized := strings.ToLower(strings.Trim(password, `"'`))
switch normalized {
case "p",
"pass",
"password",
"pat_abc",
"pw",
"s3cret",
"secret",
"t":
return true
default:
return false
}
}
func sourceOrTestFixtureFile(file string) bool {
normalized := filepath.ToSlash(file)
return sourceCodeFile(normalized) ||
strings.HasPrefix(normalized, "testdata/") ||
strings.HasPrefix(normalized, "fixtures/") ||
strings.Contains(normalized, "/testdata/") ||
strings.Contains(normalized, "/fixtures/")
}
func warnForPrivateIPv4(file string) bool {
normalized := filepath.ToSlash(file)
if sourceOrTestFixtureFile(normalized) {
return false
}
switch filepath.Ext(normalized) {
case ".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".toml", ".env":
return true
default:
return strings.HasPrefix(normalized, "docs/") ||
strings.HasPrefix(normalized, "skills/")
}
}
func credentialURLUserInfo(raw string) (string, bool) {
schemeIdx := strings.Index(raw, "://")
if schemeIdx < 0 {

View File

@@ -61,19 +61,6 @@ func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) {
}
}
func TestScanFileAllowsPrivateIPv4SourceFixtures(t *testing.T) {
got := ScanFile("internal/transport/warn_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@10.0.0.1:3128"`,
`target := "socks5://admin:secret@172.16.0.1:1080"`,
`host := "192.168.0.10"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_private_ipv4" {
t.Fatalf("private IPv4 source fixtures should not be public content findings: %#v", got)
}
}
}
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 {
@@ -645,45 +632,6 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
}
}
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@proxy:8080"`,
`repo := "https://u:t@h/r.git"`,
`target := "https://attacker:pw@open.feishu.cn"`,
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
`repo := "http://x-token:PAT_abc@git.host/app_x.git"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_credential_url" {
t.Fatalf("credential URL fixtures should not be credential URL findings: %#v", got)
}
}
}
func TestScanFileAllowsRootCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network.md", []byte(strings.Join([]string{
`proxy: http://user:pass@proxy:8080`,
`repo: https://u:t@h/r.git`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_credential_url" {
t.Fatalf("root credential URL fixtures should not be credential URL findings: %#v", got)
}
}
}
func TestScanFileAllowsRootPrivateIPv4Fixtures(t *testing.T) {
got := ScanFile("testdata/network.md", []byte(strings.Join([]string{
`endpoint: http://10.0.0.1:8080`,
`redis: 192.168.1.10:6379`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_private_ipv4" {
t.Fatalf("root private IPv4 fixtures should not be private IPv4 findings: %#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 {
@@ -700,7 +648,6 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
"DATABASE_URL=postgres://<user>:real-secret@example.invalid/db",
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
"URL=https://<user>:real-secret@example.invalid/path",
"REPO=https://x-token:" + stripeLike + "@git.host/app.git",
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -714,8 +661,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
}
}
}
if count != 4 {
t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got)
if count != 3 {
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
}
}
@@ -777,68 +724,6 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
}
}
func TestScanFileAllowsWeakTokenFieldsWithoutCredentialEvidence(t *testing.T) {
got := ScanFile("docs/resource-tokens.md", []byte(strings.Join([]string{
`{"token":"img_abc123"}`,
`{"token":"img_live_secret"}`,
`{"token":"img_prod_key"}`,
`token=ab********cd`,
`{"image_token":"img_live_secret"}`,
`{"data_mail_token":"mail_abc123"}`,
`{"whiteboard_token":"board_v3_example"}`,
`{"want_token":"token from callback"}`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("weak token fields without credential evidence should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *testing.T) {
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
stripeToken := "sk_" + "live_1234567890abcdef"
randomToken := strings.Join([]string{
"a1b2c3d4",
"e5f6g7h8",
"i9j0k1l2",
"m3n4p5q6",
}, "")
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"token":"` + githubToken + `"}`,
`token=` + stripeToken,
`{"image_token":"` + githubToken + `"}`,
`{"token":"` + randomToken + `"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("high-confidence weak token credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"access_token":"img_abc123"}`,
`{"api_token":"img_live_secret"}`,
`{"service_token":"ab********cd"}`,
`{"bot_token":"board_v3_example"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
for _, item := range got {
@@ -1167,12 +1052,10 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
}
}
func TestScanFileAllowsNonFixtureResourceTokenValues(t *testing.T) {
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("resource-like bare token value should not be credential finding: %#v", got)
}
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
}
}

View File

@@ -59,9 +59,13 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
if appID == "" || scope == "" {
return ""
}
host := "open.feishu.cn"
if brand == core.BrandLark {
host = "open.larksuite.com"
}
return fmt.Sprintf(
"%s/page/scope-apply?clientID=%s&scopes=%s",
core.ResolveOpenBaseURL(brand),
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
host,
url.QueryEscape(appID),
url.QueryEscape(scope),
)

View File

@@ -25,7 +25,7 @@ import (
const (
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
cacheTTL = 24 * time.Hour
fetchTimeout = 15 * time.Second
fetchTimeout = 5 * time.Second
stateFile = "update-state.json"
maxBody = 256 << 10 // 256 KB

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.64",
"version": "1.0.63",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -23,8 +23,8 @@ var DocMediaUpload = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard | mindnote_image", Required: true},
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard, mindnote token for mindnote)", Required: true},
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true},
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true},
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {

View File

@@ -18,7 +18,6 @@ func v2CreateFlags() []common.Flag {
return []common.Flag{
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> 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: "reference-map", Desc: docsReferenceMapFlagDesc, 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"},
{Name: "parent-position", Desc: "parent position such as my_library; mutually exclusive with --parent-token"},
@@ -33,8 +32,8 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Changed("title") && title == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title must not be empty").WithParam("--title")
}
if err := validateDocsV2ReferenceMapFlags(runtime); err != nil {
return err
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(
@@ -42,21 +41,11 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
errs.InvalidParam{Name: "--parent-position", Reason: "mutually exclusive with --parent-token"},
)
}
if runtime.Str("content") == "" && title == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
}
if runtime.Str("content") != "" {
_, err := resolveDocsV2ContentReferenceMap(runtime)
return err
}
return nil
}
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
body := buildCreateBody(runtime)
desc := "OpenAPI: create document"
if runtime.IsBot() {
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document."
@@ -68,10 +57,7 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D
}
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
return err
}
body := buildCreateBody(runtime)
data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body)
if err != nil {
@@ -100,10 +86,7 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
}
func buildCreateContent(runtime *common.RuntimeContext) string {
return buildCreateContentWithBody(runtime, runtime.Str("content"))
}
func buildCreateContentWithBody(runtime *common.RuntimeContext, content string) string {
content := runtime.Str("content")
title := strings.TrimSpace(runtime.Str("title"))
if title == "" {
return content

View File

@@ -14,7 +14,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true}`
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
@@ -71,9 +71,6 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
if err != nil {
return err
}
if err := processHTML5BlockReferenceMapForFetch(runtime, effectiveFetchFormat(runtime), ref.Token, data); err != nil {
return err
}
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
}

View File

@@ -505,14 +505,14 @@ func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) {
if got["enable_user_cite_reference_map"] != true {
t.Fatalf("enable_user_cite_reference_map = %#v, want true in %#v", got["enable_user_cite_reference_map"], got)
}
if got["return_html5_block_data"] != true {
t.Fatalf("return_html5_block_data = %#v, want true in %#v", got["return_html5_block_data"], got)
if _, ok := got["return_html5_block_data"]; ok {
t.Fatalf("extra_param should not request html5 block data: %#v", got)
}
if _, ok := got["reference_map_mode"]; ok {
t.Fatalf("extra_param should not use legacy reference_map_mode: %#v", got)
}
if len(got) != 2 {
t.Fatalf("extra_param should only contain fetch reference_map and html5 data toggles: %#v", got)
if len(got) != 1 {
t.Fatalf("extra_param should only contain fetch reference_map toggle: %#v", got)
}
}
@@ -579,46 +579,6 @@ func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) {
}
}
func TestDocsFetchIMMarkdownIgnoresHTML5BlockInsideCodeFence(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown-code-fence"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdownFence/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcnFetchIMMarkdownFence",
"revision_id": float64(1),
"content": "```xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n```\n",
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--doc", "doxcnFetchIMMarkdownFence",
"--doc-format", "im-markdown",
"--format", "json",
"--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())
}
if errField, ok := envelope["error"]; ok {
t.Fatalf("fetch output should not contain error: %#v", errField)
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
content, _ := doc["content"].(string)
if !strings.Contains(content, "```xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n```") {
t.Fatalf("fenced html5-block should stay in content, got:\n%s", content)
}
if _, ok := doc["reference_map"]; ok {
t.Fatalf("fenced html5-block should not create reference_map side effects: %#v", doc["reference_map"])
}
}
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
t.Parallel()

View File

@@ -5,7 +5,6 @@ package doc
import (
"context"
"errors"
"os"
"strings"
"testing"
@@ -64,39 +63,6 @@ func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
}
}
func TestBuildUpdateBodyWithHTML5ReferenceMapReportsPathError(t *testing.T) {
t.Parallel()
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
"content": `<html5-block path="@missing.html"></html5-block>`,
})
_, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
if err == nil {
t.Fatal("buildUpdateBodyWithHTML5ReferenceMap() succeeded, want error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err)
}
if p.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "path" {
t.Fatalf("param = %q, want path", validationErr.Param)
}
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("error should preserve os.ErrNotExist cause, got: %v", err)
}
}
func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
t.Parallel()

View File

@@ -24,9 +24,7 @@ var validCommandsV2 = map[string]bool{
"append": true,
}
const docsReferenceMapFlagDesc = "结构化 `reference_map` JSON object必须与 `--content` 一起使用。普通写入优先把结构写在正文里;`--reference-map` 主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。"
const docsUpdateReferenceMapFlagDesc = docsReferenceMapFlagDesc
const docsUpdateReferenceMapFlagDesc = "结构化 `reference_map` JSON object `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。"
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
func v2UpdateFlags() []common.Flag {
@@ -117,20 +115,13 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command append requires --content").WithParam("--content")
}
}
if content != "" {
_, err := resolveDocsV2ContentReferenceMap(runtime)
return err
}
return nil
}
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
ref, _ := parseDocumentRef(runtime.Str("doc"))
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
body, _ := buildUpdateBodyWithReferenceMap(runtime)
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
return common.NewDryRunAPI().
PUT(apiPath).
@@ -143,7 +134,7 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocumentRef(runtime.Str("doc"))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
body, err := buildUpdateBodyWithReferenceMap(runtime)
if err != nil {
return err
}

View File

@@ -1,696 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"path/filepath"
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
const (
html5BlockTag = "html5-block"
html5BlockPathAttr = "path"
html5BlockDataRefAttr = "data-ref"
html5BlockDataAttr = "data"
html5BlockReferenceRoot = "doc-fetch-resources"
html5BlockReferenceMaxRaw = 1024
)
var (
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
)
type html5BlockReferenceEntry struct {
Data string `json:"data,omitempty"`
Path string `json:"path,omitempty"`
UserID string `json:"user_id,omitempty"`
}
type html5BlockReferenceMap map[string]map[string]html5BlockReferenceEntry
type docsV2WriteInput struct {
Content string
ReferenceMap map[string]interface{}
}
type html5BlockAttr struct {
Name string
Value string
}
type html5BlockStartTag struct {
Attrs []html5BlockAttr
SelfClosing bool
}
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := buildCreateBody(runtime)
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
return body, nil
}
input, err := resolveDocsV2ContentReferenceMap(runtime)
if err != nil {
return nil, err
}
body["content"] = buildCreateContentWithBody(runtime, input.Content)
if len(input.ReferenceMap) > 0 {
body["reference_map"] = input.ReferenceMap
}
return body, nil
}
func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := buildUpdateBody(runtime)
input, err := resolveDocsV2ContentReferenceMap(runtime)
if err != nil {
return nil, err
}
if input.Content != "" {
body["content"] = input.Content
}
if len(input.ReferenceMap) > 0 {
body["reference_map"] = input.ReferenceMap
}
return body, nil
}
func validateDocsV2ReferenceMapFlags(runtime *common.RuntimeContext) error {
if runtime.Changed("reference-map") && runtime.Str("content") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content").WithParam("--reference-map")
}
return nil
}
func resolveDocsV2ContentReferenceMap(runtime *common.RuntimeContext) (docsV2WriteInput, error) {
input := docsV2WriteInput{Content: runtime.Str("content")}
if raw := runtime.Str("reference-map"); strings.TrimSpace(raw) != "" {
refMap, err := parseReferenceMapObject(raw, "--reference-map")
if err != nil {
return docsV2WriteInput{}, err
}
input.ReferenceMap = refMap
}
return prepareDocsV2WriteInput(runtime, input)
}
func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteInput) (docsV2WriteInput, error) {
refMap := cloneReferenceMapObject(input.ReferenceMap)
html5RefMap, err := html5ReferenceMapFromObject(refMap)
if err != nil {
return docsV2WriteInput{}, err
}
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
if err != nil {
return docsV2WriteInput{}, err
}
if err := resolveReferenceMapPaths(runtime, html5RefMap); err != nil {
return docsV2WriteInput{}, err
}
refMap = mergeHTML5ReferenceMap(refMap, html5RefMap)
return docsV2WriteInput{
Content: content,
ReferenceMap: refMap,
}, nil
}
func parseReferenceMapObject(raw string, label string) (map[string]interface{}, error) {
if len(bytes.TrimSpace([]byte(raw))) == 0 || string(bytes.TrimSpace([]byte(raw))) == "null" {
return nil, nil
}
var refMap map[string]interface{}
if err := json.Unmarshal([]byte(raw), &refMap); err != nil {
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err)
}
return refMap, nil
}
func parseHTML5BlockReferenceMapBytes(raw []byte, label string) (html5BlockReferenceMap, error) {
if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" {
return nil, nil
}
var refMap html5BlockReferenceMap
if err := json.Unmarshal(raw, &refMap); err != nil {
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err)
}
return compactReferenceMap(refMap), nil
}
func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string, content string, refMap html5BlockReferenceMap) (string, html5BlockReferenceMap, error) {
if !strings.Contains(content, "<html5-block") {
return content, compactReferenceMap(refMap), nil
}
if err := validateHTML5BlockWriteElementBodies(format, content); err != nil {
return "", nil, err
}
refMap = cloneReferenceMap(refMap)
if refMap == nil {
refMap = html5BlockReferenceMap{}
}
ensureReferenceGroup(refMap, html5BlockTag)
nextRef := nextHTML5BlockRef(refMap)
rewrite := func(segment string) (string, error) {
return rewriteHTML5BlockStartTags(segment, func(raw string) (string, error) {
tag, err := parseHTML5BlockStartTag(raw)
if err != nil {
return "", common.ValidationErrorf("invalid html5-block tag: %v", err).WithParam("html5-block")
}
if tag.hasAttr(html5BlockDataAttr) {
return "", common.ValidationErrorf("html5-block data is reserved for SDK internals; use data-ref with reference_map or path=\"@relative.html\"").WithParam("html5-block")
}
pathValue, hasPath := tag.attr(html5BlockPathAttr)
dataRef, hasDataRef := tag.attr(html5BlockDataRefAttr)
if hasPath && hasDataRef {
return "", common.ValidationErrorf("html5-block cannot contain both path and data-ref").WithParam("html5-block")
}
if hasDataRef {
ref := strings.TrimSpace(dataRef)
if ref == "" {
return "", common.ValidationErrorf("html5-block data-ref cannot be empty").WithParam("data-ref")
}
if _, ok := refMap[html5BlockTag][ref]; !ok {
return "", common.ValidationErrorf("reference_map.%s.%s is required for html5-block data-ref", html5BlockTag, ref).WithParam("reference_map")
}
return tag.render(false), nil
}
if !hasPath {
return "", common.ValidationErrorf("html5-block requires path=\"@relative.html\" or data-ref with reference_map").WithParam("html5-block")
}
data, err := readHTML5BlockPath(runtime, pathValue, "html5-block path")
if err != nil {
return "", err
}
ref := nextRef()
refMap[html5BlockTag][ref] = html5BlockReferenceEntry{Data: data}
tag.removeAttrs(html5BlockPathAttr, html5BlockDataRefAttr, html5BlockDataAttr)
tag.Attrs = append(tag.Attrs, html5BlockAttr{Name: html5BlockDataRefAttr, Value: ref})
return tag.render(false), nil
})
}
var (
out string
err error
)
if strings.TrimSpace(format) == "markdown" {
out = applyOutsideCodeFences(content, func(segment string) string {
if err != nil {
return segment
}
outSegment, rewriteErr := rewrite(segment)
if rewriteErr != nil {
err = rewriteErr
return segment
}
return outSegment
})
} else {
out, err = rewrite(content)
}
if err != nil {
return "", nil, err
}
return out, compactReferenceMap(refMap), nil
}
func validateHTML5BlockWriteElementBodies(format string, content string) error {
validateSegment := func(segment string) error {
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
for _, match := range matches {
if len(match) < 4 || match[2] < 0 || match[3] < 0 {
continue
}
if strings.TrimSpace(segment[match[2]:match[3]]) != "" {
return common.ValidationErrorf("html5-block content must be loaded from path=\"@relative.html\" or reference_map; remove content between <html5-block> and </html5-block>").WithParam("html5-block")
}
}
return nil
}
if strings.TrimSpace(format) != "markdown" {
return validateSegment(content)
}
var validateErr error
_ = applyOutsideCodeFences(content, func(segment string) string {
if validateErr != nil {
return segment
}
validateErr = validateSegment(segment)
return segment
})
return validateErr
}
func processHTML5BlockReferenceMapForFetch(runtime *common.RuntimeContext, format string, docToken string, data map[string]interface{}) error {
doc, _ := data["document"].(map[string]interface{})
if doc == nil {
return nil
}
content, _ := doc["content"].(string)
if !hasProcessableHTML5Block(format, content) {
return nil
}
refMap, err := referenceMapFromDocument(doc)
if err != nil {
return err
}
group := refMap[html5BlockTag]
if group == nil {
return common.ValidationErrorf("document.reference_map.%s is required for fetched html5-block content", html5BlockTag).WithParam("reference_map")
}
if err := validateFetchedHTML5BlockRefs(format, content, refMap); err != nil {
return err
}
changed := false
for ref, entry := range group {
if entry.Data == "" || len([]byte(entry.Data)) <= html5BlockReferenceMaxRaw {
continue
}
relPath, err := writeHTML5BlockReferenceFile(runtime, docToken, ref, entry.Data)
if err != nil {
return err
}
entry.Data = ""
entry.Path = "@" + filepath.ToSlash(relPath)
group[ref] = entry
changed = true
}
if changed {
doc["reference_map"] = refMap
}
return nil
}
func referenceMapFromDocument(doc map[string]interface{}) (html5BlockReferenceMap, error) {
raw, ok := doc["reference_map"]
if !ok || raw == nil {
return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map")
}
refMap, err := referenceMapFromValue(raw, "document.reference_map")
if err != nil {
return nil, err
}
if len(refMap) == 0 {
return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map")
}
return refMap, nil
}
func referenceMapFromValue(value interface{}, label string) (html5BlockReferenceMap, error) {
if typed, ok := value.(html5BlockReferenceMap); ok {
return compactReferenceMap(typed), nil
}
raw, err := json.Marshal(value)
if err != nil {
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam("reference_map").WithCause(err)
}
return parseHTML5BlockReferenceMapBytes(raw, label)
}
func validateFetchedHTML5BlockRefs(format string, content string, refMap html5BlockReferenceMap) error {
validateSegment := func(segment string) error {
_, err := rewriteHTML5BlockStartTags(segment, func(raw string) (string, error) {
tag, parseErr := parseHTML5BlockStartTag(raw)
if parseErr != nil {
return raw, common.ValidationErrorf("invalid html5-block tag in fetched content: %v", parseErr).WithParam("html5-block")
}
ref, ok := tag.attr(html5BlockDataRefAttr)
if !ok || strings.TrimSpace(ref) == "" {
return raw, common.ValidationErrorf("fetched html5-block is missing data-ref; cannot resolve HTML reference").WithParam("html5-block")
}
ref = strings.TrimSpace(ref)
if _, ok := refMap[html5BlockTag][ref]; !ok {
return raw, common.ValidationErrorf("document.reference_map.%s.%s is missing; cannot resolve html5-block. Re-run fetch or check that the upstream document.reference_map field includes this ref.", html5BlockTag, ref).WithParam("reference_map")
}
return raw, nil
})
return err
}
if strings.TrimSpace(format) != "markdown" {
return validateSegment(content)
}
var validateErr error
_ = applyOutsideCodeFences(content, func(segment string) string {
if validateErr != nil {
return segment
}
validateErr = validateSegment(segment)
return segment
})
return validateErr
}
func resolveReferenceMapPaths(runtime *common.RuntimeContext, refMap html5BlockReferenceMap) error {
for typ, group := range refMap {
for ref, entry := range group {
if strings.TrimSpace(entry.Path) == "" {
continue
}
if entry.Data != "" {
return common.ValidationErrorf("reference_map.%s.%s must use either data or path, not both", typ, ref).WithParam("reference_map")
}
data, err := readHTML5BlockPath(runtime, entry.Path, fmt.Sprintf("reference_map.%s.%s.path", typ, ref))
if err != nil {
return err
}
entry.Data = data
entry.Path = ""
group[ref] = entry
}
}
return nil
}
func readHTML5BlockPath(runtime *common.RuntimeContext, pathValue string, label string) (string, error) {
pathRaw := strings.TrimSpace(pathValue)
if !strings.HasPrefix(pathRaw, "@") {
return "", common.ValidationErrorf("%s %q must start with @, for example @widget.html", label, pathValue).WithParam("path")
}
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
if relPath == "" {
return "", common.ValidationErrorf("%s cannot be empty after @", label).WithParam("path")
}
clean := filepath.Clean(relPath)
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", common.ValidationErrorf("%s %q must be a relative path within the current working directory", label, pathValue).WithParam("path")
}
if strings.ToLower(filepath.Ext(clean)) != ".html" {
return "", common.ValidationErrorf("%s %q must point to a .html file", label, pathValue).WithParam("path")
}
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
if err != nil {
return "", common.ValidationErrorf("%s %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", label, clean, err).WithParam("path").WithCause(err)
}
return string(data), nil
}
func hasProcessableHTML5Block(format string, content string) bool {
if !strings.Contains(content, "<html5-block") {
return false
}
if strings.TrimSpace(format) != "markdown" {
return true
}
found := false
_ = applyOutsideCodeFences(content, func(segment string) string {
if strings.Contains(segment, "<html5-block") {
found = true
}
return segment
})
return found
}
func applyOutsideCodeFences(content string, fn func(segment string) string) string {
var out strings.Builder
var segment strings.Builder
inFence := false
flush := func() {
if segment.Len() == 0 {
return
}
out.WriteString(fn(segment.String()))
segment.Reset()
}
for _, line := range strings.SplitAfter(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
if !inFence {
flush()
inFence = true
} else {
inFence = false
}
out.WriteString(line)
continue
}
if inFence {
out.WriteString(line)
} else {
segment.WriteString(line)
}
}
flush()
return out.String()
}
func cloneReferenceMap(refMap html5BlockReferenceMap) html5BlockReferenceMap {
if len(refMap) == 0 {
return nil
}
out := make(html5BlockReferenceMap, len(refMap))
for typ, group := range refMap {
if len(group) == 0 {
continue
}
outGroup := make(map[string]html5BlockReferenceEntry, len(group))
for ref, entry := range group {
outGroup[ref] = entry
}
out[typ] = outGroup
}
return out
}
func cloneReferenceMapObject(refMap map[string]interface{}) map[string]interface{} {
if len(refMap) == 0 {
return nil
}
out := make(map[string]interface{}, len(refMap))
for key, value := range refMap {
out[key] = value
}
return out
}
func html5ReferenceMapFromObject(refMap map[string]interface{}) (html5BlockReferenceMap, error) {
if len(refMap) == 0 {
return nil, nil
}
group, ok := refMap[html5BlockTag]
if !ok || group == nil {
return nil, nil
}
return referenceMapFromValue(map[string]interface{}{html5BlockTag: group}, "reference_map."+html5BlockTag)
}
func mergeHTML5ReferenceMap(refMap map[string]interface{}, html5RefMap html5BlockReferenceMap) map[string]interface{} {
group := html5RefMap[html5BlockTag]
if len(group) == 0 {
return refMap
}
if refMap == nil {
refMap = map[string]interface{}{}
}
refMap[html5BlockTag] = group
return refMap
}
func compactReferenceMap(refMap html5BlockReferenceMap) html5BlockReferenceMap {
if len(refMap) == 0 {
return nil
}
out := make(html5BlockReferenceMap, len(refMap))
for typ, group := range refMap {
if len(group) == 0 {
continue
}
out[typ] = group
}
if len(out) == 0 {
return nil
}
return out
}
func ensureReferenceGroup(refMap html5BlockReferenceMap, typ string) {
if refMap[typ] == nil {
refMap[typ] = map[string]html5BlockReferenceEntry{}
}
}
func nextHTML5BlockRef(refMap html5BlockReferenceMap) func() string {
next := 1
return func() string {
for {
ref := fmt.Sprintf("html5_%d", next)
next++
if _, exists := refMap[html5BlockTag][ref]; !exists {
return ref
}
}
}
}
func writeHTML5BlockReferenceFile(runtime *common.RuntimeContext, docToken string, ref string, html string) (string, error) {
if !isSafeHTML5BlockResourceName(docToken) {
return "", common.ValidationErrorf("document_id %q cannot be used as a resource directory name", docToken).WithParam("document_id")
}
if !isSafeHTML5BlockResourceName(ref) {
return "", common.ValidationErrorf("html5-block data-ref %q cannot be used as a file name", ref).WithParam("data-ref")
}
relPath := filepath.Join(html5BlockReferenceRoot, docToken, ref+".html")
data := []byte(html)
_, err := runtime.FileIO().Save(relPath, fileio.SaveOptions{
ContentType: "text/html; charset=utf-8",
ContentLength: int64(len(data)),
}, bytes.NewReader(data))
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return "", common.ValidationErrorf("cannot write html5-block reference file %q: %v", relPath, err).WithParam("reference_map").WithCause(err)
}
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot write html5-block reference file %q: %v", relPath, err).WithCause(err)
}
return relPath, nil
}
func isSafeHTML5BlockResourceName(name string) bool {
return name != "." && name != ".." && html5BlockSafeNamePattern.MatchString(name)
}
func rewriteHTML5BlockStartTags(content string, fn func(raw string) (string, error)) (string, error) {
var rewriteErr error
out := html5BlockStartTagPattern.ReplaceAllStringFunc(content, func(raw string) string {
if rewriteErr != nil {
return raw
}
rewritten, err := fn(raw)
if err != nil {
rewriteErr = err
return raw
}
return rewritten
})
if rewriteErr != nil {
return "", rewriteErr
}
return out, nil
}
func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
trimmed := strings.TrimSpace(raw)
selfClosing := strings.HasSuffix(trimmed, "/>")
decoder := xml.NewDecoder(strings.NewReader(raw))
for {
tok, err := decoder.Token()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return html5BlockStartTag{}, err
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
if start.Name.Local != html5BlockTag {
return html5BlockStartTag{}, fmt.Errorf("expected <%s>, got <%s>", html5BlockTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
attrs := make([]html5BlockAttr, 0, len(start.Attr))
for _, attr := range start.Attr {
attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
}
return html5BlockStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
}
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
func (t html5BlockStartTag) attr(name string) (string, bool) {
for _, attr := range t.Attrs {
if attr.Name == name {
return attr.Value, true
}
}
return "", false
}
func (t html5BlockStartTag) hasAttr(name string) bool {
_, ok := t.attr(name)
return ok
}
func (t *html5BlockStartTag) removeAttrs(names ...string) {
remove := make(map[string]struct{}, len(names))
for _, name := range names {
remove[name] = struct{}{}
}
attrs := t.Attrs[:0]
for _, attr := range t.Attrs {
if _, ok := remove[attr.Name]; ok {
continue
}
attrs = append(attrs, attr)
}
t.Attrs = attrs
}
func (t html5BlockStartTag) render(selfClosing bool) string {
var b strings.Builder
b.WriteByte('<')
b.WriteString(html5BlockTag)
for _, attr := range t.Attrs {
b.WriteByte(' ')
b.WriteString(attr.Name)
b.WriteString(`="`)
b.WriteString(escapeXMLAttr(attr.Value))
b.WriteByte('"')
}
if selfClosing {
b.WriteString("/>")
} else {
b.WriteByte('>')
}
if t.SelfClosing && !selfClosing {
b.WriteString("</")
b.WriteString(html5BlockTag)
b.WriteByte('>')
}
return b.String()
}
func escapeXMLAttr(value string) string {
var b strings.Builder
for _, r := range value {
switch r {
case '&':
b.WriteString("&amp;")
case '<':
b.WriteString("&lt;")
case '>':
b.WriteString("&gt;")
case '"':
b.WriteString("&quot;")
case '\'':
b.WriteString("&apos;")
default:
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -1,563 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
for name, flags := range map[string][]common.Flag{
"create": v2CreateFlags(),
"update": v2UpdateFlags(),
} {
t.Run(name, func(t *testing.T) {
flag := findDocsTestFlag(flags, "reference-map")
if flag.Name == "" {
t.Fatal("reference-map flag not found")
}
if flag.Hidden {
t.Fatal("reference-map flag should be public")
}
if !hasDocsTestInput(flag, common.File) || !hasDocsTestInput(flag, common.Stdin) {
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
}
if !strings.Contains(flag.Desc, "@reference-map.json") {
t.Fatalf("reference-map help should mention @file support, got %q", flag.Desc)
}
})
}
}
func TestDocsV2InputFlagIsNotAvailable(t *testing.T) {
for name, flags := range map[string][]common.Flag{
"create": v2CreateFlags(),
"update": v2UpdateFlags(),
} {
t.Run(name, func(t *testing.T) {
for _, flag := range flags {
if flag.Name == "input" {
t.Fatalf("%s should not expose input flag", name)
}
}
})
}
}
func TestDocsUpdateV2ReferenceMapPreservesGenericGroups(t *testing.T) {
t.Parallel()
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
"command": "append",
"content": `<p><widget data-ref="r1"></widget></p>`,
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
})
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
t.Fatalf("buildUpdateBodyWithHTML5ReferenceMap: %v", err)
}
refMap, ok := body["reference_map"].(map[string]interface{})
if !ok {
t.Fatalf("reference_map = %#v, want object", body["reference_map"])
}
widget, _ := refMap["widget"].(map[string]interface{})
r1, _ := widget["r1"].(map[string]interface{})
if got := r1["label"]; got != "widget-ref-value" {
t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body)
}
}
func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("widget.html", []byte("<html><body>hello</body></html>"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<title>demo</title><html5-block path="@widget.html"></html5-block>`,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
if got := body["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
t.Fatalf("content was not rewritten with data-ref: %s", got)
}
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><body>hello</body></html>" {
t.Fatalf("reference_map html data = %q", got)
}
if _, ok := body["resources"]; ok {
t.Fatalf("request body must not use resources: %#v", body)
}
}
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
for _, flag := range flags {
if flag.Name == name {
return flag
}
}
return common.Flag{}
}
func hasDocsTestInput(flag common.Flag, input string) bool {
for _, item := range flag.Input {
if item == input {
return true
}
}
return false
}
func TestDocsUpdateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("widget.html", []byte("<section>updated</section>"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-update"))
stub := registerDocsAIStub(reg, "PUT", "/open-apis/docs_ai/v1/documents/doxcn_doc", map[string]interface{}{
"document": map[string]interface{}{
"revision_id": float64(2),
"new_blocks": []interface{}{
map[string]interface{}{
"block_type": "html5-block",
"block_id": "blk_html5",
"block_token": "boardXXXX",
},
},
},
"result": "success",
})
err := mountAndRunDocs(t, DocsUpdate, []string{
"+update",
"--api-version", "v2",
"--doc", "doxcn_doc",
"--command", "append",
"--content", `<html5-block path="@widget.html"></html5-block>`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
if got := body["content"].(string); got != `<html5-block data-ref="html5_1"></html5-block>` {
t.Fatalf("content = %q", got)
}
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<section>updated</section>" {
t.Fatalf("reference_map html data = %q", got)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
if blocks, _ := doc["new_blocks"].([]interface{}); len(blocks) != 1 {
t.Fatalf("new_blocks not preserved in stdout: %#v", doc)
}
}
func TestDocsFetchV2HTML5BlockKeepsSmallReferenceMapInline(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_fetch",
"revision_id": float64(3),
"content": `<docx><html5-block data-ref="html5_1"></html5-block></docx>`,
"reference_map": map[string]interface{}{
"html5-block": map[string]interface{}{
"html5_1": map[string]interface{}{"data": "<html><main>fetched</main></html>"},
},
},
},
"tips": "must_read_html_code",
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--api-version", "v2",
"--doc", "doxcn_fetch",
"--format", "json",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html")
if _, err := os.Stat(written); err == nil {
t.Fatalf("small html should stay inline, got file %s", written)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
if got := doc["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
t.Fatalf("content should keep data-ref: %s", got)
}
refMap := decodeHTML5ReferenceMap(t, doc["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><main>fetched</main></html>" {
t.Fatalf("reference_map html data = %q", got)
}
if _, ok := doc["resources"]; ok {
t.Fatalf("fetch output must not use resources: %#v", doc)
}
if _, ok := data["suggestions"]; ok {
t.Fatalf("CLI must not add suggestions; service tips is enough: %#v", data["suggestions"])
}
if got := data["tips"]; got != "must_read_html_code" {
t.Fatalf("tips should be preserved from service response, got %#v", got)
}
}
func TestDocsFetchV2HTML5BlockLargeReferenceMapUsesPath(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
largeHTML := "<html><main>" + strings.Repeat("x", html5BlockReferenceMaxRaw+1) + "</main></html>"
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-large"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_fetch",
"revision_id": float64(3),
"content": `<docx><html5-block data-ref="html5_1"></html5-block></docx>`,
"reference_map": map[string]interface{}{
"html5-block": map[string]interface{}{
"html5_1": map[string]interface{}{"data": largeHTML},
},
},
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--api-version", "v2",
"--doc", "doxcn_fetch",
"--format", "json",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html")
raw, err := os.ReadFile(written)
if err != nil {
t.Fatalf("ReadFile(%s) error: %v", written, err)
}
if string(raw) != largeHTML {
t.Fatalf("materialized html = %q", raw)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
if got := doc["content"].(string); strings.Contains(got, `path="@`) || !strings.Contains(got, `data-ref="html5_1"`) {
t.Fatalf("content should keep data-ref and not path: %s", got)
}
refMap := decodeHTML5ReferenceMap(t, doc["reference_map"])
entry := refMap[html5BlockTag]["html5_1"]
if entry.Data != "" || entry.Path != "@doc-fetch-resources/doxcn_fetch/html5_1.html" {
t.Fatalf("large html should be represented as path, got %#v", entry)
}
}
func TestDocsCreateV2HTML5BlockReferenceMapAdvancedInput(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
"--reference-map", `{"html5-block":{"html5_1":{"data":"<html></html>"}}}`,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
if got := body["content"].(string); got != `<html5-block data-ref="html5_1"></html5-block>` {
t.Fatalf("content = %q", got)
}
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html></html>" {
t.Fatalf("reference_map html data = %q", got)
}
}
func TestDocsCreateV2HTML5BlockReferenceMapFromFile(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("reference-map.json", []byte(`{"html5-block":{"html5_1":{"data":"<html>from file</html>"}}}`), 0o600); err != nil {
t.Fatalf("WriteFile(reference-map.json) error: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
"--reference-map", "@reference-map.json",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html>from file</html>" {
t.Fatalf("reference_map html data = %q", got)
}
}
func TestDocsCreateV2HTML5BlockRejectsMissingReferenceMap(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
"--as", "user",
})
if err == nil || !strings.Contains(err.Error(), `reference_map.html5-block.html5_1 is required`) {
t.Fatalf("expected missing reference_map error, got: %v", err)
}
}
func TestDocsCreateV2HTML5BlockRejectsInternalDataAttr(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block data="PGh0bWw+PC9odG1sPg=="></html5-block>`,
"--as", "user",
})
if err == nil || !strings.Contains(err.Error(), `html5-block data is reserved for SDK internals`) {
t.Fatalf("expected internal data attr error, got: %v", err)
}
}
func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block path="@missing.html"></html5-block>`,
"--as", "user",
})
if err == nil || !strings.Contains(err.Error(), `html5-block path "missing.html" cannot be read from the current working directory`) {
t.Fatalf("expected path read error, got: %v", err)
}
}
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("widget.html", []byte("<section>from file</section>"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block path="@widget.html"><section>inline</section></html5-block>`,
"--as", "user",
})
if err == nil || !strings.Contains(err.Error(), `html5-block content must be loaded from path="@relative.html"`) {
t.Fatalf("expected inline content error, got: %v", err)
}
}
func TestDocsFetchV2MissingHTML5BlockReferenceFails(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-missing"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_fetch",
"revision_id": float64(3),
"content": `<docx><html5-block data-ref="html5_missing"></html5-block></docx>`,
"reference_map": map[string]interface{}{
"html5-block": map[string]interface{}{
"html5_1": map[string]interface{}{"data": "<html></html>"},
},
},
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--api-version", "v2",
"--doc", "doxcn_fetch",
"--format", "json",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "Re-run fetch or check that the upstream document.reference_map field includes this ref") {
t.Fatalf("expected missing reference_map error, got: %v", err)
}
}
func TestHTML5BlockMarkdownCodeFenceIsIgnored(t *testing.T) {
for _, fence := range []string{"```", "~~~"} {
t.Run(fence, func(t *testing.T) {
content := fence + "xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n" + fence + "\n"
if hasProcessableHTML5Block("markdown", content) {
t.Fatalf("html5-block inside markdown code fence should be ignored")
}
})
}
}
func TestWriteHTML5BlockReferenceFileRejectsDotNames(t *testing.T) {
runtime := newFetchShortcutTestRuntime(t, "", nil)
tests := []struct {
name string
docToken string
ref string
want string
}{
{name: "dot doc token", docToken: ".", ref: "html5_1", want: "document_id"},
{name: "dotdot doc token", docToken: "..", ref: "html5_1", want: "document_id"},
{name: "dot ref", docToken: "doxcn_fetch", ref: ".", want: "data-ref"},
{name: "dotdot ref", docToken: "doxcn_fetch", ref: "..", want: "data-ref"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := writeHTML5BlockReferenceFile(runtime, tt.docToken, tt.ref, "<html></html>")
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("writeHTML5BlockReferenceFile() error = %v, want %q", err, tt.want)
}
})
}
}
func TestPrepareHTML5BlockWriteContentMarkdownRaw(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("widget.html", []byte("<html><body>markdown</body></html>"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--doc-format", "markdown",
"--content", "before\n<html5-block path=\"@widget.html\"></html5-block>\nafter",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
if got := body["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
t.Fatalf("content was not rewritten: %s", got)
}
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><body>markdown</body></html>" {
t.Fatalf("reference_map html data = %q", got)
}
}
func registerDocsAIStub(reg *httpmock.Registry, method string, url string, data map[string]interface{}) *httpmock.Stub {
stub := &httpmock.Stub{
Method: method,
URL: url,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
}
reg.Register(stub)
return stub
}
func decodeRequestBody(t *testing.T, raw []byte) map[string]interface{} {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(bytes.TrimSpace(raw), &body); err != nil {
t.Fatalf("decode request body: %v\n%s", err, raw)
}
return body
}
func decodeHTML5ReferenceMap(t *testing.T, raw interface{}) html5BlockReferenceMap {
t.Helper()
data, err := json.Marshal(raw)
if err != nil {
t.Fatalf("marshal reference_map: %v\n%#v", err, raw)
}
var refMap html5BlockReferenceMap
if err := json.Unmarshal(data, &refMap); err != nil {
t.Fatalf("decode reference_map: %v\n%s", err, data)
}
return refMap
}

View File

@@ -6,7 +6,6 @@ package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
@@ -18,13 +17,6 @@ const (
secureLabelUpdateScope = "docs:secure_label:write_only"
)
type secureLabelOperation string
const (
secureLabelOperationList secureLabelOperation = "list"
secureLabelOperationUpdate secureLabelOperation = "update"
)
var secureLabelTypes = permApplyTypes
// DriveSecureLabelList lists secure labels available to the current user.
@@ -36,9 +28,6 @@ var DriveSecureLabelList = common.Shortcut{
Scopes: []string{secureLabelReadScope},
AuthTypes: []string{"user"},
HasFormat: true,
Tips: []string{
"Use the `id` field from this command as --label-id for +secure-label-update; do not use the display name.",
},
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: "10", Desc: "page size, 1-10"},
{Name: "page-token", Desc: "pagination token from previous response"},
@@ -64,7 +53,7 @@ var DriveSecureLabelList = common.Shortcut{
nil,
)
if err != nil {
return decorateSecureLabelError(err, secureLabelOperationList)
return err
}
runtime.OutFormat(data, nil, nil)
return nil
@@ -79,21 +68,13 @@ var DriveSecureLabelUpdate = common.Shortcut{
Risk: "write",
Scopes: []string{secureLabelUpdateScope},
AuthTypes: []string{"user"},
Tips: []string{
"Pass the numeric label id returned by +secure-label-list; display names like Public(D) are rejected.",
"Downgrading a secure label may require approval; retrying the same request will not bypass approval.",
"When updating many files, serialize requests and back off on rate_limit errors.",
},
Flags: []common.Flag{
{Name: "token", Desc: "target file token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: secureLabelTypes},
{Name: "label-id", Desc: "secure label ID to set", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")); err != nil {
return err
}
_, err := normalizeSecureLabelID(runtime.Str("label-id"))
_, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -101,15 +82,11 @@ var DriveSecureLabelUpdate = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
labelID, err := normalizeSecureLabelID(runtime.Str("label-id"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("Update Drive secure label").
PATCH("/open-apis/drive/v2/files/:file_token/secure_label").
Params(map[string]interface{}{"type": docType}).
Body(map[string]interface{}{"id": labelID}).
Body(map[string]interface{}{"id": runtime.Str("label-id")}).
Set("file_token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -117,18 +94,14 @@ var DriveSecureLabelUpdate = common.Shortcut{
if err != nil {
return err
}
labelID, err := normalizeSecureLabelID(runtime.Str("label-id"))
if err != nil {
return err
}
body := map[string]interface{}{"id": labelID}
body := map[string]interface{}{"id": runtime.Str("label-id")}
data, err := runtime.CallAPITyped("PATCH",
fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)),
map[string]interface{}{"type": docType},
body,
)
if err != nil {
return decorateSecureLabelError(err, secureLabelOperationUpdate)
return err
}
runtime.Out(data, nil)
return nil
@@ -149,70 +122,3 @@ func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]inter
func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) {
return resolvePermApplyTarget(raw, explicitType)
}
// normalizeSecureLabelID trims a label id and rejects display names before the
// request reaches Drive, where they otherwise surface as opaque JSON errors.
func normalizeSecureLabelID(raw string) (string, error) {
labelID := strings.TrimSpace(raw)
if labelID == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--label-id is required").
WithParam("--label-id")
}
for _, r := range labelID {
if r < '0' || r > '9' {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--label-id must be a numeric secure label ID, not a display name: %q", raw).
WithParam("--label-id").
WithHint("run `lark-cli drive +secure-label-list` and pass the numeric `id` value; do not pass label names like `Public(D)`")
}
}
return labelID, nil
}
// decorateSecureLabelError appends command-aware recovery guidance while
// preserving upstream/classifier hints already attached to the typed error.
func decorateSecureLabelError(err error, operation secureLabelOperation) error {
if err == nil {
return nil
}
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
guidance := secureLabelErrorGuidance(p.Code, operation)
if guidance == "" {
return err
}
if p.Hint == "" {
p.Hint = guidance
} else if !strings.Contains(p.Hint, guidance) {
p.Hint = p.Hint + "; " + guidance
}
return err
}
// secureLabelErrorGuidance returns recovery guidance for secure-label API
// failures whose generic code-level classification needs command context.
func secureLabelErrorGuidance(code int, operation secureLabelOperation) string {
switch code {
case 99991400:
if operation == secureLabelOperationUpdate {
return "secure label updates are rate limited; retry later with exponential backoff and serialize bulk updates"
}
return "secure label listing is rate limited; retry later with exponential backoff"
case 1063013:
if operation == secureLabelOperationUpdate {
return "secure label downgrade requires approval; request approval or choose a non-downgrade label before retrying"
}
case 1063002:
if operation == secureLabelOperationUpdate {
return "the current user lacks permission to update this file's secure label; use a user with file and security-label permission"
}
return "the current user lacks permission to list secure labels; use a user with security-label read permission"
case 1063001, 99992402, 9499:
if operation == secureLabelOperationUpdate {
return "check --token/--type and pass a secure label ID from `lark-cli drive +secure-label-list`, not the display name"
}
return "check secure label list parameters such as --page-size, --page-token, and --lang"
}
return ""
}

View File

@@ -5,11 +5,9 @@ package drive
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
@@ -92,54 +90,13 @@ func TestDriveSecureLabelList_ExecuteSuccess(t *testing.T) {
}
}
func TestDriveSecureLabelList_RateLimitPreservesUpstreamHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/my_secure_labels?page_size=10",
Status: 429,
Body: map[string]interface{}{
"code": 99991400,
"msg": "rate limit exceeded",
"error": map[string]interface{}{
"details": []interface{}{
map[string]interface{}{"value": "server says slow down"},
},
},
},
})
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
"+secure-label-list",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected rate limit error")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T: %v", err, err)
}
if apiErr.Subtype != errs.SubtypeRateLimit || apiErr.Code != 99991400 || !apiErr.Retryable {
t.Fatalf("problem = %+v, want code=99991400 subtype=rate_limit retryable=true", apiErr.Problem)
}
for _, want := range []string{"server says slow down", "secure label listing is rate limited"} {
if !strings.Contains(apiErr.Hint, want) {
t.Fatalf("hint missing %q: %q", want, apiErr.Hint)
}
}
if strings.Contains(apiErr.Hint, "updates are rate limited") {
t.Fatalf("list hint should not use update-specific wording: %q", apiErr.Hint)
}
}
func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "https://example.feishu.cn/docx/doxTok123?from=share",
"--label-id", " 7217780879644737539 ",
"--label-id", "7217780879644737539",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
@@ -175,7 +132,7 @@ func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
"+secure-label-update",
"--token", "doxTok123",
"--type", "docx",
"--label-id", " 7217780879644737539 ",
"--label-id", "7217780879644737539",
"--as", "user",
}, f, stdout)
if err != nil {
@@ -191,32 +148,7 @@ func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
}
}
func TestDriveSecureLabelUpdate_RejectsDisplayNameAsLabelID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "doxTok123",
"--type", "docx",
"--label-id", "Public(D)",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected label id validation error")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--label-id" {
t.Fatalf("Param = %q, want --label-id", validationErr.Param)
}
if !strings.Contains(validationErr.Hint, "+secure-label-list") {
t.Fatalf("hint missing list guidance: %q", validationErr.Hint)
}
}
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsFailedPrecondition(t *testing.T) {
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
@@ -237,78 +169,7 @@ func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsFailedPrecondition(t *te
if err == nil {
t.Fatal("expected 1063013 error")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if validationErr.Subtype != errs.SubtypeFailedPrecondition || validationErr.Code != 1063013 {
t.Fatalf("problem = %+v, want code=1063013 subtype=failed_precondition", validationErr.Problem)
}
if !strings.Contains(validationErr.Hint, "approval") {
t.Fatalf("hint missing approval guidance: %q", validationErr.Hint)
}
}
func TestDriveSecureLabelUpdate_InvalidJSONTypeGetsLabelHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
Status: 400,
Body: map[string]interface{}{
"code": 9499, "msg": "Invalid parameter type in json: id",
},
})
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "https://example.feishu.cn/docx/doxTok123",
"--label-id", "7217780879644737539",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected 9499 error")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T: %v", err, err)
}
if apiErr.Subtype != errs.SubtypeInvalidParameters || apiErr.Code != 9499 {
t.Fatalf("problem = %+v, want code=9499 subtype=invalid_parameters", apiErr.Problem)
}
if !strings.Contains(apiErr.Hint, "+secure-label-list") {
t.Fatalf("hint missing secure label list guidance: %q", apiErr.Hint)
}
}
func TestDriveSecureLabelUpdate_RateLimitIsRetryableWithBackoffHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
Status: 429,
Body: map[string]interface{}{
"code": 99991400, "msg": "rate limit exceeded",
},
})
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "https://example.feishu.cn/docx/doxTok123",
"--label-id", "7217780879644737539",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected rate limit error")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T: %v", err, err)
}
if apiErr.Subtype != errs.SubtypeRateLimit || apiErr.Code != 99991400 || !apiErr.Retryable {
t.Fatalf("problem = %+v, want code=99991400 subtype=rate_limit retryable=true", apiErr.Problem)
}
if !strings.Contains(apiErr.Hint, "backoff") {
t.Fatalf("hint missing backoff guidance: %q", apiErr.Hint)
if !strings.Contains(err.Error(), "Security label downgrade requires approval") {
t.Fatalf("expected raw API error message, got: %v", err)
}
}

View File

@@ -651,7 +651,6 @@ func TestShortcuts(t *testing.T) {
want := []string{
"+chat-create",
"+chat-list",
"+chat-members-list",
"+chat-messages-list",
"+chat-search",
"+chat-update",

View File

@@ -1,420 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"fmt"
"io"
"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 (
imChatMembersListPathFmt = "/open-apis/im/v1/chats/%s/members/list"
chatMembersListDefaultPageSize = 20
chatMembersListMaxPageSize = 100
// chatMembersListDefaultPageDelay throttles --page-all the same way the
// generic paginateLoop does (200ms). It matters for tenants WITHOUT the
// server-side member cap, where a large group drains many pages back to
// back and could otherwise trip rate limits.
chatMembersListDefaultPageDelay = 200
)
// ImChatMembersList is the +chat-members-list shortcut: it lists chat members,
// returning users and bots in separate buckets (users[]/bots[]). It owns its
// pagination loop (mirroring the generic paginateLoop conventions: a per-page
// log line, a --page-limit cap, a non-advancing-token guard) precisely because
// the response is multi-bucket — the generic --page-all merger is built for
// single-array responses and would drop the bots[] bucket and the final-page
// truncations[] signal. See mergeChatMemberPages for the merge semantics.
var ImChatMembersList = common.Shortcut{
Service: "im",
Command: "+chat-members-list",
Description: "List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket",
Risk: "read",
// Declare the narrowest scope the API accepts so tokens carrying only
// im:chat.members:read are honored (same rationale as +chat-list).
Scopes: []string{"im:chat.members:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "chat-id", Required: true, Desc: "chat ID (oc_xxx)"},
{Name: "member-types", Type: "string_slice", Desc: "member types to return (user, bot); omit = all"},
{Name: "member-id-type", Default: "open_id", Desc: "ID type for member_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", chatMembersListMaxPageSize)},
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
{Name: "page-delay", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageDelay), Desc: "delay in ms between pages when --page-all (0 = no delay)"},
},
Tips: []string{
"Default fetches a single page; pass --page-all to walk every page.",
"With --page-all and no explicit --page-size, the max page size is used to minimize round-trips.",
"truncations[] in the result means the server capped a bucket due to security config — the member list is incomplete.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
chatID := strings.TrimSpace(runtime.Str("chat-id"))
if chatID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx)").WithParam("--chat-id")
}
if !strings.HasPrefix(chatID, "oc_") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-id %q: must be an open_chat_id starting with oc_", chatID).WithParam("--chat-id")
}
if n := runtime.Int("page-size"); n < 1 || n > chatMembersListMaxPageSize {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and %d", chatMembersListMaxPageSize).WithParam("--page-size")
}
if n := runtime.Int("page-limit"); n < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit")
}
if n := runtime.Int("page-delay"); n < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-delay must be a non-negative integer").WithParam("--page-delay")
}
_, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
chatID := strings.TrimSpace(runtime.Str("chat-id"))
dry := common.NewDryRunAPI()
if chatMembersShouldAutoPaginate(runtime) {
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
params, _ := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
return dry.
GET(fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))).
Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
warnIfConflictingPagingFlags(runtime)
chatID := strings.TrimSpace(runtime.Str("chat-id"))
res, err := fetchChatMembers(ctx, runtime, chatID)
if err != nil {
return err
}
// The truncation signal is the whole reason this is a dedicated shortcut:
// surface it loudly so an agent never mistakes a capped list for a
// complete one.
if len(res.truncations) > 0 {
writeChatMembersTruncationWarning(runtime.IO().ErrOut, res.truncations)
}
fmt.Fprintf(runtime.IO().ErrOut, "Found %d user(s) and %d bot(s)\n", len(res.users), len(res.bots))
outData := map[string]interface{}{
"chat_id": chatID,
"users": res.users,
"bots": res.bots,
"truncations": res.truncations,
"has_more": res.hasMore,
"page_token": res.pageToken,
}
if res.userTotal != nil {
outData["user_total"] = res.userTotal
}
if res.botTotal != nil {
outData["bot_total"] = res.botTotal
}
runtime.OutFormat(outData, &output.Meta{Count: len(res.users) + len(res.bots)}, func(w io.Writer) {
renderChatMembersPretty(w, chatID, res)
})
return nil
},
}
// chatMembersResult is the aggregated view across one or more pages.
type chatMembersResult struct {
users []interface{}
bots []interface{}
truncations []interface{}
userTotal interface{}
botTotal interface{}
hasMore bool
pageToken string
}
// effectiveChatMembersPageSize resolves the page_size to request. When draining
// every page (--page-all) and the caller did NOT explicitly set --page-size, it
// uses the maximum so a full walk takes the fewest round-trips. An explicit
// --page-size is always honored; without --page-all the smaller default is kept
// as a sensible single-page preview size.
func effectiveChatMembersPageSize(runtime *common.RuntimeContext) int {
if chatMembersShouldAutoPaginate(runtime) && !runtime.Changed("page-size") {
return chatMembersListMaxPageSize
}
if n := runtime.Int("page-size"); n > 0 {
return n
}
return chatMembersListDefaultPageSize
}
// chatMembersShouldAutoPaginate reports whether the fetch loop should walk
// every page. An explicit --page-token disables the auto loop because the
// caller supplied a specific cursor (single-page fetch).
func chatMembersShouldAutoPaginate(runtime *common.RuntimeContext) bool {
if strings.TrimSpace(runtime.Str("page-token")) != "" {
return false
}
return runtime.Bool("page-all")
}
// buildChatMembersParams builds the query params for one page request. The
// startToken (when non-empty) seeds the page_token; the loop overrides it per
// page. Returns the params and the normalized member-types CSV (already
// validated by Validate, so the error is only a defensive guard).
func buildChatMembersParams(runtime *common.RuntimeContext, startToken string) (map[string]interface{}, error) {
memberTypes, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
if err != nil {
return nil, err
}
params := map[string]interface{}{
"member_id_type": runtime.Str("member-id-type"),
"page_size": effectiveChatMembersPageSize(runtime),
}
if memberTypes != "" {
params["member_types"] = memberTypes
}
if startToken != "" {
params["page_token"] = startToken
}
return params, nil
}
// fetchChatMembers walks the list_members endpoint, honoring the four
// pagination flags the same way the generic --page-all path does. It merges
// each page into the aggregate as it arrives (rather than buffering every raw
// page), so peak memory is just the aggregated members plus the single most
// recent page — important for large groups under --page-limit 0.
func fetchChatMembers(ctx context.Context, runtime *common.RuntimeContext, chatID string) (*chatMembersResult, error) {
auto := chatMembersShouldAutoPaginate(runtime)
pageLimit := runtime.Int("page-limit")
pageDelay := runtime.Int("page-delay")
apiPath := fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))
params, err := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
if err != nil {
return nil, err
}
res := newChatMembersResult()
var lastData map[string]interface{}
pageToken := strings.TrimSpace(runtime.Str("page-token"))
for page := 0; ; page++ {
if pageToken != "" {
params["page_token"] = pageToken
}
fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", page+1)
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
if err != nil {
return nil, err
}
addMemberBuckets(res, data)
lastData = data
hasMore, nextToken := common.PaginationMeta(data)
if !auto {
break
}
if !hasMore || nextToken == "" {
break
}
if nextToken == pageToken {
// Guard against a buggy server echoing the same cursor with
// has_more=true: without --page-limit we would loop forever.
fmt.Fprintln(runtime.IO().ErrOut, "Stopping pagination: server returned a non-advancing page_token.")
break
}
if pageLimit > 0 && page+1 >= pageLimit {
fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", pageLimit)
break
}
pageToken = nextToken
// Throttle between pages (only reached when another page follows), so
// draining a large untruncated list doesn't hammer the API.
if pageDelay > 0 {
time.Sleep(time.Duration(pageDelay) * time.Millisecond)
}
}
if lastData != nil {
applyLastPageSignals(res, lastData)
}
return res, nil
}
// newChatMembersResult returns an empty aggregate with non-nil buckets so the
// JSON output always carries arrays (never null).
func newChatMembersResult() *chatMembersResult {
return &chatMembersResult{
users: []interface{}{},
bots: []interface{}{},
truncations: []interface{}{},
}
}
// addMemberBuckets appends one page's users[] and bots[] into the aggregate.
// Concatenating every bucket is what avoids dropping bots[] — the bug the
// generic single-array --page-all merger would hit on this multi-bucket shape.
func addMemberBuckets(res *chatMembersResult, data map[string]interface{}) {
if u, ok := data["users"].([]interface{}); ok {
res.users = append(res.users, u...)
}
if b, ok := data["bots"].([]interface{}); ok {
res.bots = append(res.bots, b...)
}
}
// applyLastPageSignals copies the per-request signals from the FINAL page:
// has_more / page_token / truncations / totals. These must come from the last
// page, not page 1: truncations[] is emitted only on the final page (empty
// earlier), so reading it sooner would hide a server-side cap; user_total /
// bot_total are server-wide counts, and taking the final page's value keeps a
// single, consistent source rather than a possibly-stale earlier count.
func applyLastPageSignals(res *chatMembersResult, data map[string]interface{}) {
res.hasMore, res.pageToken = common.PaginationMeta(data)
if t, ok := data["truncations"].([]interface{}); ok {
res.truncations = t
}
res.userTotal = data["user_total"]
res.botTotal = data["bot_total"]
}
// mergeChatMemberPages folds a slice of page payloads into one aggregate. It is
// the same logic fetchChatMembers applies incrementally, kept as a pure
// function so the multi-bucket merge + last-page-signal semantics are unit
// tested in one place.
func mergeChatMemberPages(pages []map[string]interface{}) *chatMembersResult {
res := newChatMembersResult()
if len(pages) == 0 {
return res
}
for _, data := range pages {
addMemberBuckets(res, data)
}
applyLastPageSignals(res, pages[len(pages)-1])
return res
}
// normalizeMemberTypes validates the --member-types slice (already CSV-split by
// cobra) into a lowercased, deduped CSV string. Empty input is a no-op (return
// the API's default of all types). Any element outside {user, bot} is rejected.
func normalizeMemberTypes(raw []string) (string, error) {
if len(raw) == 0 {
return "", nil
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, p := range raw {
p = strings.TrimSpace(strings.ToLower(p))
if p != "user" && p != "bot" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --member-types value %q: expected one of user, bot", p).WithParam("--member-types")
}
if _, dup := seen[p]; dup {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return strings.Join(out, ","), nil
}
// warnIfConflictingPagingFlags mirrors the wiki list shortcuts: --page-token
// wins (single-page fetch from the supplied cursor) and --page-all is ignored.
func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) {
if strings.TrimSpace(runtime.Str("page-token")) != "" && runtime.Bool("page-all") {
fmt.Fprintln(runtime.IO().ErrOut,
"warning: --page-token is set, so --page-all is ignored (single-page fetch from the supplied cursor)")
}
}
// writeChatMembersTruncationWarning emits a stderr warning for every
// server-side bucket cap reported in truncations[]. It uses the repo's plain
// "warning: <code>: <message>" convention (see shortcuts/common/runner.go and
// +chat-list's bot_strip_p2p) — no emoji, so it stays legible in CI logs and
// pipes regardless of terminal encoding.
func writeChatMembersTruncationWarning(w io.Writer, truncations []interface{}) {
for _, t := range truncations {
tm, ok := t.(map[string]interface{})
if !ok {
continue
}
memberType := valueOrAll(tm["member_type"])
limit := tm["limit"]
fmt.Fprintf(w, "warning: members_truncated: %s bucket capped at %v by server security config; the member list is INCOMPLETE\n", memberType, limit)
}
}
func valueOrAll(v interface{}) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return "member"
}
func renderChatMembersPretty(w io.Writer, chatID string, res *chatMembersResult) {
fmt.Fprintf(w, "Chat: %s\n", chatID)
// Show the server-wide total next to the fetched count: when truncated or
// paged, total can far exceed len(users)/len(bots), and that gap is exactly
// what tells the reader how incomplete the list is.
fmt.Fprintf(w, "Users (%d%s):\n", len(res.users), totalSuffix(res.userTotal, len(res.users)))
for i, u := range res.users {
m, _ := u.(map[string]interface{})
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
}
fmt.Fprintf(w, "Bots (%d%s):\n", len(res.bots), totalSuffix(res.botTotal, len(res.bots)))
for i, b := range res.bots {
m, _ := b.(map[string]interface{})
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
}
if len(res.truncations) > 0 {
fmt.Fprintln(w, "warning: result truncated by server security config (see truncations[]); the list is INCOMPLETE")
}
if res.hasMore {
fmt.Fprint(w, "More pages available; pass --page-all (and --page-limit 0 for everything)")
if res.pageToken != "" {
fmt.Fprintf(w, ", or --page-token %s to resume", res.pageToken)
}
fmt.Fprintln(w)
}
}
func valueOrDash(v interface{}) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return "-"
}
// totalSuffix renders " of <total>" when the server-reported total exceeds the
// number actually fetched (so a truncated/partial bucket is obvious), and ""
// when the total is absent or already matches the fetched count.
func totalSuffix(total interface{}, fetched int) string {
n, ok := toInt(total)
if !ok || n <= fetched {
return ""
}
return fmt.Sprintf(" of %d", n)
}
// toInt coerces a JSON-decoded number (float64 / json.Number / int) to int.
func toInt(v interface{}) (int, bool) {
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
case int64:
return int(n), true
case json.Number:
if i, err := n.Int64(); err == nil {
return int(i), true
}
}
return 0, false
}

View File

@@ -1,325 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// page builds one list_members page payload shaped like the data object the
// server returns (users[]/bots[]/truncations[] plus paging + totals).
func cmlPage(users, bots, truncations []interface{}, hasMore bool, pageToken string) map[string]interface{} {
return map[string]interface{}{
"users": users,
"bots": bots,
"truncations": truncations,
"has_more": hasMore,
"page_token": pageToken,
"user_total": 324,
"bot_total": 2,
}
}
func us(ids ...string) []interface{} {
out := make([]interface{}, 0, len(ids))
for _, id := range ids {
out = append(out, map[string]interface{}{"member_id": id})
}
return out
}
// TestMergeChatMemberPages_MergesUsersAndBots covers Bug 1: every list bucket
// (users AND bots) must be concatenated across pages, not just one of them.
func TestMergeChatMemberPages_MergesUsersAndBots(t *testing.T) {
pages := []map[string]interface{}{
cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
cmlPage(us("u3"), us("b2", "b3"), []interface{}{}, false, ""),
}
res := mergeChatMemberPages(pages)
if len(res.users) != 3 {
t.Errorf("users: want 3 merged, got %d", len(res.users))
}
if len(res.bots) != 3 {
t.Errorf("bots: want 3 merged, got %d", len(res.bots))
}
}
// TestMergeChatMemberPages_TruncationsFromLastPage covers Bug 2: truncations[]
// is emitted only on the final page, so the merged view must take it from the
// last page rather than inherit page 1's empty slice.
func TestMergeChatMemberPages_TruncationsFromLastPage(t *testing.T) {
limit := []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}
pages := []map[string]interface{}{
cmlPage(us("u1"), us("b1"), []interface{}{}, true, "p2"),
cmlPage(us("u2"), nil, limit, false, ""),
}
res := mergeChatMemberPages(pages)
if len(res.truncations) != 1 {
t.Fatalf("truncations: want last page's 1 entry, got %d (%v)", len(res.truncations), res.truncations)
}
}
// TestMergeChatMemberPages_HasMoreAndTokenFromLastPage guards that paging
// signals come from the final page (so a --page-limit cutoff is visible).
func TestMergeChatMemberPages_HasMoreAndTokenFromLastPage(t *testing.T) {
pages := []map[string]interface{}{
cmlPage(us("u1"), nil, nil, true, "p2"),
cmlPage(us("u2"), nil, nil, true, "p3"), // loop stopped early; server still has more
}
res := mergeChatMemberPages(pages)
if !res.hasMore {
t.Error("has_more: want true from last page")
}
if res.pageToken != "p3" {
t.Errorf("page_token: want last page's p3, got %q", res.pageToken)
}
}
// TestMergeChatMemberPages_TotalsFromLastPage verifies user_total / bot_total
// are taken from the final page (not an earlier, possibly-different value).
func TestMergeChatMemberPages_TotalsFromLastPage(t *testing.T) {
pages := []map[string]interface{}{
{"users": us("u1"), "user_total": 999, "bot_total": 7, "has_more": true, "page_token": "p2"},
{"users": us("u2"), "user_total": 324, "bot_total": 2, "has_more": false, "page_token": ""},
}
res := mergeChatMemberPages(pages)
if n, _ := toInt(res.userTotal); n != 324 {
t.Errorf("user_total: want last page's 324, got %v", res.userTotal)
}
if n, _ := toInt(res.botTotal); n != 2 {
t.Errorf("bot_total: want last page's 2, got %v", res.botTotal)
}
}
// TestChatMembersValidate covers --chat-id presence + oc_ prefix enforcement.
func TestChatMembersValidate(t *testing.T) {
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
})
cases := []struct {
name string
chatID string
wantErr bool
}{
{"valid oc_", "oc_abc", false},
{"empty", "", true},
{"missing oc_ prefix", "abc123", true},
}
for _, c := range cases {
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": c.chatID}, nil, nil)
err := ImChatMembersList.Validate(context.Background(), rt)
if c.wantErr {
assertValidationError(t, c.name, err, "--chat-id")
continue
}
if err != nil {
t.Errorf("%s: unexpected error %v", c.name, err)
}
}
}
// assertValidationError checks err satisfies the repo's typed-error contract for
// a validation failure: a *errs.ValidationError carrying the expected Param, and
// problem metadata of category validation / subtype invalid_argument.
func assertValidationError(t *testing.T, ctx string, err error, wantParam string) {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Errorf("%s: want *errs.ValidationError, got %T (%v)", ctx, err, err)
return
}
if ve.Param != wantParam {
t.Errorf("%s: Param = %q, want %q", ctx, ve.Param, wantParam)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("%s: problem = %+v (ok=%v), want category=%s subtype=%s", ctx, p, ok, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
}
func TestNormalizeMemberTypes(t *testing.T) {
cases := []struct {
in []string
want string
wantErr bool
}{
{nil, "", false},
{[]string{"user", "bot"}, "user,bot", false},
{[]string{"USER", "user"}, "user", false}, // lowercased + deduped
{[]string{"admin"}, "", true},
{[]string{""}, "", true},
}
for _, c := range cases {
got, err := normalizeMemberTypes(c.in)
if c.wantErr {
assertValidationError(t, fmt.Sprintf("normalizeMemberTypes(%v)", c.in), err, "--member-types")
continue
}
if err != nil {
t.Errorf("normalizeMemberTypes(%v): unexpected error %v", c.in, err)
}
if got != c.want {
t.Errorf("normalizeMemberTypes(%v) = %q, want %q", c.in, got, c.want)
}
}
}
// TestEffectiveChatMembersPageSize covers the --page-all max-page-size behavior:
// drain with no explicit size → max; explicit size → honored; single page → default.
func TestEffectiveChatMembersPageSize(t *testing.T) {
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
})
cases := []struct {
name string
b map[string]bool
ints map[string]int
want int
}{
{"page-all, size unset -> max", map[string]bool{"page-all": true}, nil, chatMembersListMaxPageSize},
{"page-all, size explicit -> honored", map[string]bool{"page-all": true}, map[string]int{"page-size": 15}, 15},
{"single page, size unset -> default", nil, nil, chatMembersListDefaultPageSize},
}
for _, c := range cases {
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": "oc_x"}, c.b, c.ints)
if got := effectiveChatMembersPageSize(rt); got != c.want {
t.Errorf("%s: want %d, got %d", c.name, c.want, got)
}
}
}
// newChatMembersTestRuntime registers the shortcut's flags and returns a
// user-identity runtime wired to the given RoundTripper for multi-page mocking.
func newChatMembersTestRuntime(t *testing.T, rt http.RoundTripper, str map[string]string, b map[string]bool, ints map[string]int) *common.RuntimeContext {
t.Helper()
runtime := newUserShortcutRuntime(t, rt)
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("chat-id", "", "")
cmd.Flags().String("member-id-type", "open_id", "")
cmd.Flags().StringSlice("member-types", nil, "")
cmd.Flags().String("page-token", "", "")
cmd.Flags().Bool("page-all", false, "")
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().Int("page-limit", 10, "")
cmd.Flags().Int("page-delay", 200, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
for k, v := range str {
if err := cmd.Flags().Set(k, v); err != nil {
t.Fatalf("set %s: %v", k, err)
}
}
for k, v := range b {
if err := cmd.Flags().Set(k, strconv.FormatBool(v)); err != nil {
t.Fatalf("set %s: %v", k, err)
}
}
for k, v := range ints {
if err := cmd.Flags().Set(k, strconv.Itoa(v)); err != nil {
t.Fatalf("set %s: %v", k, err)
}
}
runtime.Cmd = cmd
return runtime
}
// TestFetchChatMembers_PageAllMergesBucketsAndTruncations exercises the full
// fetch loop over mocked pages: users/bots merge across pages and the final
// page's truncations[] survives.
func TestFetchChatMembers_PageAllMergesBucketsAndTruncations(t *testing.T) {
calls := 0
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/oc_test/members/list") {
return shortcutJSONResponse(404, map[string]interface{}{"code": 1}), nil
}
calls++
token := req.URL.Query().Get("page_token")
if token == "" {
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
}), nil
}
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": cmlPage(us("u3"), us("b2"), []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}, false, ""),
}), nil
})
runtime := newChatMembersTestRuntime(t, rt,
map[string]string{"chat-id": "oc_test"},
map[string]bool{"page-all": true},
map[string]int{"page-size": 2, "page-limit": 0, "page-delay": 0})
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
if err != nil {
t.Fatalf("fetchChatMembers: %v", err)
}
if calls != 2 {
t.Errorf("want 2 page calls, got %d", calls)
}
if len(res.users) != 3 {
t.Errorf("users: want 3, got %d", len(res.users))
}
if len(res.bots) != 2 {
t.Errorf("bots: want 2, got %d", len(res.bots))
}
if len(res.truncations) != 1 {
t.Errorf("truncations: want 1 from last page, got %d", len(res.truncations))
}
if res.hasMore {
t.Error("has_more: want false after draining all pages")
}
}
// TestFetchChatMembers_PageLimitStops verifies --page-limit caps the loop and
// leaves has_more=true so the caller knows the result is incomplete.
func TestFetchChatMembers_PageLimitStops(t *testing.T) {
seq := 0
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
// Every page reports more pages available, with an advancing token so the
// loop is stopped by --page-limit, not the non-advancing-token guard.
seq++
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": cmlPage(us("u"), nil, nil, true, fmt.Sprintf("p%d", seq)),
}), nil
})
runtime := newChatMembersTestRuntime(t, rt,
map[string]string{"chat-id": "oc_test"},
map[string]bool{"page-all": true},
map[string]int{"page-size": 1, "page-limit": 3, "page-delay": 0})
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
if err != nil {
t.Fatalf("fetchChatMembers: %v", err)
}
if len(res.users) != 3 {
t.Errorf("users: want 3 (capped at page-limit), got %d", len(res.users))
}
if !res.hasMore {
t.Error("has_more: want true (loop cut short by page-limit)")
}
errOut := runtime.IO().ErrOut.(*bytes.Buffer)
if !strings.Contains(errOut.String(), "reached page limit (3)") {
t.Errorf("want page-limit notice on stderr, got: %s", errOut.String())
}
}

View File

@@ -10,7 +10,6 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
ImChatCreate,
ImChatList,
ImChatMembersList,
ImChatMessageList,
ImChatSearch,
ImChatUpdate,

View File

@@ -58,9 +58,45 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
return objectives, nil
}
// buildContentBlock converts text and mentions to a ContentBlock.
func buildContentBlock(text string, mentions []string) *ContentBlock {
elements := make([]ContentParagraphElement, 0, len(mentions)+1)
// Add text element
textElem := ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: &text,
},
}
elements = append(elements, textElem)
// Add mention elements
for _, mention := range mentions {
mentionElem := ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: &mention,
},
}
elements = append(elements, mentionElem)
}
return &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: elements,
},
},
},
}
}
// createObjective calls the API to create an objective.
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
content := BuildContentBlock(obj.Text, obj.Mention)
content := buildContentBlock(obj.Text, obj.Mention)
body := map[string]interface{}{
"content": content,
}
@@ -84,7 +120,7 @@ func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleI
// createKR calls the API to create a key result.
func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) {
content := BuildContentBlock(kr.Text, kr.Mention)
content := buildContentBlock(kr.Text, kr.Mention)
body := map[string]interface{}{
"content": content,
}
@@ -188,7 +224,7 @@ var OKRBatchCreate = common.Shortcut{
for i, obj := range objectives {
// Objective creation
objContent := BuildContentBlock(obj.Text, obj.Mention)
objContent := buildContentBlock(obj.Text, obj.Mention)
objBody := map[string]interface{}{
"content": objContent,
}
@@ -205,7 +241,7 @@ var OKRBatchCreate = common.Shortcut{
// KR creations
for j, kr := range obj.KRs {
krContent := BuildContentBlock(kr.Text, kr.Mention)
krContent := buildContentBlock(kr.Text, kr.Mention)
krBody := map[string]interface{}{
"content": krContent,
}

View File

@@ -557,7 +557,7 @@ func TestParseBatchCreateInput_Valid(t *testing.T) {
func TestBuildContentBlock(t *testing.T) {
t.Parallel()
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"})
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}

View File

@@ -29,10 +29,15 @@ type RespCategory struct {
// RespCycle 周期
type RespCycle struct {
ID string `json:"id"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CycleStatus *string `json:"cycle_status,omitempty"`
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
TenantCycleID string `json:"tenant_cycle_id"`
Owner RespOwner `json:"owner"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CycleStatus *string `json:"cycle_status,omitempty"`
Score *float64 `json:"score,omitempty"`
}
// RespIndicator 指标
@@ -147,145 +152,3 @@ type RespProgress struct {
Content *string `json:"content,omitempty"`
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
}
// ========== Simple-style response types (semi-plain text format) ==========
// RespKeyResultSimple is KeyResult response with SemiPlainContent instead of ContentBlock JSON string.
type RespKeyResultSimple struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Owner RespOwner `json:"owner"`
ObjectiveID string `json:"objective_id"`
Position *int32 `json:"position,omitempty"`
Content *SemiPlainContent `json:"content,omitempty"`
Score *float64 `json:"score,omitempty"`
Weight *float64 `json:"weight,omitempty"`
Deadline *string `json:"deadline,omitempty"`
}
// RespObjectiveSimple is Objective response with SemiPlainContent instead of ContentBlock JSON string.
type RespObjectiveSimple struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Owner RespOwner `json:"owner"`
CycleID string `json:"cycle_id"`
Position *int32 `json:"position,omitempty"`
Content *SemiPlainContent `json:"content,omitempty"`
Score *float64 `json:"score,omitempty"`
Notes *SemiPlainContent `json:"notes,omitempty"`
Weight *float64 `json:"weight,omitempty"`
Deadline *string `json:"deadline,omitempty"`
CategoryID *string `json:"category_id,omitempty"`
KeyResults []RespKeyResultSimple `json:"key_results,omitempty"`
}
// RespProgressSimple is Progress response with SemiPlainContent instead of ContentBlock JSON string.
type RespProgressSimple struct {
ID string `json:"progress_id"`
ModifyTime string `json:"modify_time"`
CreateTime *string `json:"create_time,omitempty"`
Content *SemiPlainContent `json:"content,omitempty"`
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
}
// ToSimple converts KeyResult to RespKeyResultSimple.
func (k *KeyResult) ToSimple() *RespKeyResultSimple {
if k == nil {
return nil
}
result := &RespKeyResultSimple{
ID: k.ID,
CreateTime: formatTimestamp(k.CreateTime),
UpdateTime: formatTimestamp(k.UpdateTime),
Owner: *k.Owner.ToResp(),
ObjectiveID: k.ObjectiveID,
Position: k.Position,
Score: k.Score,
Weight: k.Weight,
}
if k.Deadline != nil {
d := formatTimestamp(*k.Deadline)
result.Deadline = &d
}
result.Content = k.Content.ToSemiPlain()
return result
}
// ToSimple converts Objective to RespObjectiveSimple.
func (o *Objective) ToSimple() *RespObjectiveSimple {
if o == nil {
return nil
}
result := &RespObjectiveSimple{
ID: o.ID,
CreateTime: formatTimestamp(o.CreateTime),
UpdateTime: formatTimestamp(o.UpdateTime),
Owner: *o.Owner.ToResp(),
CycleID: o.CycleID,
Position: o.Position,
Score: o.Score,
Weight: o.Weight,
CategoryID: o.CategoryID,
}
if o.Deadline != nil {
d := formatTimestamp(*o.Deadline)
result.Deadline = &d
}
result.Content = o.Content.ToSemiPlain()
result.Notes = o.Notes.ToSemiPlain()
return result
}
// ToSimple converts ProgressV1 to RespProgressSimple.
func (p *ProgressV1) ToSimple() *RespProgressSimple {
if p == nil {
return nil
}
resp := &RespProgressSimple{
ID: p.ID,
ModifyTime: formatTimestamp(p.ModifyTime),
}
if p.ProgressRate != nil {
resp.ProgressRate = &RespProgressRate{
Percent: p.ProgressRate.Percent,
}
if p.ProgressRate.Status != nil {
s := ProgressStatus(*p.ProgressRate.Status).String()
if s != "" {
resp.ProgressRate.Status = &s
}
}
}
if p.Content != nil {
resp.Content = p.Content.ToV2().ToSemiPlain()
}
return resp
}
// ToSimple converts Progress to RespProgressSimple.
func (p *Progress) ToSimple() *RespProgressSimple {
if p == nil {
return nil
}
createTime := formatTimestamp(p.CreateTime)
resp := &RespProgressSimple{
ID: p.ID,
ModifyTime: formatTimestamp(p.UpdateTime),
CreateTime: &createTime,
}
if p.ProgressRate != nil {
resp.ProgressRate = &RespProgressRate{
Percent: p.ProgressRate.ProgressPercent,
}
if p.ProgressRate.ProgressStatus != nil {
s := ProgressStatus(*p.ProgressRate.ProgressStatus).String()
if s != "" {
resp.ProgressRate.Status = &s
}
}
}
resp.Content = p.Content.ToSemiPlain()
return resp
}

View File

@@ -26,7 +26,6 @@ var OKRCycleDetail = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true},
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
@@ -36,10 +35,6 @@ var OKRCycleDetail = common.Shortcut{
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
}
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -55,7 +50,6 @@ var OKRCycleDetail = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
style := runtime.Str("style")
// Paginate objectives under the cycle.
queryParams := map[string]interface{}{"page_size": "100"}
@@ -102,106 +96,85 @@ var OKRCycleDetail = common.Shortcut{
}
// For each objective, paginate key results and convert to response format.
if style == "simple" {
respObjectives := make([]*RespObjectiveSimple, 0, len(objectives))
for i := range objectives {
respObjectives := make([]*RespObjective, 0, len(objectives))
for i := range objectives {
if err := ctx.Err(); err != nil {
return err
}
obj := &objectives[i]
krQuery := map[string]interface{}{"page_size": "100"}
var keyResults []KeyResult
krPage := 0
for {
if err := ctx.Err(); err != nil {
return err
}
obj := &objectives[i]
if krPage > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
krPage++
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
data, err := runtime.CallAPITyped("GET", path, krQuery, nil)
if err != nil {
return err
}
respObj := obj.ToSimple()
if respObj == nil {
continue
}
respKRs := make([]RespKeyResultSimple, 0, len(keyResults))
for j := range keyResults {
if r := keyResults[j].ToSimple(); r != nil {
respKRs = append(respKRs, *r)
itemsRaw, _ := data["items"].([]interface{})
for _, item := range itemsRaw {
raw, err := json.Marshal(item)
if err != nil {
continue
}
var kr KeyResult
if err := json.Unmarshal(raw, &kr); err != nil {
continue
}
keyResults = append(keyResults, kr)
}
respObj.KeyResults = respKRs
respObjectives = append(respObjectives, respObj)
hasMore, pageToken := common.PaginationMeta(data)
if !hasMore || pageToken == "" {
break
}
krQuery["page_token"] = pageToken
}
result := map[string]interface{}{
"cycle_id": cycleID,
"objectives": respObjectives,
"total": len(respObjectives),
"style": style,
respObj := obj.ToResp()
if respObj == nil {
continue
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
for _, o := range respObjectives {
contentText := ""
if o.Content != nil {
contentText = o.Content.Text
}
notesText := ""
if o.Notes != nil {
notesText = o.Notes.Text
}
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, contentText, notesText, ptrFloat64(o.Score), ptrFloat64(o.Weight))
for _, kr := range o.KeyResults {
krText := ""
if kr.Content != nil {
krText = kr.Content.Text
}
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, krText, ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
}
respKRs := make([]RespKeyResult, 0, len(keyResults))
for j := range keyResults {
if r := keyResults[j].ToResp(); r != nil {
respKRs = append(respKRs, *r)
}
})
} else {
// richtext mode
respObjectives := make([]*RespObjective, 0, len(objectives))
for i := range objectives {
if err := ctx.Err(); err != nil {
return err
}
obj := &objectives[i]
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
if err != nil {
return err
}
respObj := obj.ToResp()
if respObj == nil {
continue
}
respKRs := make([]RespKeyResult, 0, len(keyResults))
for j := range keyResults {
if r := keyResults[j].ToResp(); r != nil {
respKRs = append(respKRs, *r)
}
}
respObj.KeyResults = respKRs
respObjectives = append(respObjectives, respObj)
}
result := map[string]interface{}{
"cycle_id": cycleID,
"objectives": respObjectives,
"total": len(respObjectives),
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
for _, o := range respObjectives {
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
for _, kr := range o.KeyResults {
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
}
}
})
respObj.KeyResults = respKRs
respObjectives = append(respObjectives, respObj)
}
result := map[string]interface{}{
"cycle_id": cycleID,
"objectives": respObjectives,
"total": len(respObjectives),
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Cycle %s: %d objective(s)\n", cycleID, len(respObjectives))
for _, o := range respObjectives {
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
for _, kr := range o.KeyResults {
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
}
}
})
return nil
},
}

View File

@@ -46,38 +46,12 @@ func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd time.Time) bool {
if err1 != nil || err2 != nil {
return false
}
cycleStart := time.UnixMilli(startMs).UTC()
cycleEnd := time.UnixMilli(endMs).UTC()
cycleStart := time.UnixMilli(startMs)
cycleEnd := time.UnixMilli(endMs)
// Two ranges overlap iff one starts before the other ends
return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart)
}
// isCurrentActiveCycle checks whether a cycle is currently active:
// - current time is within the cycle's start and end time
// - cycle status is default (0) or normal (1)
func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
startMs, err1 := strconv.ParseInt(cycle.StartTime, 10, 64)
endMs, err2 := strconv.ParseInt(cycle.EndTime, 10, 64)
if err1 != nil || err2 != nil {
return false
}
cycleStart := time.UnixMilli(startMs).UTC()
cycleEnd := time.UnixMilli(endMs).UTC()
nowUTC := now.UTC()
// Check time range: now must be >= start and <= end
if nowUTC.Before(cycleStart) || nowUTC.After(cycleEnd) {
return false
}
// Check status: must be default or normal
if cycle.CycleStatus == nil {
return false
}
status := *cycle.CycleStatus
return status == CycleStatusDefault || status == CycleStatusNormal
}
var OKRListCycles = common.Shortcut{
Service: "okr",
Command: "+cycle-list",
@@ -201,30 +175,14 @@ var OKRListCycles = common.Shortcut{
respCycles = append(respCycles, filtered[i].ToResp())
}
// Filter current active cycles
now := time.Now()
currentActiveCycles := make([]*RespCycle, 0)
for i := range filtered {
if isCurrentActiveCycle(&filtered[i], now) {
currentActiveCycles = append(currentActiveCycles, filtered[i].ToResp())
}
}
runtime.OutFormat(map[string]interface{}{
"cycles": respCycles,
"total": len(respCycles),
"current_active_cycles": currentActiveCycles,
"cycles": respCycles,
"total": len(respCycles),
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
for _, c := range respCycles {
fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus))
}
if len(currentActiveCycles) > 0 {
fmt.Fprintf(w, "\nCurrent active cycle(s):\n")
for _, c := range currentActiveCycles {
fmt.Fprintf(w, " [%s] %s ~ %s\n", c.ID, c.StartTime, c.EndTime)
}
}
})
return nil
},

View File

@@ -5,10 +5,8 @@ package okr
import (
"bytes"
"strconv"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
@@ -262,156 +260,11 @@ func TestCycleListExecute_NoCycles(t *testing.T) {
if len(cycles) != 0 {
t.Fatalf("cycles = %v, want empty", cycles)
}
// Assert current_active_cycles field exists and is a slice
rawCurrentActive, ok := data["current_active_cycles"]
if !ok {
t.Fatal("current_active_cycles field is missing from response")
}
currentActive, ok := rawCurrentActive.([]interface{})
if !ok {
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
}
if len(currentActive) != 0 {
t.Fatalf("current_active_cycles = %v, want empty", currentActive)
}
}
// --- isCurrentActiveCycle unit tests ---
func TestIsCurrentActiveCycle(t *testing.T) {
t.Parallel()
now := time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
cycle *Cycle
expected bool
}{
{
name: "active cycle with normal status",
cycle: &Cycle{
ID: "c1",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31 23:59:59
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: true,
},
{
name: "active cycle with default status",
cycle: &Cycle{
ID: "c2",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31
CycleStatus: CycleStatusDefault.Ptr(),
},
expected: true,
},
{
name: "cycle with invalid status",
cycle: &Cycle{
ID: "c3",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31
CycleStatus: CycleStatusInvalid.Ptr(),
},
expected: false,
},
{
name: "cycle with hidden status",
cycle: &Cycle{
ID: "c4",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31
CycleStatus: CycleStatusHidden.Ptr(),
},
expected: false,
},
{
name: "past cycle",
cycle: &Cycle{
ID: "c5",
StartTime: "1704067200000", // 2024-01-01
EndTime: "1719791999999", // 2024-06-30
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: false,
},
{
name: "future cycle",
cycle: &Cycle{
ID: "c6",
StartTime: "1830297600000", // 2028-01-01
EndTime: "1861833599999", // 2028-12-31
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: false,
},
{
name: "nil cycle status",
cycle: &Cycle{
ID: "c7",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31
CycleStatus: nil,
},
expected: false,
},
{
name: "invalid start time",
cycle: &Cycle{
ID: "c8",
StartTime: "invalid",
EndTime: "1798761599999", // 2026-12-31
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: false,
},
{
name: "exact start time boundary",
cycle: &Cycle{
ID: "c9",
StartTime: "1782734400000", // 2026-06-29 12:00:00 UTC
EndTime: "1798761599000", // 2026-12-31 23:59:59 UTC
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: true,
},
{
name: "exact end time boundary",
cycle: &Cycle{
ID: "c10",
StartTime: "1767225600000", // 2026-01-01 00:00:00 UTC
EndTime: "1782734400000", // 2026-06-29 12:00:00 UTC
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isCurrentActiveCycle(tt.cycle, now)
if result != tt.expected {
t.Fatalf("isCurrentActiveCycle() = %v, want %v", result, tt.expected)
}
})
}
}
func TestCycleListExecute_WithCycles(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
// Calculate timestamps relative to now to avoid test expiration
now := time.Now().UTC()
// Active cycle: 6 months before to 6 months after now
activeStartMs := now.AddDate(0, -6, 0).UnixMilli()
activeEndMs := now.AddDate(0, 6, 0).UnixMilli()
// Past cycle: 2 years before to 1.5 years before now
pastStartMs := now.AddDate(-2, 0, 0).UnixMilli()
pastEndMs := now.AddDate(-1, -6, 0).UnixMilli()
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles",
@@ -421,19 +274,19 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "cycle-active",
"start_time": strconv.FormatInt(activeStartMs, 10),
"end_time": strconv.FormatInt(activeEndMs, 10),
"cycle_status": 1, // normal
"id": "cycle-1",
"start_time": "1735689600000",
"end_time": "1751318400000",
"cycle_status": 1,
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"tenant_cycle_id": "tc-1",
"score": 0.75,
},
map[string]interface{}{
"id": "cycle-past",
"start_time": strconv.FormatInt(pastStartMs, 10),
"end_time": strconv.FormatInt(pastEndMs, 10),
"cycle_status": 2, // invalid
"id": "cycle-2",
"start_time": "1704067200000",
"end_time": "1719792000000",
"cycle_status": 2,
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"tenant_cycle_id": "tc-2",
"score": 0.5,
@@ -458,46 +311,6 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
if int(total) != 2 {
t.Fatalf("total = %v, want 2", total)
}
// Check current_active_cycles - should only contain cycle-active
rawCurrentActive, ok := data["current_active_cycles"]
if !ok {
t.Fatal("current_active_cycles field is missing from response")
}
currentActive, ok := rawCurrentActive.([]interface{})
if !ok {
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
}
if len(currentActive) != 1 {
t.Fatalf("current_active_cycles count = %d, want 1", len(currentActive))
}
activeCycle, ok := currentActive[0].(map[string]interface{})
if !ok {
t.Fatalf("current_active_cycles[0] is not a map, got %T", currentActive[0])
}
if activeCycle["id"] != "cycle-active" {
t.Fatalf("current_active_cycles[0].id = %v, want cycle-active", activeCycle["id"])
}
// Verify removed fields are not present in the response
for _, c := range cycles {
cycleMap, _ := c.(map[string]interface{})
if _, ok := cycleMap["create_time"]; ok {
t.Fatal("create_time should not be present in response")
}
if _, ok := cycleMap["update_time"]; ok {
t.Fatal("update_time should not be present in response")
}
if _, ok := cycleMap["tenant_cycle_id"]; ok {
t.Fatal("tenant_cycle_id should not be present in response")
}
if _, ok := cycleMap["owner"]; ok {
t.Fatal("owner should not be present in response")
}
if _, ok := cycleMap["score"]; ok {
t.Fatal("score should not be present in response")
}
}
}
func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) {

View File

@@ -5,9 +5,7 @@ package okr
import (
"encoding/json"
"regexp"
"strconv"
"strings"
"time"
)
@@ -263,9 +261,14 @@ func (c *Cycle) ToResp() *RespCycle {
return nil
}
resp := &RespCycle{
ID: c.ID,
StartTime: formatTimestamp(c.StartTime),
EndTime: formatTimestamp(c.EndTime),
ID: c.ID,
CreateTime: formatTimestamp(c.CreateTime),
UpdateTime: formatTimestamp(c.UpdateTime),
TenantCycleID: c.TenantCycleID,
Owner: *c.Owner.ToResp(),
StartTime: formatTimestamp(c.StartTime),
EndTime: formatTimestamp(c.EndTime),
Score: c.Score,
}
if c.CycleStatus != nil {
s := c.CycleStatus.ToString()
@@ -730,131 +733,6 @@ func (p *ContentPersonV1) ToV2() *ContentMention {
}
}
// ========== SemiPlainContent (半纯文本格式) ==========
// Regex patterns for semi-plain text processing (pre-compiled for performance).
var (
placeholderRE = regexp.MustCompile(`\s*@\{[^}]+\}\s*`)
multiSpaceRE = regexp.MustCompile(`\s+`)
)
// SemiPlainDoc represents a document link in semi-plain content.
type SemiPlainDoc struct {
Title string `json:"title"`
URL string `json:"url"`
}
// SemiPlainContent is a simplified, lossy representation of ContentBlock.
// It contains plain text, mentions, docs, and images without rich formatting or position info.
type SemiPlainContent struct {
Text string `json:"text"`
Mention []string `json:"mention,omitempty"`
Docs []SemiPlainDoc `json:"docs,omitempty"`
Images []string `json:"images,omitempty"`
}
// ToSemiPlain converts ContentBlock to SemiPlainContent (lossy conversion).
// Position information and formatting are discarded; only text, mentions, docs, and images are extracted.
func (c *ContentBlock) ToSemiPlain() *SemiPlainContent {
if c == nil {
return nil
}
result := &SemiPlainContent{}
var textParts []string
for _, block := range c.Blocks {
if block.Paragraph != nil {
for _, elem := range block.Paragraph.Elements {
switch {
case elem.TextRun != nil && elem.TextRun.Text != nil:
textParts = append(textParts, *elem.TextRun.Text)
case elem.Mention != nil && elem.Mention.UserID != nil:
textParts = append(textParts, " @{"+*elem.Mention.UserID+"} ")
result.Mention = append(result.Mention, *elem.Mention.UserID)
case elem.DocsLink != nil:
doc := SemiPlainDoc{}
if elem.DocsLink.Title != nil {
doc.Title = *elem.DocsLink.Title
}
if elem.DocsLink.URL != nil {
doc.URL = *elem.DocsLink.URL
}
result.Docs = append(result.Docs, doc)
}
}
}
if block.Gallery != nil {
for _, img := range block.Gallery.Images {
if img.Src != nil {
result.Images = append(result.Images, *img.Src)
}
}
}
}
result.Text = strings.Join(textParts, "")
return result
}
// ToContentBlock converts SemiPlainContent to ContentBlock.
// Text and mentions are placed in a single paragraph (text first, then mentions).
// Docs and images are NOT converted (input semi-plain format only supports text+mention).
func (s *SemiPlainContent) ToContentBlock() *ContentBlock {
if s == nil {
return nil
}
elements := make([]ContentParagraphElement, 0, len(s.Mention)+1)
// Strip @{userID} placeholders from text to avoid duplicate mentions
// (these placeholders are only for readability in the output format)
strippedText := placeholderRE.ReplaceAllString(s.Text, " ")
// Collapse multiple spaces and trim
strippedText = multiSpaceRE.ReplaceAllString(strippedText, " ")
strippedText = strings.TrimSpace(strippedText)
// Add text element if stripped text is not empty
if strippedText != "" {
text := strippedText
elements = append(elements, ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: &text,
},
})
}
// Add mention elements
for _, mention := range s.Mention {
m := mention
elements = append(elements, ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: &m,
},
})
}
return &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: elements,
},
},
},
}
}
// BuildContentBlock converts text and mentions to a ContentBlock.
// This is a convenience wrapper around SemiPlainContent.ToContentBlock().
func BuildContentBlock(text string, mentions []string) *ContentBlock {
return (&SemiPlainContent{
Text: text,
Mention: mentions,
}).ToContentBlock()
}
// ProgressRateV1 进度率
type ProgressRateV1 struct {
Percent *float64 `json:"percent,omitempty"`

View File

@@ -57,9 +57,7 @@ func TestToRespMethods(t *testing.T) {
convey.So(resp, convey.ShouldNotBeNil)
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
// Verify removed fields are not present in RespCycle
convey.So(resp.StartTime, convey.ShouldNotBeEmpty)
convey.So(resp.EndTime, convey.ShouldNotBeEmpty)
convey.So(*resp.Score, convey.ShouldEqual, 0.75)
})
convey.Convey("Objective", func() {
@@ -520,449 +518,5 @@ func float64Ptr(v float64) *float64 { return &v }
// boolPtr returns a pointer to the given bool value.
func boolPtr(v bool) *bool { return &v }
// ========== SemiPlainContent Conversion Tests ==========
func TestContentBlockToSemiPlain_TextOnly(t *testing.T) {
t.Parallel()
cb := &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Hello world"),
},
},
},
},
},
},
}
sp := cb.ToSemiPlain()
if sp == nil {
t.Fatal("expected non-nil SemiPlainContent")
}
if sp.Text != "Hello world" {
t.Fatalf("expected text 'Hello world', got '%s'", sp.Text)
}
if len(sp.Mention) != 0 {
t.Fatalf("expected 0 mentions, got %d", len(sp.Mention))
}
if len(sp.Docs) != 0 {
t.Fatalf("expected 0 docs, got %d", len(sp.Docs))
}
if len(sp.Images) != 0 {
t.Fatalf("expected 0 images, got %d", len(sp.Images))
}
}
func TestContentBlockToSemiPlain_WithMention(t *testing.T) {
t.Parallel()
cb := &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Hello "),
},
},
{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: strPtr("ou_123"),
},
},
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr(", how are you?"),
},
},
},
},
},
},
}
sp := cb.ToSemiPlain()
if sp == nil {
t.Fatal("expected non-nil SemiPlainContent")
}
// Text includes @{userID} placeholder to preserve positional context
if sp.Text != "Hello @{ou_123} , how are you?" {
t.Fatalf("expected text 'Hello @{ou_123} , how are you?', got '%s'", sp.Text)
}
if len(sp.Mention) != 1 || sp.Mention[0] != "ou_123" {
t.Fatalf("expected mention [ou_123], got %v", sp.Mention)
}
}
func TestContentBlockToSemiPlain_WithDocsAndImages(t *testing.T) {
t.Parallel()
cb := &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Check out this doc: "),
},
},
{
ParagraphElementType: ParagraphElementTypeDocsLink.Ptr(),
DocsLink: &ContentDocsLink{
Title: strPtr("Design Doc"),
URL: strPtr("https://example.feishu.cn/docx/xxx"),
},
},
},
},
},
{
BlockElementType: BlockElementTypeGallery.Ptr(),
Gallery: &ContentGallery{
Images: []ContentImageItem{
{
Src: strPtr("https://example.com/img1.png"),
},
{
Src: strPtr("https://example.com/img2.png"),
},
},
},
},
},
}
sp := cb.ToSemiPlain()
if sp == nil {
t.Fatal("expected non-nil SemiPlainContent")
}
if sp.Text != "Check out this doc: " {
t.Fatalf("unexpected text: '%s'", sp.Text)
}
if len(sp.Docs) != 1 {
t.Fatalf("expected 1 doc, got %d", len(sp.Docs))
}
if sp.Docs[0].Title != "Design Doc" || sp.Docs[0].URL != "https://example.feishu.cn/docx/xxx" {
t.Fatalf("unexpected doc: %+v", sp.Docs[0])
}
if len(sp.Images) != 2 {
t.Fatalf("expected 2 images, got %d", len(sp.Images))
}
if sp.Images[0] != "https://example.com/img1.png" || sp.Images[1] != "https://example.com/img2.png" {
t.Fatalf("unexpected images: %v", sp.Images)
}
}
func TestContentBlockToSemiPlain_Nil(t *testing.T) {
t.Parallel()
var cb *ContentBlock
sp := cb.ToSemiPlain()
if sp != nil {
t.Fatal("expected nil SemiPlainContent for nil ContentBlock")
}
}
func TestSemiPlainContentToContentBlock_TextOnly(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: "Hello world",
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
if len(cb.Blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
}
block := cb.Blocks[0]
if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph {
t.Fatal("expected paragraph block")
}
if block.Paragraph == nil || len(block.Paragraph.Elements) != 1 {
t.Fatalf("expected 1 paragraph element, got %d", len(block.Paragraph.Elements))
}
elem := block.Paragraph.Elements[0]
if elem.ParagraphElementType == nil || *elem.ParagraphElementType != ParagraphElementTypeTextRun {
t.Fatal("expected textRun element")
}
if elem.TextRun == nil || elem.TextRun.Text == nil || *elem.TextRun.Text != "Hello world" {
t.Fatalf("unexpected text: %v", elem.TextRun)
}
}
func TestSemiPlainContentToContentBlock_WithMentions(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: "Please review",
Mention: []string{"ou_123", "ou_456"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
if len(cb.Blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
}
elems := cb.Blocks[0].Paragraph.Elements
if len(elems) != 3 {
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
}
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun || *elems[0].TextRun.Text != "Please review" {
t.Fatal("unexpected first element")
}
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_123" {
t.Fatal("unexpected second element")
}
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_456" {
t.Fatal("unexpected third element")
}
}
func TestSemiPlainContentToContentBlock_EmptyText(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: " ",
Mention: []string{"ou_123"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Empty text should be skipped, only mention remains
if len(elems) != 1 {
t.Fatalf("expected 1 element (mention only), got %d", len(elems))
}
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
t.Fatal("expected mention element")
}
}
func TestSemiPlainContentToContentBlock_DocsImagesIgnored(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: "Test",
Mention: []string{"ou_123"},
Docs: []SemiPlainDoc{{Title: "Doc", URL: "https://..."}},
Images: []string{"https://img.png"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Docs and images are ignored in input conversion
if len(elems) != 2 {
t.Fatalf("expected 2 elements (text + mention), got %d", len(elems))
}
}
func TestSemiPlainContentToContentBlock_PlaceholderStripping(t *testing.T) {
t.Parallel()
// Simulate round-trip: output format has @{userID} in text,
// input conversion should strip them to avoid duplicate mentions
sp := &SemiPlainContent{
Text: "任务一 @{ou_zhangsan} ,任务二 @{ou_lisi} ",
Mention: []string{"ou_zhangsan", "ou_lisi"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Should have 3 elements: 1 text (stripped) + 2 mentions
if len(elems) != 3 {
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
}
// Text should have placeholders stripped
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun {
t.Fatal("expected first element to be textRun")
}
// Note: space before comma is preserved from the placeholder's trailing space
expectedText := "任务一 ,任务二"
if *elems[0].TextRun.Text != expectedText {
t.Fatalf("expected stripped text '%s', got '%s'", expectedText, *elems[0].TextRun.Text)
}
// Mentions should be preserved as separate elements
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_zhangsan" {
t.Fatal("unexpected second element")
}
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_lisi" {
t.Fatal("unexpected third element")
}
}
func TestSemiPlainContentToContentBlock_OnlyPlaceholders(t *testing.T) {
t.Parallel()
// Text that is only placeholders should result in no text element
sp := &SemiPlainContent{
Text: " @{ou_123} @{ou_456} ",
Mention: []string{"ou_123", "ou_456"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Should have only 2 mention elements, no text element
if len(elems) != 2 {
t.Fatalf("expected 2 elements (mentions only), got %d", len(elems))
}
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
t.Fatal("expected first element to be mention")
}
if *elems[1].ParagraphElementType != ParagraphElementTypeMention {
t.Fatal("expected second element to be mention")
}
}
func TestSemiPlainContentToContentBlock_Nil(t *testing.T) {
t.Parallel()
var sp *SemiPlainContent
cb := sp.ToContentBlock()
if cb != nil {
t.Fatal("expected nil ContentBlock for nil SemiPlainContent")
}
}
func TestBuildContentBlock_Conversion(t *testing.T) {
t.Parallel()
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
if len(elems) != 3 {
t.Fatalf("expected 3 elements, got %d", len(elems))
}
if *elems[0].TextRun.Text != "Test text" {
t.Fatalf("unexpected text: %s", *elems[0].TextRun.Text)
}
if *elems[1].Mention.UserID != "ou_123" {
t.Fatalf("unexpected mention: %s", *elems[1].Mention.UserID)
}
if *elems[2].Mention.UserID != "ou_456" {
t.Fatalf("unexpected mention: %s", *elems[2].Mention.UserID)
}
}
func TestToSimpleMethods(t *testing.T) {
t.Parallel()
// Test Objective.ToSimple()
text := "Objective text"
obj := &Objective{
ID: "obj-1",
Content: BuildContentBlock(text, []string{"ou_123"}),
Notes: BuildContentBlock("Note text", nil),
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_owner")},
CycleID: "cycle-1",
Score: float64Ptr(0.7),
Weight: float64Ptr(0.5),
Deadline: strPtr("1735776000000"),
}
simpleObj := obj.ToSimple()
if simpleObj == nil {
t.Fatal("expected non-nil RespObjectiveSimple")
}
if simpleObj.ID != "obj-1" {
t.Fatalf("expected ID obj-1, got %s", simpleObj.ID)
}
// Text includes @{userID} placeholder for positional context
expectedContentText := "Objective text @{ou_123} "
if simpleObj.Content == nil || simpleObj.Content.Text != expectedContentText {
t.Fatalf("unexpected content text: expected '%s', got '%s'", expectedContentText, simpleObj.Content.Text)
}
if simpleObj.Notes == nil || simpleObj.Notes.Text != "Note text" {
t.Fatalf("unexpected notes: %+v", simpleObj.Notes)
}
if simpleObj.Score == nil || *simpleObj.Score != 0.7 {
t.Fatalf("unexpected score: %v", simpleObj.Score)
}
if len(simpleObj.Content.Mention) != 1 || simpleObj.Content.Mention[0] != "ou_123" {
t.Fatalf("unexpected mentions: %v", simpleObj.Content.Mention)
}
// Test KeyResult.ToSimple()
kr := &KeyResult{
ID: "kr-1",
ObjectiveID: "obj-1",
Content: BuildContentBlock("KR text", nil),
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_kr_owner")},
Score: float64Ptr(0.5),
}
simpleKR := kr.ToSimple()
if simpleKR == nil {
t.Fatal("expected non-nil RespKeyResultSimple")
}
if simpleKR.Content == nil || simpleKR.Content.Text != "KR text" {
t.Fatalf("unexpected KR content: %+v", simpleKR.Content)
}
// Test ProgressV1.ToSimple()
progress := &ProgressV1{
ID: "prog-1",
ModifyTime: "1735776000000",
Content: BuildContentBlock("Progress text", []string{"ou_mention"}).ToV1(),
}
simpleProgress := progress.ToSimple()
if simpleProgress == nil {
t.Fatal("expected non-nil RespProgressSimple")
}
// Text includes @{userID} placeholder for positional context
expectedProgressText := "Progress text @{ou_mention} "
if simpleProgress.Content == nil || simpleProgress.Content.Text != expectedProgressText {
t.Fatalf("unexpected progress text: expected '%s', got '%s'", expectedProgressText, simpleProgress.Content.Text)
}
if len(simpleProgress.Content.Mention) != 1 || simpleProgress.Content.Mention[0] != "ou_mention" {
t.Fatalf("unexpected progress mentions: %v", simpleProgress.Content.Mention)
}
// Test Progress.ToSimple() (V2 progress record)
progressV2 := &Progress{
ID: "prog-v2-1",
CreateTime: "1735689600000",
UpdateTime: "1735776000000",
Content: BuildContentBlock("V2 progress text", []string{"ou_v2_mention"}),
ProgressRate: &ProgressRate{
ProgressPercent: float64Ptr(80.0),
ProgressStatus: int32Ptr(int32(ProgressStatusDone)),
},
}
simpleProgressV2 := progressV2.ToSimple()
if simpleProgressV2 == nil {
t.Fatal("expected non-nil RespProgressSimple for Progress V2")
}
if simpleProgressV2.ID != "prog-v2-1" {
t.Fatalf("expected ID prog-v2-1, got %s", simpleProgressV2.ID)
}
if simpleProgressV2.CreateTime == nil || *simpleProgressV2.CreateTime == "" {
t.Fatal("expected non-empty CreateTime for Progress V2")
}
expectedV2Text := "V2 progress text @{ou_v2_mention} "
if simpleProgressV2.Content == nil || simpleProgressV2.Content.Text != expectedV2Text {
t.Fatalf("unexpected V2 progress text: expected '%s', got '%s'", expectedV2Text, simpleProgressV2.Content.Text)
}
if simpleProgressV2.ProgressRate == nil || simpleProgressV2.ProgressRate.Status == nil || *simpleProgressV2.ProgressRate.Status != "done" {
t.Fatalf("expected progress status 'done', got %+v", simpleProgressV2.ProgressRate)
}
if simpleProgressV2.ProgressRate.Percent == nil || *simpleProgressV2.ProgressRate.Percent != 80.0 {
t.Fatalf("expected progress percent 80.0, got %v", simpleProgressV2.ProgressRate.Percent)
}
if len(simpleProgressV2.Content.Mention) != 1 || simpleProgressV2.Content.Mention[0] != "ou_v2_mention" {
t.Fatalf("unexpected V2 progress mentions: %v", simpleProgressV2.Content.Mention)
}
}
// listTypePtr returns a pointer to the given ListType value.
func listTypePtr(v ListType) *ListType { return &v }

View File

@@ -1,311 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// patchParams holds the parsed parameters for the patch operation.
type patchParams struct {
Level string
TargetID string
Style string
Content *ContentBlock
Notes *ContentBlock
Score *float64
Deadline *string
UserIDType string
}
// parsePatchParams parses and validates flags from runtime into request-ready parameters.
func parsePatchParams(runtime *common.RuntimeContext) (*patchParams, error) {
p := &patchParams{
Level: runtime.Str("level"),
TargetID: runtime.Str("target-id"),
Style: runtime.Str("style"),
UserIDType: runtime.Str("user-id-type"),
}
hasField := false
// Parse content if provided
if contentStr := runtime.Str("content"); contentStr != "" {
hasField = true
if err := common.RejectDangerousCharsTyped("--content", contentStr); err != nil {
return nil, err
}
if p.Style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(contentStr), &sp); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
p.Content = sp.ToContentBlock()
} else {
var cb ContentBlock
if err := json.Unmarshal([]byte(contentStr), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
p.Content = &cb
}
}
// Parse notes if provided (only for objective)
if notesStr := runtime.Str("notes"); notesStr != "" {
hasField = true
if err := common.RejectDangerousCharsTyped("--notes", notesStr); err != nil {
return nil, err
}
if p.Level != "objective" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes is only supported for level=objective").WithParam("--notes")
}
if p.Style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(notesStr), &sp); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--notes").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes text is required and cannot be empty").WithParam("--notes")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes mention[%d] cannot be empty", i).WithParam("--notes")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--notes")
}
p.Notes = sp.ToContentBlock()
} else {
var cb ContentBlock
if err := json.Unmarshal([]byte(notesStr), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid ContentBlock JSON: %s", err).WithParam("--notes").WithCause(err)
}
p.Notes = &cb
}
}
// Parse score if provided
if scoreStr := runtime.Str("score"); scoreStr != "" {
hasField = true
score, err := strconv.ParseFloat(scoreStr, 64)
if err != nil || math.IsNaN(score) || math.IsInf(score, 0) {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be a valid number").WithParam("--score")
}
if score < 0 || score > 1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be between 0 and 1").WithParam("--score")
}
// Check for exactly one decimal place
scoreStrTrimmed := strings.TrimRight(strings.TrimRight(scoreStr, "0"), ".")
parts := strings.Split(scoreStrTrimmed, ".")
if len(parts) == 2 && len(parts[1]) > 1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must have at most one decimal place (e.g., 0.5, not 0.51)").WithParam("--score")
}
// Validation ensures at most one decimal place, so score is already correctly formatted
p.Score = &score
}
// Parse deadline if provided
if deadlineStr := runtime.Str("deadline"); deadlineStr != "" {
hasField = true
deadlineMs, err := strconv.ParseInt(deadlineStr, 10, 64)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a valid millisecond timestamp (integer)").WithParam("--deadline")
}
if deadlineMs <= 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a positive millisecond timestamp").WithParam("--deadline")
}
// Reject non-millisecond timestamps: year 2000 in ms is ~946e9, year 2100 in ms is ~4.1e12
// Anything less than 1e12 is likely seconds or a wrong unit
if deadlineMs < 1000000000000 { // 1e12 ms = year ~33658, so use 1e12 as lower bound for reasonable ms timestamps
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a millisecond timestamp (13 digits), not seconds").WithParam("--deadline")
}
p.Deadline = &deadlineStr
}
// At least one field must be provided
if !hasField {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --content, --notes, --score, or --deadline must be provided")
}
return p, nil
}
// OKRPatch patches an objective or key result.
var OKRPatch = common.Shortcut{
Service: "okr",
Command: "+patch",
Description: "Patch an OKR objective or key result (content, notes, score, deadline)",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "patch level: objective | key-result", Required: true, Enum: []string{"objective", "key-result"}},
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
{Name: "style", Default: "simple", Desc: "input style for content/notes: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
{Name: "content", Desc: "content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
{Name: "notes", Desc: "notes (objective only): semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
{Name: "score", Desc: "score value between 0 and 1, with at most one decimal place (e.g., 0.5)"},
{Name: "deadline", Desc: "deadline as millisecond timestamp"},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
if level != "objective" && level != "key-result" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
}
targetID := runtime.Str("target-id")
if targetID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
}
if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil {
return err
}
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
}
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
// Delegate content/notes/score/deadline validation to parsePatchParams
if _, err := parsePatchParams(runtime); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
p, err := parsePatchParams(runtime)
if err != nil {
return common.NewDryRunAPI().
PATCH("").
Desc(fmt.Sprintf("Dry-run skipped: %s", err.Error()))
}
body := make(map[string]interface{})
if p.Content != nil {
body["content"] = p.Content
}
if p.Notes != nil {
body["notes"] = p.Notes
}
if p.Score != nil {
body["score"] = *p.Score
}
if p.Deadline != nil {
body["deadline"] = *p.Deadline
}
params := map[string]interface{}{
"user_id_type": p.UserIDType,
}
api := common.NewDryRunAPI()
if p.Level == "objective" {
api = api.PATCH("/open-apis/okr/v2/objectives/:objective_id").
Set("objective_id", p.TargetID)
} else {
api = api.PATCH("/open-apis/okr/v2/key_results/:key_result_id").
Set("key_result_id", p.TargetID)
}
return api.Params(params).Body(body).
Desc(fmt.Sprintf("Patch OKR %s: content=%v, notes=%v, score=%v, deadline=%v",
p.Level, p.Content != nil, p.Notes != nil, p.Score != nil, p.Deadline != nil))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
p, err := parsePatchParams(runtime)
if err != nil {
return err
}
body := make(map[string]interface{})
if p.Content != nil {
body["content"] = p.Content
}
if p.Notes != nil {
body["notes"] = p.Notes
}
if p.Score != nil {
body["score"] = *p.Score
}
if p.Deadline != nil {
body["deadline"] = *p.Deadline
}
queryParams := map[string]interface{}{
"user_id_type": p.UserIDType,
}
var path string
if p.Level == "objective" {
path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s", p.TargetID)
} else {
path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s", p.TargetID)
}
_, err = runtime.CallAPITyped("PATCH", path, queryParams, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to patch OKR %s", p.Level)
}
result := map[string]interface{}{
"level": p.Level,
"target_id": p.TargetID,
"patched": map[string]bool{
"content": p.Content != nil,
"notes": p.Notes != nil,
"score": p.Score != nil,
"deadline": p.Deadline != nil,
},
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Patched OKR %s [%s]\n", p.Level, p.TargetID)
if p.Content != nil {
fmt.Fprintf(w, " - content: updated\n")
}
if p.Notes != nil {
fmt.Fprintf(w, " - notes: updated\n")
}
if p.Score != nil {
fmt.Fprintf(w, " - score: %.1f\n", *p.Score)
}
if p.Deadline != nil {
fmt.Fprintf(w, " - deadline: %s\n", formatTimestamp(*p.Deadline))
}
})
return nil
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,6 @@ import (
"io"
"math"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
@@ -36,37 +35,12 @@ type createProgressRecordParams struct {
// parseCreateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createProgressRecordParams, error) {
style := runtime.Str("style")
content := runtime.Str("content")
var contentV1 *ContentBlockV1
if style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(content), &sp); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
// Validate mention IDs are non-empty
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
// Build ContentBlock from semi-plain content (text + mentions)
contentV1 = sp.ToContentBlock().ToV1()
} else {
// richtext mode
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
contentV1 = cb.ToV1()
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
contentV1 := cb.ToV1()
targetType := runtime.Str("target-type")
targetTypeVal := targetTypeAllowed[targetType]
@@ -118,7 +92,7 @@ var OKRCreateProgressRecord = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
{Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}},
{Name: "progress-percent", Desc: "progress percentage"},
@@ -126,7 +100,6 @@ var OKRCreateProgressRecord = common.Shortcut{
{Name: "source-title", Default: "created by lark-cli", Desc: "source title for display"},
{Name: "source-url", Desc: "source URL for display (defaults to open platform URL based on brand)"},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
content := runtime.Str("content")
@@ -136,36 +109,10 @@ var OKRCreateProgressRecord = common.Shortcut{
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
return err
}
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
// Validate content based on style
if style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(content), &sp); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
// If user provided docs or images in simple mode, warn that they are ignored
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
} else {
// richtext mode
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
// Validate content is valid JSON and can be parsed as ContentBlock
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
targetID := runtime.Str("target-id")
@@ -266,43 +213,21 @@ var OKRCreateProgressRecord = common.Shortcut{
return err
}
style := runtime.Str("style")
var result map[string]interface{}
if style == "simple" {
resp := record.ToSimple()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
}
})
} else {
resp := record.ToResp()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
resp := record.ToResp()
result := map[string]interface{}{
"progress": resp,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Created Progress [%s]\n", resp.ID)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
return nil
},
}

View File

@@ -5,13 +5,11 @@ package okr
import (
"bytes"
"errors"
"strings"
"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"
@@ -40,7 +38,6 @@ func runProgressCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.B
}
const validContentBlockJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}`
const validSemiPlainJSON = `{"text":"test content","mention":["ou_123"]}`
// --- Validate tests ---
@@ -63,7 +60,6 @@ func TestProgressCreateValidate_InvalidContentJSON(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", "not-json",
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
})
@@ -81,7 +77,6 @@ func TestProgressCreateValidate_MissingTargetID(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-type", "objective",
})
if err == nil {
@@ -95,7 +90,6 @@ func TestProgressCreateValidate_InvalidTargetID_NonNumeric(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "abc",
"--target-type", "objective",
})
@@ -113,7 +107,6 @@ func TestProgressCreateValidate_InvalidTargetType(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "invalid",
})
@@ -131,7 +124,6 @@ func TestProgressCreateValidate_ControlCharsInContent(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", "{\"blocks\":[{\"block_element_type\":\"para\tgraph\"}]}",
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
})
@@ -146,7 +138,6 @@ func TestProgressCreateValidate_InvalidUserIDType(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--user-id-type", "invalid",
@@ -162,7 +153,6 @@ func TestProgressCreateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--progress-percent", "999999999999",
@@ -181,7 +171,6 @@ func TestProgressCreateValidate_InvalidProgressPercent_NonNumeric(t *testing.T)
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--progress-percent", "abc",
@@ -200,7 +189,6 @@ func TestProgressCreateValidate_InvalidProgressStatus(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--progress-status", "invalid_status",
@@ -231,7 +219,6 @@ func TestProgressCreateValidate_Valid(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
})
@@ -248,7 +235,6 @@ func TestProgressCreateDryRun(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--dry-run",
@@ -278,7 +264,6 @@ func TestProgressCreateDryRun_WithProgressRate(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--progress-percent", "75",
@@ -314,7 +299,6 @@ func TestProgressCreateExecute_Success(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "456",
"--target-type", "key_result",
})
@@ -346,7 +330,6 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "789",
"--target-type", "objective",
})
@@ -354,200 +337,3 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
t.Fatal("expected error for API failure")
}
}
// --- Simple mode tests ---
func TestProgressCreateExecute_SimpleMode_DefaultStyle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v1/progress_records/",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"progress_id": "300",
"modify_time": "1735776000000",
},
},
})
// Use default style (simple) without specifying --style
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validSemiPlainJSON,
"--target-id", "123",
"--target-type", "objective",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
pr, _ := data["progress"].(map[string]interface{})
if pr == nil {
t.Fatal("expected progress in output")
}
if pr["progress_id"] != "300" {
t.Fatalf("progress_id = %v, want 300", pr["progress_id"])
}
}
func TestProgressCreateExecute_SimpleMode_ExplicitStyle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v1/progress_records/",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"progress_id": "400",
"modify_time": "1735776000000",
},
},
})
// Explicitly specify --style simple with mentions
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", `{"text":"simple progress with mention","mention":["ou_abc","ou_def"]}`,
"--style", "simple",
"--target-id", "456",
"--target-type", "key_result",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
pr, _ := data["progress"].(map[string]interface{})
if pr == nil {
t.Fatal("expected progress in output")
}
if pr["progress_id"] != "400" {
t.Fatalf("progress_id = %v, want 400", pr["progress_id"])
}
}
func TestProgressCreateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", `{"text":"missing closing brace`,
"--target-id", "123",
"--target-type", "objective",
})
if err == nil {
t.Fatal("expected error for invalid semi-plain JSON")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressCreateValidate_SimpleMode_EmptyText(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", `{"text":" ","mention":[]}`,
"--target-id", "123",
"--target-type", "objective",
})
if err == nil {
t.Fatal("expected error for empty text in simple mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "--content text is required and cannot be empty") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressCreateValidate_SimpleMode_DocsImagesNotSupported(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", `{"text":"has docs","mention":[],"docs":[{"title":"doc","url":"https://example.com"}]}`,
"--target-id", "123",
"--target-type", "objective",
})
if err == nil {
t.Fatal("expected error for docs in simple mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressCreateDryRun_SimpleMode(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validSemiPlainJSON,
"--target-id", "123",
"--target-type", "objective",
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v1/progress_records/") {
t.Fatalf("dry-run output should contain API path, got: %s", output)
}
if !strings.Contains(output, "POST") {
t.Fatalf("dry-run output should contain POST method, got: %s", output)
}
}

View File

@@ -26,7 +26,6 @@ var OKRGetProgressRecord = common.Shortcut{
Flags: []common.Flag{
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id")
@@ -40,10 +39,6 @@ var OKRGetProgressRecord = common.Shortcut{
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -60,7 +55,6 @@ var OKRGetProgressRecord = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id")
userIDType := runtime.Str("user-id-type")
style := runtime.Str("style")
queryParams := map[string]interface{}{"user_id_type": userIDType}
@@ -75,45 +69,21 @@ var OKRGetProgressRecord = common.Shortcut{
return err
}
var result map[string]interface{}
if style == "simple" {
resp := record.ToSimple()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
if len(resp.Content.Mention) > 0 {
fmt.Fprintf(w, " Mentions: %v\n", resp.Content.Mention)
}
}
})
} else {
resp := record.ToResp()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
resp := record.ToResp()
result := map[string]interface{}{
"progress": resp,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Progress [%s]\n", resp.ID)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
return nil
},
}

View File

@@ -10,7 +10,6 @@ import (
"io"
"math"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
@@ -26,35 +25,12 @@ type updateProgressRecordParams struct {
// parseUpdateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updateProgressRecordParams, error) {
style := runtime.Str("style")
content := runtime.Str("content")
var contentV1 *ContentBlockV1
if style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(content), &sp); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
contentV1 = sp.ToContentBlock().ToV1()
} else {
// richtext mode
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
contentV1 = cb.ToV1()
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
contentV1 := cb.ToV1()
var progressRate *ProgressRateV1
if v := runtime.Str("progress-percent"); v != "" {
@@ -91,11 +67,10 @@ var OKRUpdateProgressRecord = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
{Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "progress-percent", Desc: "progress percentage"},
{Name: "progress-status", Desc: "progress status: normal | overdue | done", Enum: []string{"normal", "overdue", "done"}},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id")
@@ -113,35 +88,9 @@ var OKRUpdateProgressRecord = common.Shortcut{
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
return err
}
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
// Validate content based on style
if style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(content), &sp); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
} else {
// richtext mode
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
if v := runtime.Str("progress-percent"); v != "" {
@@ -209,43 +158,21 @@ var OKRUpdateProgressRecord = common.Shortcut{
return err
}
style := runtime.Str("style")
var result map[string]interface{}
if style == "simple" {
resp := record.ToSimple()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
}
})
} else {
resp := record.ToResp()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
resp := record.ToResp()
result := map[string]interface{}{
"progress": resp,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Updated Progress [%s]\n", resp.ID)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
return nil
},
}

View File

@@ -5,13 +5,11 @@ package okr
import (
"bytes"
"errors"
"strings"
"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"
@@ -47,7 +45,6 @@ func TestProgressUpdateValidate_MissingProgressID(t *testing.T) {
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--content", validContentBlockJSON,
"--style", "richtext",
})
if err == nil {
t.Fatal("expected error for missing --progress-id")
@@ -61,7 +58,6 @@ func TestProgressUpdateValidate_InvalidProgressID(t *testing.T) {
"+progress-update",
"--progress-id", "abc",
"--content", validContentBlockJSON,
"--style", "richtext",
})
if err == nil {
t.Fatal("expected error for invalid --progress-id")
@@ -90,7 +86,6 @@ func TestProgressUpdateValidate_InvalidContentJSON(t *testing.T) {
"+progress-update",
"--progress-id", "123",
"--content", "not-json",
"--style", "richtext",
})
if err == nil {
t.Fatal("expected error for invalid --content JSON")
@@ -107,7 +102,6 @@ func TestProgressUpdateValidate_InvalidUserIDType(t *testing.T) {
"+progress-update",
"--progress-id", "123",
"--content", validContentBlockJSON,
"--style", "richtext",
"--user-id-type", "invalid",
})
if err == nil {
@@ -122,7 +116,6 @@ func TestProgressUpdateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
"+progress-update",
"--progress-id", "123",
"--content", validContentBlockJSON,
"--style", "richtext",
"--progress-percent", "-999999999999",
})
if err == nil {
@@ -140,7 +133,6 @@ func TestProgressUpdateValidate_InvalidProgressStatus(t *testing.T) {
"+progress-update",
"--progress-id", "123",
"--content", validContentBlockJSON,
"--style", "richtext",
"--progress-status", "invalid_status",
})
if err == nil {
@@ -170,7 +162,6 @@ func TestProgressUpdateValidate_Valid(t *testing.T) {
"+progress-update",
"--progress-id", "123",
"--content", validContentBlockJSON,
"--style", "richtext",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -186,7 +177,6 @@ func TestProgressUpdateDryRun(t *testing.T) {
"+progress-update",
"--progress-id", "456",
"--content", validContentBlockJSON,
"--style", "richtext",
"--dry-run",
})
if err != nil {
@@ -211,7 +201,6 @@ func TestProgressUpdateDryRun_WithProgressRate(t *testing.T) {
"+progress-update",
"--progress-id", "456",
"--content", validContentBlockJSON,
"--style", "richtext",
"--progress-percent", "50",
"--progress-status", "overdue",
"--dry-run",
@@ -246,7 +235,6 @@ func TestProgressUpdateExecute_Success(t *testing.T) {
"+progress-update",
"--progress-id", "789",
"--content", validContentBlockJSON,
"--style", "richtext",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -277,202 +265,8 @@ func TestProgressUpdateExecute_APIError(t *testing.T) {
"+progress-update",
"--progress-id", "999",
"--content", validContentBlockJSON,
"--style", "richtext",
})
if err == nil {
t.Fatal("expected error for API failure")
}
}
// --- Simple mode tests ---
func TestProgressUpdateExecute_SimpleMode_DefaultStyle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v1/progress_records/500",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"progress_id": "500",
"modify_time": "1735776000000",
},
},
})
// Use default style (simple) without specifying --style
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "500",
"--content", validSemiPlainJSON,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
pr, _ := data["progress"].(map[string]interface{})
if pr == nil {
t.Fatal("expected progress in output")
}
if pr["progress_id"] != "500" {
t.Fatalf("progress_id = %v, want 500", pr["progress_id"])
}
}
func TestProgressUpdateExecute_SimpleMode_ExplicitStyle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v1/progress_records/600",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"progress_id": "600",
"modify_time": "1735776000000",
},
},
})
// Explicitly specify --style simple with mentions and progress rate
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "600",
"--content", `{"text":"updated progress","mention":["ou_abc"]}`,
"--style", "simple",
"--progress-percent", "80",
"--progress-status", "normal",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
pr, _ := data["progress"].(map[string]interface{})
if pr == nil {
t.Fatal("expected progress in output")
}
if pr["progress_id"] != "600" {
t.Fatalf("progress_id = %v, want 600", pr["progress_id"])
}
}
func TestProgressUpdateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "123",
"--content", `{"text":"invalid json`,
})
if err == nil {
t.Fatal("expected error for invalid semi-plain JSON")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressUpdateValidate_SimpleMode_EmptyMention(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "123",
"--content", `{"text":"has empty mention","mention":["ou_abc",""]}`,
})
if err == nil {
t.Fatal("expected error for empty mention in simple mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "--content mention[1] cannot be empty") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressUpdateValidate_SimpleMode_ImagesNotSupported(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "123",
"--content", `{"text":"has images","mention":[],"images":["img_token"]}`,
})
if err == nil {
t.Fatal("expected error for images in simple mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressUpdateDryRun_SimpleMode(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "700",
"--content", validSemiPlainJSON,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v1/progress_records/700") {
t.Fatalf("dry-run output should contain API path, got: %s", output)
}
if !strings.Contains(output, "PUT") {
t.Fatalf("dry-run output should contain PUT method, got: %s", output)
}
}

View File

@@ -22,6 +22,5 @@ func Shortcuts() []common.Shortcut {
OKRReorder,
OKRWeight,
OKRIndicatorUpdate,
OKRPatch,
}
}

View File

@@ -5,7 +5,6 @@ package backward
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
@@ -18,30 +17,20 @@ import (
// 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_" (being renamed to
// "local_office_") and the backend requires "office_sheet_file" instead.
// 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_"
localOfficeTokenPrefix = "local_office_"
)
// officeTokenPrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
var officeTokenPrefixes = []string{fakeOfficeTokenPrefix, localOfficeTokenPrefix}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken, mapping either the
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
// "office_sheet_file".
// 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 {
for _, prefix := range officeTokenPrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return officeSheetFileParentType
}
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
return officeSheetFileParentType
}
return sheetImageParentType
}
@@ -146,8 +135,7 @@ func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath strin
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
wrapped := common.WrapInputStatErrorTyped(err, "file not found")
var v *errs.ValidationError
if errors.As(wrapped, &v) {
if v, ok := wrapped.(*errs.ValidationError); ok {
return "", nil, v.WithParam("--file")
}
return "", nil, wrapped

View File

@@ -332,21 +332,11 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
}, nil
}
// maxBatchOperations caps how many sub-operations a single +batch-update may
// carry. Every translated op (with its own cells/properties payload) is held in
// the out slice at once before the whole batch is marshaled, so an unbounded
// operation count is the same unbounded-materialization hazard as the fan-out
// matrix, on the operations axis.
const maxBatchOperations = 100
// translateBatchOperations 翻译整个 ops 数组fail-fast遇错立即返回。
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
if len(rawOps) == 0 {
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
}
if len(rawOps) > maxBatchOperations {
return nil, sheetsValidationForFlag("operations", "--operations accepts at most %d entries; got %d", maxBatchOperations, len(rawOps))
}
out := make([]interface{}, 0, len(rawOps))
for i, raw := range rawOps {
translated, err := translateBatchOp(raw, token, i)

View File

@@ -1,59 +1,4 @@
{
"+formula-verify": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string_slice",
"required": "optional",
"desc": "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
},
{
"name": "sheet-name",
"kind": "public",
"type": "string_slice",
"required": "optional",
"desc": "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
},
{
"name": "range",
"kind": "own",
"type": "string_slice",
"required": "optional",
"desc": "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."
},
{
"name": "max-locations",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max locations / samples per error type; default 20.",
"default": "20"
},
{
"name": "exit-on-error",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."
}
]
},
"+workbook-info": {
"risk": "read",
"flags": [
@@ -80,32 +25,6 @@
}
]
},
"+revision-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+sheet-create": {
"risk": "write",
"flags": [
@@ -154,14 +73,6 @@
"desc": "Initial column count (default 20, max 200)",
"default": "20"
},
{
"name": "type",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands",
"default": "sheet"
},
{
"name": "dry-run",
"kind": "system",
@@ -308,7 +219,7 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it",
"desc": "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`",
"default": "-1"
},
{
@@ -604,7 +515,7 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
"input": [
"file",
"stdin"
@@ -1158,7 +1069,7 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)",
"desc": "Group nesting level to ungroup; default 1 (outermost)",
"default": "1"
},
{
@@ -1800,13 +1711,6 @@
"required": "optional",
"desc": "Font color (hex, e.g. `#000000`)"
},
{
"name": "font-family",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
},
{
"name": "font-size",
"kind": "own",
@@ -2835,7 +2739,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
"input": [
"file",
"stdin"
@@ -2855,13 +2759,6 @@
"required": "optional",
"desc": "Font color (hex, e.g. `#000000`)"
},
{
"name": "font-family",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
},
{
"name": "font-size",
"kind": "own",
@@ -2988,7 +2885,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
"input": [
"file",
"stdin"
@@ -3068,7 +2965,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
"input": [
"file",
"stdin"
@@ -3112,7 +3009,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
"input": [
"file",
"stdin"
@@ -3230,7 +3127,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
"input": [
"file",
"stdin"
@@ -4169,7 +4066,7 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"
"desc": "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"
},
{
"name": "dry-run",
@@ -4850,138 +4747,5 @@
"desc": ""
}
]
},
"+history-list": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "end-version",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+history-revert": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "history-version-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "History version to revert to (from +history-list)."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+history-revert-status": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "transaction-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Async revert transaction id (from +history-revert)."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+changeset-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "start-revision",
"kind": "own",
"type": "int",
"required": "required",
"desc": "Start version (CS revision); the before baseline for review (must be >= 1)"
},
{
"name": "end-revision",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20",
"default": "-1"
}
]
}
}

View File

@@ -241,10 +241,6 @@
"description": "字体颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"font_family": {
"description": "字体名称/字族(例如 \"Arial\"、\"微软雅黑\"、\"宋体\"",
"type": "string"
},
"font_size": {
"description": "字体大小单位px/像素,例如 10、12、14",
"type": "number"
@@ -6502,9 +6498,6 @@
"font_color": {
"type": "string"
},
"font_family": {
"type": "string"
},
"font_line": {
"enum": [
"none",
@@ -6874,9 +6867,6 @@
"font_color": {
"type": "string"
},
"font_family": {
"type": "string"
},
"font_line": {
"enum": [
"none",

View File

@@ -27,7 +27,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -38,10 +38,9 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
@@ -166,7 +165,6 @@ var flagDefs = map[string]commandDef{
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
@@ -190,15 +188,6 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+changeset-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "start-revision", Kind: "own", Type: "int", Required: "required", Desc: "Start version (CS revision); the before baseline for review (must be >= 1)"},
{Name: "end-revision", Kind: "own", Type: "int", Required: "optional", Desc: "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20", Default: "-1"},
},
},
"+chart-create": {
Risk: "write",
Flags: []flagDef{
@@ -206,7 +195,7 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
@@ -416,7 +405,7 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)", Default: "1"},
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -437,7 +426,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -474,7 +463,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
@@ -537,7 +526,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"},
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -643,45 +632,6 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+formula-verify": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
{Name: "sheet-name", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
{Name: "range", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."},
{Name: "max-locations", Kind: "own", Type: "int", Required: "optional", Desc: "Max locations / samples per error type; default 20.", Default: "20"},
{Name: "exit-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."},
},
},
"+history-list": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "end-version", Kind: "own", Type: "int", Required: "optional", Desc: "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+history-revert": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "history-version-id", Kind: "own", Type: "string", Required: "required", Desc: "History version to revert to (from +history-list)."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+history-revert-status": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "transaction-id", Kind: "own", Type: "string", Required: "required", Desc: "Async revert transaction id (from +history-revert)."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+pivot-create": {
Risk: "write",
Flags: []flagDef{
@@ -784,14 +734,6 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+revision-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+rows-resize": {
Risk: "write",
Flags: []flagDef{
@@ -826,7 +768,6 @@ var flagDefs = map[string]commandDef{
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
{Name: "type", Kind: "own", Type: "string", Required: "optional", Desc: "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands", Default: "sheet"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -881,7 +822,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it", Default: "-1"},
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", Default: "-1"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -1000,7 +941,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},

View File

@@ -6,6 +6,7 @@ package sheets
import (
_ "embed"
"encoding/json"
"fmt"
"sort"
"sync"
@@ -53,7 +54,7 @@ func loadFlagSchemas() (*flagSchemaIndex, error) {
flagSchemasOnce.Do(func() {
var idx flagSchemaIndex
if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil {
parseFlagErr = errs.NewInternalError(errs.SubtypeUnknown, "flag-schemas.json: %v", err).WithCause(err)
parseFlagErr = fmt.Errorf("flag-schemas.json: %w", err)
return
}
if idx.Flags == nil {

View File

@@ -243,7 +243,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if schema.Type != "" {
if !matchesJSONType(value, schema.Type) {
return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value))
}
}
@@ -251,20 +251,20 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
// already reported above). Apply to both `number` and `integer` types.
if num, ok := value.(float64); ok {
if schema.Minimum != nil && num < *schema.Minimum {
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum)
}
if schema.Maximum != nil && num > *schema.Maximum {
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum)
}
}
// Array length bounds — only checked when value is an array.
if arr, ok := value.([]interface{}); ok {
if schema.MinItems != nil && len(arr) < *schema.MinItems {
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems)
}
if schema.MaxItems != nil && len(arr) > *schema.MaxItems {
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems)
}
}
@@ -282,7 +282,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if hint := suggestEnumMatch(value, schema.Enum); hint != "" {
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
}
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("%s", msg)
}
}
@@ -295,7 +295,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
}
}
if !matched {
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path))
}
}
@@ -305,7 +305,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if obj, ok := value.(map[string]interface{}); ok {
for _, key := range schema.Required {
if _, present := obj[key]; !present {
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path))
}
}
if schema.Properties != nil {
@@ -357,7 +357,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
sort.Strings(extras)
for _, key := range extras {
if schema.AdditionalProperties.Strict {
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
}
if schema.AdditionalProperties.Schema != nil {
child := key

View File

@@ -281,18 +281,18 @@ func (m mapFlagView) validateRawTypes() error {
// parse time; reject here too to keep batch/standalone parity.
f, isNum := val.(float64)
if !isNum {
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
}
if math.Trunc(f) != f {
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64))
}
case "float64":
if _, isNum := val.(float64); !isNum {
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
}
case "bool":
if _, isBool := val.(bool); !isBool {
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val))
}
}
}

View File

@@ -10,7 +10,6 @@ package sheets
import (
"context"
"encoding/json"
"errors"
"fmt"
neturl "net/url"
"strings"
@@ -45,8 +44,7 @@ func sheetsValidationCauseForFlag(name string, cause error) *errs.ValidationErro
// classification and only adds the domain's flag param.
func sheetsInputStatError(flag string, err error) error {
wrapped := common.WrapInputStatErrorTyped(err)
var v *errs.ValidationError
if errors.As(wrapped, &v) {
if v, ok := wrapped.(*errs.ValidationError); ok {
return v.WithParam(sheetsFlagParam(flag))
}
return wrapped
@@ -54,30 +52,21 @@ func sheetsInputStatError(flag string, err error) error {
// 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_" (being renamed to
// "local_office_") and the backend requires "office_sheet_file" instead.
// 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_"
localOfficeTokenPrefix = "local_office_"
)
// officeTokenPrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
var officeTokenPrefixes = []string{fakeOfficeTokenPrefix, localOfficeTokenPrefix}
// 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 {
for _, prefix := range officeTokenPrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return officeSheetFileParentType
}
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
return officeSheetFileParentType
}
return sheetImageParentType
}
@@ -451,7 +440,7 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
// buildCellStyleFromFlags reads the 12 flat style flags and returns the
// buildCellStyleFromFlags reads the 11 flat style flags and returns the
// cell_styles map expected by set_cell_range. Skips any flag the user
// didn't set so partial styles work.
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
@@ -462,9 +451,6 @@ func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
if v := runtime.Str("font-color"); v != "" {
style["font_color"] = v
}
if v := runtime.Str("font-family"); v != "" {
style["font_family"] = v
}
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
style["font_size"] = runtime.Float64("font-size")
}

View File

@@ -215,8 +215,7 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
if borderStyles != nil {
prototype["border_styles"] = borderStyles
}
ops := make([]interface{}, 0, len(ranges))
var totalCells int64
var ops []interface{}
for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil {
@@ -226,13 +225,6 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
if err != nil {
return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err)
}
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
return nil, err
}
totalCells += int64(rows) * int64(cols)
if err := checkBatchStampBudget(totalCells); err != nil {
return nil, err
}
cells := fillCellsMatrix(rows, cols, prototype)
ops = append(ops, map[string]interface{}{
"tool_name": "set_cell_range",
@@ -307,7 +299,7 @@ func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[str
return nil, err
}
clearType := normalizeClearType(runtime.Str("scope"))
ops := make([]interface{}, 0, len(ranges))
var ops []interface{}
for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil {
@@ -390,10 +382,13 @@ var DropdownDelete = common.Shortcut{
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
// validateDropdownRanges enforces the shared maxBatchRanges cap.
if _, err := validateDropdownRanges(runtime); err != nil {
ranges, err := validateDropdownRanges(runtime)
if err != nil {
return err
}
if len(ranges) > 100 {
return sheetsValidationForFlag("ranges", "--ranges accepts at most 100 entries; got %d", len(ranges))
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -437,8 +432,7 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
}
prototype = map[string]interface{}{"data_validation": validation}
}
ops := make([]interface{}, 0, len(ranges))
var totalCells int64
var ops []interface{}
for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil {
@@ -448,13 +442,6 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
if err != nil {
return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err)
}
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
return nil, err
}
totalCells += int64(rows) * int64(cols)
if err := checkBatchStampBudget(totalCells); err != nil {
return nil, err
}
cells := fillCellsMatrix(rows, cols, prototype)
ops = append(ops, map[string]interface{}{
"tool_name": "set_cell_range",
@@ -474,25 +461,6 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
// ─── helpers resurrected from B3 (used here + future skills) ──────────
// maxBatchRanges caps how many ranges a fan-out batch (+cells-batch-set-style /
// +cells-batch-clear / +dropdown-update / +dropdown-delete) may carry, bounding
// the number of ops materialized into one batch_update.
const maxBatchRanges = 100
// checkBatchStampBudget rejects a fan-out batch whose ranges materialize more
// than maxStampMatrixCells cells in aggregate. A batch builds every range's
// cells matrix up front, so the SUM across ranges is the real peak-memory bound
// — the per-range checkStampMatrixBudget alone can't stop many ranges from
// summing past it. totalCells is int64 to stay overflow-safe.
func checkBatchStampBudget(totalCells int64) error {
if totalCells > maxStampMatrixCells {
return sheetsValidationForFlag("ranges",
"ranges expand to %d cells total, over the %d-cell safety cap; reduce the number or size of ranges",
totalCells, maxStampMatrixCells)
}
return nil
}
// validateDropdownRanges parses --ranges, requires every entry to carry a
// sheet prefix, and returns the parsed list.
func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
@@ -522,9 +490,6 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
}
out = append(out, s)
}
if len(out) > maxBatchRanges {
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(out))
}
return out, nil
}

View File

@@ -1,105 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_changeset ─────────────────────────────────────────────
//
// +changeset-get wraps the get_changeset read tool: fetch the raw changeset
// (the list of edit actions) between two CS revisions of a spreadsheet, so a
// human or reviewing agent can verify whether an AI edit actually fulfilled
// the user's request.
//
// - --start-revision is the "before" baseline (required, >= 1).
// - --end-revision is optional; when omitted it defaults to the latest
// revision, returning every changeset from start up to now.
// - The version gap is capped at 20 (end - start + 1 <= 20); the same cap
// is enforced server-side (sheet-facade-agg maxChangesetRevGap).
const changesetMaxRevGap = 20
// ChangesetGet fetches the raw changesets between two spreadsheet versions.
var ChangesetGet = common.Shortcut{
Service: "sheets",
Command: "+changeset-get",
Description: "Fetch the raw changeset (edit actions) between two versions, to review whether an AI edit fulfilled the request.",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+changeset-get"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, _, err := changesetRevisions(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
input, _ := changesetInput(runtime)
return invokeToolDryRun(token, ToolKindRead, "get_changeset", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
input, err := changesetInput(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_changeset", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Pass only --start-revision to diff against the latest version; add --end-revision to bound the range.",
"The version gap is capped at 20 revisions (end - start + 1 <= 20).",
},
}
// changesetRevisions reads and validates the start / end revision flags.
// end <= 0 means "not provided" (default to latest, resolved server-side); a
// provided end must be >= start and within the 20-revision gap.
func changesetRevisions(runtime flagView) (start int, end int, err error) {
start = runtime.Int("start-revision")
end = runtime.Int("end-revision")
if start < 1 {
return 0, 0, sheetsValidationForFlag("start-revision", "--start-revision must be >= 1")
}
if end > 0 {
if end < start {
return 0, 0, sheetsValidationForFlag("end-revision", "--end-revision (%d) must be >= --start-revision (%d)", end, start)
}
if end-start+1 > changesetMaxRevGap {
return 0, 0, sheetsValidationForFlag("end-revision", "version gap exceeds limit %d (start=%d, end=%d)", changesetMaxRevGap, start, end)
}
}
return start, end, nil
}
// changesetInput builds the get_changeset tool input. end_revision is only
// sent when explicitly provided; otherwise the server defaults to latest.
func changesetInput(runtime flagView) (map[string]interface{}, error) {
start, end, err := changesetRevisions(runtime)
if err != nil {
return nil, err
}
input := map[string]interface{}{
"start_revision": start,
}
if end > 0 {
input["end_revision"] = end
}
return input, nil
}

View File

@@ -1,86 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// TestChangesetGet_DryRun locks the get_changeset tool input: --end-revision
// is only sent when explicitly provided, otherwise the server defaults to the
// latest revision.
func TestChangesetGet_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
wantInput map[string]interface{}
}{
{
name: "start + end bounded range",
args: []string{"--url", testURL, "--start-revision", "120", "--end-revision", "135"},
wantInput: map[string]interface{}{
"start_revision": float64(120),
"end_revision": float64(135),
},
},
{
name: "start only → end omitted (server defaults to latest)",
args: []string{"--url", testURL, "--start-revision", "120"},
wantInput: map[string]interface{}{
"start_revision": float64(120),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChangesetGet, tt.args)
got := decodeToolInput(t, body, "get_changeset")
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestChangesetGet_Validation covers the client-side revision guards, which
// mirror the server cap (sheet-facade-agg maxChangesetRevGap = 20).
func TestChangesetGet_Validation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
wantSub string
}{
{
name: "start-revision must be >= 1",
args: []string{"--url", testURL, "--start-revision", "0"},
wantSub: "start-revision must be >= 1",
},
{
name: "end before start rejected",
args: []string{"--url", testURL, "--start-revision", "100", "--end-revision", "50"},
wantSub: "end-revision",
},
{
name: "gap over 20 rejected",
args: []string{"--url", testURL, "--start-revision", "1", "--end-revision", "30"},
wantSub: "version gap exceeds limit",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, ChangesetGet, append(c.args, "--dry-run"))
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), c.wantSub) {
t.Errorf("expected %q; got=%s|%s|%v", c.wantSub, stdout, stderr, err)
}
})
}
}

View File

@@ -1,167 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_formula_verify ───────────────────────────────────────
//
// Wraps verify_formula (read): scan formulas + cell error states across one
// or more sub-sheets and aggregate Excel errors (#REF! / #DIV/0! / #VALUE! /
// #NAME? / #NULL! / #NUM! / #N/A) plus compile failures (formula_errors)
// into a recalc.py-shaped JSON status report. The contract is the single
// AI self-check entry point for the R10 "write → verify zero-error"
// invariant — see canonical-spec/references/lark_sheet_formula_verify/.
// FormulaVerify wraps verify_formula. Sheet selection is optional (both
// --sheet-id and --sheet-name are repeatable); when omitted, the tool scans
// every visible sub-sheet's current_region.
var FormulaVerify = common.Shortcut{
Service: "sheets",
Command: "+formula-verify",
Description: "Scan formulas / cell errors and return a recalc.py-shaped status report (success / errors_found / partial).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+formula-verify"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
if err := validateFormulaVerifySheetSelector(runtime); err != nil {
return err
}
return validateFormulaVerifyLimits(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
return invokeToolDryRun(token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token))
if err != nil {
return err
}
runtime.Out(out, nil)
if runtime.Bool("exit-on-error") {
return formulaVerifyExitOnError(out)
}
return nil
},
}
// validateFormulaVerifySheetSelector enforces XOR-like guarantees on the
// two multi-value selectors: at most one of --sheet-id / --sheet-name may be
// non-empty (passing both is the high-frequency reflex confusion when the
// caller cargo-cults the single-sheet shortcut signature). Both empty is the
// documented "scan every visible sub-sheet" path. Control-char checks reuse
// requireSheetSelector's logic on each item.
func validateFormulaVerifySheetSelector(runtime *common.RuntimeContext) error {
ids := nonEmptySliceItems(runtime.StrSlice("sheet-id"))
names := nonEmptySliceItems(runtime.StrSlice("sheet-name"))
if len(ids) > 0 && len(names) > 0 {
return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive; pick one selector to identify sub-sheets").
WithParams(
sheetsInvalidParam("sheet-id", "mutually exclusive"),
sheetsInvalidParam("sheet-name", "mutually exclusive"),
)
}
for _, id := range ids {
if err := requireSheetSelector(id, ""); err != nil {
return err
}
}
for _, name := range names {
if err := requireSheetSelector("", name); err != nil {
return err
}
}
return nil
}
// validateFormulaVerifyLimits rejects non-positive caps so a misplaced 0 or
// negative flag value can't silently degrade the scan (the server-side
// default would otherwise mask the typo).
func validateFormulaVerifyLimits(runtime *common.RuntimeContext) error {
if runtime.Changed("max-locations") && runtime.Int("max-locations") <= 0 {
return sheetsValidationForFlag("max-locations", "--max-locations must be > 0")
}
return nil
}
// nonEmptySliceItems trims and drops blanks from a repeated-flag value so
// `--sheet-id ""` doesn't masquerade as a real entry.
func nonEmptySliceItems(in []string) []string {
out := make([]string, 0, len(in))
for _, v := range in {
if trimmed := strings.TrimSpace(v); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
// formulaVerifyInput builds the verify_formula tool input map from CLI flags.
// excel_id is required; everything else is optional per the schema.
func formulaVerifyInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
input := map[string]interface{}{
"excel_id": token,
}
if ids := nonEmptySliceItems(runtime.StrSlice("sheet-id")); len(ids) > 0 {
input["sheet_ids"] = ids
} else if names := nonEmptySliceItems(runtime.StrSlice("sheet-name")); len(names) > 0 {
// The verify_formula schema only declares sheet_ids; the facade
// accepts sheet_names as a parallel optional field so name-based
// selection works without forcing the caller to pre-resolve. Mirrors
// how the other read shortcuts pack both fields via
// sheetSelectorForToolInput.
input["sheet_names"] = names
}
if ranges := nonEmptySliceItems(runtime.StrSlice("range")); len(ranges) > 0 {
input["ranges"] = ranges
}
if runtime.Changed("max-locations") {
input["max_locations_per_error"] = runtime.Int("max-locations")
}
return input
}
// formulaVerifyExitOnError converts a verify_formula status into a non-zero
// CLI exit when the caller passed --exit-on-error. status="errors_found"
// is the only failure mode for this flag: "partial" means truncated but the
// scanned slice is clean, and "success" is obviously clean. A missing /
// unknown status is treated as a typed internal error because the tool's
// schema guarantees the field and we don't want a silent zero-exit.
func formulaVerifyExitOnError(out interface{}) error {
m, ok := out.(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"verify_formula: missing status field in tool output")
}
status, _ := m["status"].(string)
switch status {
case "success", "partial":
return nil
case "errors_found":
total, _ := util.ToFloat64(m["total_errors"])
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"verify_formula: %d formula error(s) detected; resolve and re-run", int(total)).
WithHint("inspect error_summary[*] / compile_errors[*] in the JSON output, fix or wrap with IFERROR, then re-run +formula-verify until status=success")
default:
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"verify_formula: unexpected status %q", status)
}
}

View File

@@ -1,213 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
// TestFormulaVerify_DryRun pins the wire shape verify_formula sends for the
// common input combinations: no selector (workbook-wide scan), explicit
// sheet_ids, explicit ranges, and the optional max_locations_per_error
// field. The test exercises the One-OpenAPI body
// directly so the schema field names stay locked to the canonical
// tool-schemas.json verify_formula node.
func TestFormulaVerify_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
wantInput map[string]interface{}
}{
{
name: "no selector — workbook-wide scan defaults",
args: []string{"--url", testURL},
wantInput: map[string]interface{}{
"excel_id": testToken,
},
},
{
name: "sheet_ids multi via repeat",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--sheet-id", testSheetID2},
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_ids": []interface{}{testSheetID, testSheetID2},
},
},
{
name: "sheet_names multi via comma",
args: []string{"--url", testURL, "--sheet-name", "Sheet1,Sheet2"},
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_names": []interface{}{"Sheet1", "Sheet2"},
},
},
{
name: "ranges + max_locations",
args: []string{
"--url", testURL,
"--range", "A1:Z200",
"--range", "AA1:AZ100",
"--max-locations", "5",
},
wantInput: map[string]interface{}{
"excel_id": testToken,
"ranges": []interface{}{"A1:Z200", "AA1:AZ100"},
"max_locations_per_error": float64(5),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, FormulaVerify, tt.args)
got := decodeToolInput(t, body, "verify_formula")
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestFormulaVerify_DryRunInvokeReadPath confirms the request hits
// invoke_read (read scope) and not invoke_write — a scope mismatch here would
// surface as a 403 from the gateway.
func TestFormulaVerify_DryRunInvokeReadPath(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, FormulaVerify, []string{"--url", testURL})
if len(calls) == 0 {
t.Fatalf("dry-run produced no api calls")
}
call, _ := calls[0].(map[string]interface{})
url, _ := call["url"].(string)
if !strings.HasSuffix(url, "/tools/invoke_read") {
t.Errorf("verify_formula must hit invoke_read; got url=%q", url)
}
if want := "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read"; url != want {
t.Errorf("url = %q, want %q", url, want)
}
}
// TestFormulaVerify_RejectsBothSelectors locks the "at most one selector"
// rule on the two multi-value flags. Both empty is the documented
// workbook-wide scan path, so we only reject the both-supplied case.
func TestFormulaVerify_RejectsBothSelectors(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, FormulaVerify, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--sheet-name", "Sheet1",
"--dry-run",
})
ve := requireValidation(t, err, "mutually exclusive")
gotParams := map[string]bool{}
for _, p := range ve.Params {
gotParams[p.Name] = true
}
if !gotParams["--sheet-id"] || !gotParams["--sheet-name"] {
t.Errorf("params = %#v, want both --sheet-id and --sheet-name flagged", ve.Params)
}
}
// TestFormulaVerify_RejectsNonPositiveLimits guards against typos like
// `--max-locations 0`, which would otherwise be silently swallowed by the
// "explicit value but unset" comparison in the input builder.
func TestFormulaVerify_RejectsNonPositiveLimits(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
want string
}{
{
name: "max-locations=0",
args: []string{"--url", testURL, "--max-locations", "0"},
want: "--max-locations must be > 0",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, FormulaVerify, append(c.args, "--dry-run"))
requireValidation(t, err, c.want)
})
}
}
// TestFormulaVerifyExitOnError_StatusMatrix locks the --exit-on-error
// contract: success/partial → no error; errors_found → typed validation
// error with SubtypeFailedPrecondition; missing or unknown status →
// typed internal error so a silent zero-exit can never happen.
func TestFormulaVerifyExitOnError_StatusMatrix(t *testing.T) {
t.Parallel()
t.Run("success returns no error", func(t *testing.T) {
t.Parallel()
if err := formulaVerifyExitOnError(map[string]interface{}{"status": "success"}); err != nil {
t.Fatalf("success path returned err: %v", err)
}
})
t.Run("partial returns no error", func(t *testing.T) {
t.Parallel()
if err := formulaVerifyExitOnError(map[string]interface{}{"status": "partial", "has_more": true}); err != nil {
t.Fatalf("partial path returned err: %v", err)
}
})
t.Run("errors_found yields failed_precondition with count", func(t *testing.T) {
t.Parallel()
err := formulaVerifyExitOnError(map[string]interface{}{
"status": "errors_found",
"total_errors": float64(7),
})
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if !strings.Contains(ve.Message, "7 formula error") {
t.Errorf("message %q must surface the error count", ve.Message)
}
if ve.Hint == "" {
t.Errorf("hint must be set so AI agents know to re-run after fixes")
}
})
t.Run("unknown status maps to internal/invalid_response", func(t *testing.T) {
t.Parallel()
err := formulaVerifyExitOnError(map[string]interface{}{"status": "weird"})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype)
}
})
t.Run("non-object output maps to internal/invalid_response", func(t *testing.T) {
t.Parallel()
err := formulaVerifyExitOnError("oops")
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype)
}
})
}

View File

@@ -1,97 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_history (BE-1: +history-list) ─────────────────────────
//
// Wraps the facade-agg `history_list` tool (read) behind the One-OpenAPI
// invoke_read endpoint. The tool returns a sheet's version history. The
// facade-agg tool already performs the response transform (minor_histories
// trim / id → history_version_id / 4-field projection / RFC3339 create_time),
// so the CLI passes the tool output straight through and does NOT re-implement
// the transform client-side.
//
// History is workbook-level (no sheet selector), mirroring +workbook-info:
// the only locator is --url / --spreadsheet-token (XOR), with --token accepted
// as a parse-time alias for --spreadsheet-token via the shared PostMount hook.
//
// Flags are declared inline here rather than via flagsFor(): the generated
// flag_defs_gen.go / data/flag-defs.json are synced from sheet-skill-spec
// (BE-3) and must not be hand-edited, so this hand-written shortcut owns its
// own flag set. The two locator flags match +workbook-info's shape exactly.
// historyLocatorFlags is the --url / --spreadsheet-token XOR locator pair
// shared by the three history shortcuts. Mirrors +workbook-info's flag-defs
// entry; XOR is enforced in Validate via parseSpreadsheetRef, not by Required.
func historyLocatorFlags() []common.Flag {
return []common.Flag{
{Name: "url", Type: "string", Desc: "Spreadsheet locator (a /sheets/ or /wiki/ URL)."},
{Name: "spreadsheet-token", Type: "string", Desc: "Spreadsheet locator (raw spreadsheet token)."},
}
}
// HistoryList wraps the history_list tool: list a spreadsheet's history
// versions. Each item carries history_version_id / create_time / action /
// all_block_revision (projected server-side). An empty sheet yields an empty
// list and exit 0.
//
// Backward pagination: --end-version (optional int) maps to the tool's
// `end_version` parameter. Omit on the first call to fetch the latest page.
// On subsequent pages pass the previous response's next_end_version as
// --end-version. The tool returns next_end_version + has_more only when
// more history exists; both fields are absent at the earliest page.
var HistoryList = common.Shortcut{
Service: "sheets",
Command: "+history-list",
Description: "List a spreadsheet's edit history versions (history_version_id, create_time, action, all_block_revision).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: append(historyLocatorFlags(),
common.Flag{Name: "end-version", Type: "int", Desc: "Max version to query (descending pagination). Omit on the first call; pass the previous response's next_end_version on subsequent pages."},
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := resolveSpreadsheetToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
return invokeToolDryRun(token, ToolKindRead, "history_list", historyListInput(runtime, token))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "history_list", historyListInput(runtime, token))
if err != nil {
return err
}
// Pass the tool output through verbatim — facade-agg already shaped it.
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Capture a history_version_id from the result to feed +history-revert.",
"For older history, capture next_end_version from the response and pass it as --end-version on the next call (omitted by the server when the earliest page is reached).",
},
}
// historyListInput composes the history_list tool input. --end-version is
// optional: include it only when explicitly set so the server treats absence
// as "first page (latest)".
func historyListInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
in := map[string]interface{}{"excel_id": token}
if runtime.Changed("end-version") {
in["end_version"] = runtime.Int("end-version")
}
return in
}

View File

@@ -1,197 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_history (BE-2: +history-revert / +history-revert-status) ──
//
// Two thin callTool wrappers over the facade-agg history tools:
// - +history-revert → history_revert (write) — async revert
// - +history-revert-status → history_revert_status (read) — poll outcome
//
// Both target a single history version via --history-version-id (the id
// surfaced by +history-list). Revert is asynchronous: it returns a receipt /
// transaction id that +history-revert-status then polls, distinguishing
// in-progress / success / failure from the tool output (passed through
// verbatim — no client-side shaping).
//
// ⚠️ Backend state: the facade-agg history_revert / history_revert_status
// tools are registered but their downstream RPC wiring is a DEFERRED
// follow-up; today they return a "not wired yet" guard error from the gateway,
// which surfaces here as a normal tool error. These CLI shortcuts are correct
// thin wrappers and will work end-to-end once the backend follow-up lands —
// this is NOT a CLI blocker. See self_check.md.
//
// Flags are declared inline (historyLocatorFlags + history-version-id) rather
// than via flagsFor(), because flag_defs_gen.go / data/flag-defs.json are
// synced from sheet-skill-spec (BE-3) and must not be hand-edited.
// historyVersionIDFlag is the target-version selector shared by +history-revert.
// Required at the cli surface (cobra MarkFlagRequired): a missing value yields
// cobra's standard "required flag(s) \"history-version-id\" not set" message
// before Validate runs. We still trim + reject control-chars in Validate to
// reject empty strings ("--history-version-id "" "), which cobra accepts.
func historyVersionIDFlag() common.Flag {
return common.Flag{
Name: "history-version-id",
Type: "string",
Required: true,
Desc: "History version to act on (from +history-list).",
}
}
func historyRevertFlags() []common.Flag {
return append(historyLocatorFlags(), historyVersionIDFlag())
}
// validateHistoryVersionID enforces the required, control-char-clean
// --history-version-id. Returns the trimmed value so callers reuse it.
func validateHistoryVersionID(runtime *common.RuntimeContext) (string, error) {
id := strings.TrimSpace(runtime.Str("history-version-id"))
if id == "" {
return "", sheetsValidationForFlag("history-version-id", "--history-version-id is required")
}
return id, nil
}
func historyRevertInput(token, versionID string) map[string]interface{} {
return map[string]interface{}{
"excel_id": token,
"history_version_id": versionID,
}
}
// transactionIDFlag is the async-revert receipt selector used by
// +history-revert-status: the transaction_id returned by +history-revert (NOT a
// history version id — the facade-agg status tool keys on transaction_id).
// Required at the cli surface (cobra MarkFlagRequired) — same gating model as
// historyVersionIDFlag. Validate still trims + rejects empty/control-char
// values to catch the case where cobra accepts --transaction-id with an
// empty-string value.
func transactionIDFlag() common.Flag {
return common.Flag{
Name: "transaction-id",
Type: "string",
Required: true,
Desc: "Async revert transaction id (from +history-revert).",
}
}
func historyRevertStatusFlags() []common.Flag {
return append(historyLocatorFlags(), transactionIDFlag())
}
// validateTransactionID enforces the required, trimmed --transaction-id and
// returns it for reuse.
func validateTransactionID(runtime *common.RuntimeContext) (string, error) {
id := strings.TrimSpace(runtime.Str("transaction-id"))
if id == "" {
return "", sheetsValidationForFlag("transaction-id", "--transaction-id is required")
}
return id, nil
}
func historyRevertStatusInput(token, transactionID string) map[string]interface{} {
return map[string]interface{}{
"excel_id": token,
"transaction_id": transactionID,
}
}
// HistoryRevert wraps the history_revert tool (write): asynchronously revert a
// spreadsheet to the given history version. --history-version-id is required
// at the cli surface (cobra MarkFlagRequired); a missing flag fails before
// Validate runs with cobra's standard "required flag(s)" error (which the
// dispatcher classifies as a typed *errs.ValidationError, exit 2). We still
// trim + reject empty / control-char values in Validate to catch the
// case where cobra accepts --history-version-id with an empty-string value.
var HistoryRevert = common.Shortcut{
Service: "sheets",
Command: "+history-revert",
Description: "Revert a spreadsheet to a given history version (asynchronous; poll with +history-revert-status).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: historyRevertFlags(),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, err := validateHistoryVersionID(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
versionID := strings.TrimSpace(runtime.Str("history-version-id"))
return invokeToolDryRun(token, ToolKindWrite, "history_revert", historyRevertInput(token, versionID))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
versionID, err := validateHistoryVersionID(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "history_revert", historyRevertInput(token, versionID))
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Revert is asynchronous — pass the returned id to +history-revert-status to track in-progress / success / failure.",
},
}
// HistoryRevertStatus wraps the history_revert_status tool (read): poll the
// outcome of a prior +history-revert. The tool output distinguishes
// in-progress / success / failure and is passed through verbatim.
var HistoryRevertStatus = common.Shortcut{
Service: "sheets",
Command: "+history-revert-status",
Description: "Poll the status of a history revert (in-progress / success / failure).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: historyRevertStatusFlags(),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, err := validateTransactionID(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
txnID := strings.TrimSpace(runtime.Str("transaction-id"))
return invokeToolDryRun(token, ToolKindRead, "history_revert_status", historyRevertStatusInput(token, txnID))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
txnID, err := validateTransactionID(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "history_revert_status", historyRevertStatusInput(token, txnID))
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}

View File

@@ -1,167 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
// TestHistoryShortcuts_DryRun asserts each history shortcut targets the right
// facade-agg tool, routes through the correct read/write invoke endpoint, and
// builds the expected tool input (excel_id always; history_version_id for the
// revert pair).
func TestHistoryShortcuts_DryRun(t *testing.T) {
t.Parallel()
const versionID = "histVER123"
const txnID = "txn-abc-123"
tests := []struct {
name string
sc common.Shortcut
args []string
toolName string
wantPath string // invoke_read | invoke_write suffix
wantInput map[string]interface{}
}{
{
name: "+history-list via --url",
sc: HistoryList,
args: []string{"--url", testURL},
toolName: "history_list",
wantPath: "invoke_read",
wantInput: map[string]interface{}{
"excel_id": testToken,
},
},
{
name: "+history-list via --spreadsheet-token",
sc: HistoryList,
args: []string{"--spreadsheet-token", testToken},
toolName: "history_list",
wantPath: "invoke_read",
wantInput: map[string]interface{}{
"excel_id": testToken,
},
},
{
name: "+history-list paginates with --end-version",
sc: HistoryList,
args: []string{"--url", testURL, "--end-version", "12345"},
toolName: "history_list",
wantPath: "invoke_read",
wantInput: map[string]interface{}{
"excel_id": testToken,
"end_version": float64(12345), // post-JSON-unmarshal numeric type
},
},
{
name: "+history-revert routes to invoke_write with version id",
sc: HistoryRevert,
args: []string{"--url", testURL, "--history-version-id", versionID},
toolName: "history_revert",
wantPath: "invoke_write",
wantInput: map[string]interface{}{
"excel_id": testToken,
"history_version_id": versionID,
},
},
{
name: "+history-revert-status routes to invoke_read with transaction id",
sc: HistoryRevertStatus,
args: []string{"--url", testURL, "--transaction-id", txnID},
toolName: "history_revert_status",
wantPath: "invoke_read",
wantInput: map[string]interface{}{
"excel_id": testToken,
"transaction_id": txnID,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
callURL := dryRunFirstCallURL(t, tt.sc, tt.args)
if !containsSuffix(callURL, tt.wantPath) {
t.Errorf("invoke url = %q, want suffix %q", callURL, tt.wantPath)
}
body := parseDryRunBody(t, tt.sc, tt.args)
got := decodeToolInput(t, body, tt.toolName)
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestHistoryRevert_MissingRequiredFlag asserts each shortcut rejects a
// missing required selector before any request is sent, with two distinct
// gates by design:
//
// - +history-revert: --history-version-id is cobra-required (Required=true
// in the flag def → MarkFlagRequired). cobra refuses the call before
// Validate runs with a plain "required flag(s)" error; the cmd dispatcher
// classifies it as a typed *errs.ValidationError (invalid_argument, exit 2).
// The test rig invokes the shortcut via cmd.Execute and observes the raw
// cobra error directly (no dispatcher wrap), so we assert the cobra text
// contract instead of the typed envelope.
//
// - +history-revert-status: --transaction-id is cobra-optional;
// requiredness is enforced inside Validate so we still get a typed,
// flag-tagged *errs.ValidationError with Param="--transaction-id".
func TestHistoryRevert_MissingRequiredFlag(t *testing.T) {
t.Parallel()
t.Run(HistoryRevert.Command, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, HistoryRevert, []string{"--url", testURL})
if err == nil {
t.Fatalf("%s: expected error for missing --history-version-id", HistoryRevert.Command)
}
msg := err.Error()
if !strings.Contains(msg, "required flag(s)") || !strings.Contains(msg, "history-version-id") {
t.Fatalf("%s: cobra error = %q, want substrings 'required flag(s)' and 'history-version-id'", HistoryRevert.Command, msg)
}
})
t.Run(HistoryRevertStatus.Command, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, HistoryRevertStatus, []string{"--url", testURL})
if err == nil {
t.Fatalf("%s: expected error for missing --transaction-id", HistoryRevertStatus.Command)
}
msg := err.Error()
if !strings.Contains(msg, "required flag(s)") || !strings.Contains(msg, "transaction-id") {
t.Fatalf("%s: cobra error = %q, want substrings 'required flag(s)' and 'transaction-id'", HistoryRevertStatus.Command, msg)
}
})
}
// dryRunFirstCallURL runs the shortcut in --dry-run and returns the first
// api call's url, so tests can assert read vs. write endpoint routing.
func dryRunFirstCallURL(t *testing.T, sc common.Shortcut, args []string) string {
t.Helper()
out, err := runShortcut(t, sc, append(args, "--dry-run"))
if err != nil {
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
}
dryRun := decodeDryRunRaw(t, out)
calls, ok := dryRun["api"].([]interface{})
if !ok || len(calls) == 0 {
t.Fatalf("dry-run api array empty or wrong shape: %#v", dryRun)
}
call, _ := calls[0].(map[string]interface{})
url, _ := call["url"].(string)
return url
}
func containsSuffix(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@@ -1,83 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_revision_get ───────────────────────────────────────────
//
// RevisionGet is a read-only derivative over get_workbook_structure that
// projects out only the document revision (version number). The backend
// surfaces `revision` on every read/write tool response, so this shortcut
// needs no dedicated backend tool — it issues the lightest existing read
// (no range, just the workbook token) and narrows the payload to the single
// field callers want.
//
// The revision is the anchor for recover / undo. Callers that have just run a
// write already have it in that write's response; +revision-get is the
// explicit, zero-side-effect way to fetch the current value on its own.
var RevisionGet = common.Shortcut{
Service: "sheets",
Command: "+revision-get",
Description: "Get the spreadsheet's current document revision (version number).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+revision-get"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := resolveSpreadsheetToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
return invokeToolDryRun(token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
"excel_id": token,
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
"excel_id": token,
})
if err != nil {
return err
}
rev, err := projectRevision(out)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"revision": rev}, nil)
return nil
},
Tips: []string{
"The revision is the version anchor for recover / undo; every read and write tool response already carries it.",
},
}
// projectRevision narrows a get_workbook_structure response to its `revision`
// field. An absent revision means the backend predates revision injection on
// read responses; surface that as an explicit error rather than emitting a
// silent null.
func projectRevision(out interface{}) (interface{}, error) {
obj, ok := out.(map[string]interface{})
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"get_workbook_structure returned non-object output")
}
rev, ok := obj["revision"]
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"get_workbook_structure did not return a revision (backend may not support it yet)")
}
return rev, nil
}

View File

@@ -1,37 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import "testing"
func TestRevisionGetProjectRevision(t *testing.T) {
t.Parallel()
t.Run("extracts revision from a workbook-structure object", func(t *testing.T) {
out := map[string]interface{}{
"revision": float64(60),
"sheets": []interface{}{map[string]interface{}{"sheet_id": "Nh34WX"}},
}
got, err := projectRevision(out)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != float64(60) {
t.Errorf("revision = %v, want 60", got)
}
})
t.Run("errors when revision is absent", func(t *testing.T) {
out := map[string]interface{}{"sheets": []interface{}{}}
if _, err := projectRevision(out); err == nil {
t.Error("expected an error when revision is missing, got nil")
}
})
t.Run("errors on a non-object output", func(t *testing.T) {
if _, err := projectRevision("not-an-object"); err == nil {
t.Error("expected an error for non-object output, got nil")
}
})
}

View File

@@ -483,11 +483,11 @@ func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) (map[
func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error) {
s = strings.TrimSpace(s)
if s == "" {
return "", 0, 0, fmt.Errorf("range is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
return "", 0, 0, fmt.Errorf("range is empty")
}
parts := strings.Split(s, ":")
if len(parts) > 2 {
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element")
}
dim1, idx1, err := parseA1Position(parts[0])
if err != nil {
@@ -501,10 +501,10 @@ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error)
return "", 0, 0, err
}
if dim1 != dim2 {
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range")
}
if idx2 < idx1 {
return "", 0, 0, fmt.Errorf("end position is before start") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
return "", 0, 0, fmt.Errorf("end position is before start")
}
return dim1, idx1, idx2, nil
}
@@ -515,7 +515,7 @@ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error)
func parseA1Position(s string) (dimension string, idx int, err error) {
s = strings.TrimSpace(s)
if s == "" {
return "", 0, fmt.Errorf("position is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
return "", 0, fmt.Errorf("position is empty")
}
isDigits := true
isLetters := true
@@ -530,14 +530,14 @@ func parseA1Position(s string) (dimension string, idx int, err error) {
if isDigits {
n, _ := strconv.Atoi(s)
if n <= 0 {
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s)
}
return "row", n - 1, nil
}
if isLetters {
return "column", letterToColumnIndex(s), nil
}
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s)
}
// columnIndexToLetter converts a 0-based column index to the spreadsheet

View File

@@ -382,32 +382,6 @@ func (p *tablePayload) validate() error {
return common.ValidationErrorf("--sheets[%d] %q: mode %q is invalid (want \"overwrite\" or \"append\")", i, s.Name, s.Mode)
}
}
return p.checkCellBudget()
}
// maxTablePutCells bounds how many cells a single +table-put / +workbook-create
// write may materialize. Unlike the fan-out stamp cap (maxStampMatrixCells),
// these cells come from the caller's own --sheets/--values payload rather than a
// range blow-up, so this is a generous OOM guardrail, not a usability limit:
// buildSheetMatrix builds the whole rows×cols matrix of per-cell maps in memory
// before slicing it into tablePutMaxCellsPerWrite-sized writes, so an unbounded
// payload (2.6M cells ≈ 900MB heap, doubled again by json.Marshal) OOMs the
// process before the first write leaves.
const maxTablePutCells = 1_000_000
// checkCellBudget rejects a payload whose total materialized cell count across
// all sheets exceeds maxTablePutCells. Counted in int64 to stay overflow-safe on
// pathological row/column counts.
func (p *tablePayload) checkCellBudget() error {
var total int64
for i := range p.Sheets {
total += int64(len(p.Sheets[i].Rows)) * int64(len(p.Sheets[i].Columns))
}
if total > maxTablePutCells {
return common.ValidationErrorf(
"--sheets/--values cover %d cells total, over the %d-cell safety cap; split the write across smaller payloads",
total, maxTablePutCells)
}
return nil
}
@@ -669,8 +643,7 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
if err != nil {
return nil, err
}
matrix, err = applyWorkbookCreateStylesToMatrix(matrix, styles, col0, baseRow, fmt.Sprintf("--styles for sheet %q", s.Name))
if err != nil {
if err := applyWorkbookCreateStylesToMatrix(matrix, styles, col0, baseRow, fmt.Sprintf("--styles for sheet %q", s.Name)); err != nil {
return nil, err
}
@@ -682,15 +655,11 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
}, nil
}
// styles can pad the matrix wider than the declared columns (cell_styles on
// blank cells past the data extent), so the written width comes from the
// padded matrix, not ncols.
writeCols := len(matrix[0])
startCol := columnIndexToLetter(col0)
endCol := columnIndexToLetter(col0 + writeCols - 1)
endCol := columnIndexToLetter(col0 + ncols - 1)
allowOverwrite := s.AllowOverwrite == nil || *s.AllowOverwrite
rowsPerBatch := tablePutMaxCellsPerWrite / writeCols
rowsPerBatch := tablePutMaxCellsPerWrite / ncols
if rowsPerBatch < 1 {
rowsPerBatch = 1
}
@@ -724,7 +693,7 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
"sheet_id": sheetID,
"range": fmt.Sprintf("%s%d:%s%d", startCol, baseRow+1, endCol, baseRow+len(matrix)),
"data_rows": len(s.Rows),
"columns": writeCols,
"columns": ncols,
"writes": writes,
"mode": writeModeName(s),
}, nil
@@ -816,7 +785,7 @@ func writeTypedSheets(ctx context.Context, runtime *common.RuntimeContext, token
s := &payload.Sheets[i]
sheetID, ok := byName[s.Name]
if !ok {
rows, cols := sheetCreateDims(s, styles.styleFor(i))
rows, cols := sheetCreateDims(s)
sheetID, err = createSheet(ctx, runtime, token, s.Name, rows, cols)
if err != nil {
return written, fmt.Errorf("creating sheet %q failed: %w", s.Name, err) //nolint:forbidigo // intermediate error; surfaced as a partial_success message string via tablePutPartial, not a typed final error
@@ -895,12 +864,10 @@ func createSheet(ctx context.Context, runtime *common.RuntimeContext, token, nam
// sheetCreateDims sizes a to-be-created sheet to the spec's write range so the
// follow-up set_cell_range can't exceed sheet bounds. It accounts for the
// start_cell offset, the optional header row, and any --styles extent (so a
// cell_styles / merge / resize op past the data still fits the grid). The
// backend's 20×200 defaults are kept as floors (ordinary small tables are
// created exactly as before) and its hard limits (200 cols, 50000 rows) as
// ceilings.
func sheetCreateDims(s *tableSheetSpec, styles *workbookCreateStylePayload) (rows, cols int) {
// start_cell offset and the optional header row. The backend's 20×200 defaults
// are kept as floors (ordinary small tables are created exactly as before) and
// its hard limits (200 cols, 50000 rows) as ceilings.
func sheetCreateDims(s *tableSheetSpec) (rows, cols int) {
_, col0, row0, _ := sheetAnchor(s)
cols = col0 + len(s.Columns)
rows = row0 + len(s.Rows)
@@ -915,19 +882,6 @@ func sheetCreateDims(s *tableSheetSpec, styles *workbookCreateStylePayload) (row
if headerOn(s) || (s.Mode == "append" && s.Header == nil) {
rows++
}
// --styles can reach past the data (cell_styles on blank cells get padded
// into the matrix and written; merges / resizes run as separate ops). Size
// the grid to cover them too. workbookCreateStyleDimensions returns the
// extent relative to the anchor, so add the anchor offset back.
if styles != nil {
styleRows, styleCols := workbookCreateStyleDimensions(styles, col0, row0)
if col0+styleCols > cols {
cols = col0 + styleCols
}
if row0+styleRows > rows {
rows = row0 + styleRows
}
}
if cols < 20 {
cols = 20
}
@@ -1025,6 +979,8 @@ func tablePutDryRun(runtime *common.RuntimeContext) *common.DryRunAPI {
for i := range payload.Sheets {
s := &payload.Sheets[i]
matrix, _ := buildSheetMatrix(s, headerOn(s))
desc := fmt.Sprintf("write sheet %q (%d data rows × %d cols, mode=%s) via set_cell_range",
s.Name, len(s.Rows), len(s.Columns), writeModeName(s))
rng := tablePutFullRange(s, len(matrix))
if s.Mode == "append" {
rng = "<append below existing data>"
@@ -1032,23 +988,10 @@ func tablePutDryRun(runtime *common.RuntimeContext) *common.DryRunAPI {
// cell_styles are merged into the matrix only for overwrite mode,
// where the anchor row is known statically; append's base row is
// resolved at execute time, so the preview leaves the matrix bare
// (the merges / sizes ops below still render). Padding can widen /
// lengthen the matrix past the data, so recompute the range from the
// padded dims to match what Execute writes.
// (the merges / sizes ops below still render).
_, col0, row0, _ := sheetAnchor(s)
matrix, _ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
if len(matrix) > 0 {
rng = fmt.Sprintf("%s%d:%s%d",
columnIndexToLetter(col0), row0+1,
columnIndexToLetter(col0+len(matrix[0])-1), row0+len(matrix))
}
_ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
}
writeCols := len(s.Columns)
if len(matrix) > 0 {
writeCols = len(matrix[0])
}
desc := fmt.Sprintf("write sheet %q (%d data rows × %d cols, mode=%s) via set_cell_range",
s.Name, len(s.Rows), writeCols, writeModeName(s))
input := map[string]interface{}{
"excel_id": token,
"sheet_name": s.Name,

View File

@@ -537,15 +537,14 @@ func TestTablePut_SheetCreateDims(t *testing.T) {
cases := []struct {
name string
spec tableSheetSpec
styles *workbookCreateStylePayload
wantRows, wantCols int
}{
{"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, nil, 200, 20},
{"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, nil, 200, 37},
{"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, nil, 501, 20},
{"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, nil, 200, 42},
{"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, nil, 500, 20},
{"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, nil, 200, 200},
{"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, 200, 20},
{"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, 200, 37},
{"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, 501, 20},
{"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, 200, 42},
{"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, 500, 20},
{"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, 200, 200},
// Default headerOn() is false for append mode, but writeSheetData forces
// a header when append hits an empty sheet with no explicit Header
// choice (so column names aren't lost). sheetCreateDims runs only on
@@ -553,22 +552,14 @@ func TestTablePut_SheetCreateDims(t *testing.T) {
// match: append + Header=nil ⇒ +1 row. Otherwise an append-near-50000
// payload would be created one row short.
{"append on new sheet sizes for the forced header row (49999 data rows + 1 header = 50000)",
tableSheetSpec{Mode: "append", Columns: cols(3), Rows: rows(49999)}, nil, 50000, 20},
tableSheetSpec{Mode: "append", Columns: cols(3), Rows: rows(49999)}, 50000, 20},
{"append + Header=false (explicit) does NOT add the forced header row",
tableSheetSpec{Mode: "append", Header: bp(false), Columns: cols(3), Rows: rows(50)}, nil, 200, 20},
// --styles reaching past the data grows the grid so a cell_styles op on a
// blank cell (or a merge / resize) still fits after the create.
{"styles past the data grow the grid",
tableSheetSpec{Columns: cols(3), Rows: rows(5)},
&workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{{Range: "A1:Z400"}}}, 400, 26},
{"styles inside the data don't shrink the grid",
tableSheetSpec{Columns: cols(3), Rows: rows(5)},
&workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{{Range: "A1:B2"}}}, 200, 20},
tableSheetSpec{Mode: "append", Header: bp(false), Columns: cols(3), Rows: rows(50)}, 200, 20},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotRows, gotCols := sheetCreateDims(&tt.spec, tt.styles)
gotRows, gotCols := sheetCreateDims(&tt.spec)
if gotRows != tt.wantRows || gotCols != tt.wantCols {
t.Errorf("sheetCreateDims = (%d rows, %d cols), want (%d, %d)", gotRows, gotCols, tt.wantRows, tt.wantCols)
}

View File

@@ -123,26 +123,6 @@ func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, e
if strings.TrimSpace(runtime.Str("title")) == "" {
return nil, common.ValidationErrorf("--title is required")
}
// --type bitable 建一张空白多维表格子表operation=create_bitable默认 sheet 为普通
// 电子表格子表。bitable 子表内容编辑走 lark-base 命令row-count/col-count 不适用。
sheetType := strings.TrimSpace(runtime.Str("type"))
if sheetType == "" {
sheetType = "sheet"
}
if sheetType != "sheet" && sheetType != "bitable" {
return nil, common.ValidationErrorf("--type must be 'sheet' or 'bitable'")
}
if sheetType == "bitable" {
input := map[string]interface{}{
"excel_id": token,
"operation": "create_bitable",
"sheet_name": strings.TrimSpace(runtime.Str("title")),
}
if runtime.Changed("index") {
input["target_index"] = runtime.Int("index")
}
return input, nil
}
if n := runtime.Int("row-count"); n < 0 || n > 50000 {
return nil, common.ValidationErrorf("--row-count must be between 0 and 50000")
}
@@ -654,26 +634,16 @@ var WorkbookCreate = common.Shortcut{
s := &payload.Sheets[i]
matrix, _ := buildSheetMatrix(s, headerOn(s))
_, col0, row0, _ := sheetAnchor(s)
matrix, _ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
// Padding can widen / lengthen the matrix past the data, so build the
// range from the padded dims to match what Execute writes.
rng := tablePutFullRange(s, len(matrix))
writeCols := len(s.Columns)
if len(matrix) > 0 {
writeCols = len(matrix[0])
rng = fmt.Sprintf("%s%d:%s%d",
columnIndexToLetter(col0), row0+1,
columnIndexToLetter(col0+writeCols-1), row0+len(matrix))
}
_ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
input := map[string]interface{}{
"excel_id": "<new-token>",
"sheet_name": s.Name,
"range": rng,
"range": tablePutFullRange(s, len(matrix)),
"cells": matrix,
}
wireBody, _ := buildToolBody("set_cell_range", input)
dry.POST("/open-apis/sheet_ai/v2/spreadsheets/<new-token>/tools/invoke_write").
Desc(fmt.Sprintf("write sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), writeCols)).
Desc(fmt.Sprintf("write sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), len(s.Columns))).
Body(wireBody)
appendWorkbookCreateVisualOpsDryRun(dry, "<new-token>", "", s.Name, sheetStyles.styleFor(i))
}
@@ -866,19 +836,13 @@ func buildValuesPayload(runtime flagView, sheetStyles *workbookCreateSheetStyles
cols[i] = tableColumnSpec{Name: fmt.Sprintf("col%d", i+1)} // type-less
}
noHeader := false
payload := &tablePayload{Sheets: []tableSheetSpec{{
return &tablePayload{Sheets: []tableSheetSpec{{
Name: valuesSheetName,
Mode: "overwrite",
Header: &noHeader,
Columns: cols,
Rows: rows,
}}}
// --values bypasses tablePayload.validate(), so enforce the cell budget here
// too — otherwise a giant --values array materializes unbounded.
if err := payload.checkCellBudget(); err != nil {
return nil, err
}
return payload, nil
}}}, nil
}
// parseValuesRows decodes --values (JSON 2D array, with @file/stdin already
@@ -1282,7 +1246,7 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
func workbookCreateCellStyleField(name string) bool {
switch name {
case "font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
case "font_color", "font_size", "font_weight", "font_style", "font_line",
"background_color", "horizontal_alignment", "vertical_alignment",
"number_format", "word_wrap":
return true
@@ -1393,79 +1357,20 @@ func workbookCreateStyleDimensions(styles *workbookCreateStylePayload, baseCol,
return rows, cols
}
// padMatrixForStyles grows the matrix down and right so it covers every
// cell_styles range, appending empty cells for the positions the data doesn't
// reach. cell_styles are applied by writing into the matrix in place (see
// applyWorkbookCreateStylesToMatrix), so a style on a cell past the data extent
// needs a real — if empty — cell to attach to. This lets a create/write set
// styles on blank cells (reserved regions, decorative headers, empty borders).
//
// Only cell_styles contribute to the extent: cell_merges / row_sizes / col_sizes
// run as separate API calls (see workbookCreateVisualOps) and never touch the
// matrix. Ranges that start left of baseCol or above baseRow are skipped here
// (the matrix can only extend down/right) and left for the caller to reject.
// The (possibly reallocated) matrix is returned; callers must use the result.
func padMatrixForStyles(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int) [][]interface{} {
func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int, label string) error {
if styles == nil {
return rows
return nil
}
needRows := len(rows)
needCols := 0
if len(rows) > 0 {
needCols = len(rows[0])
}
for _, op := range styles.CellStyles {
startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range)
if err != nil || startCol < baseCol || startRow < baseRow {
continue // unparsable, or up/left of the anchor: not paddable
}
if endCol-baseCol+1 > needCols {
needCols = endCol - baseCol + 1
}
if endRow-baseRow+1 > needRows {
needRows = endRow - baseRow + 1
}
}
// Widen existing rows to needCols.
for r := range rows {
for len(rows[r]) < needCols {
rows[r] = append(rows[r], map[string]interface{}{})
}
}
// Append full empty rows to reach needRows.
for len(rows) < needRows {
row := make([]interface{}, needCols)
for c := range row {
row[c] = map[string]interface{}{}
}
rows = append(rows, row)
}
return rows
}
// applyWorkbookCreateStylesToMatrix pads the matrix to cover the cell_styles
// ranges (see padMatrixForStyles), merges each op's style into the covered
// cells, and returns the padded matrix. A range that starts left of / above the
// write anchor can't be padded to and is rejected.
func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int, label string) ([][]interface{}, error) {
if styles == nil {
return rows, nil
}
rows = padMatrixForStyles(rows, styles, baseCol, baseRow)
for i, op := range styles.CellStyles {
startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range)
if err != nil {
return rows, common.ValidationErrorf("%s[%d].range %q: %v", label, i, op.Range, err)
return common.ValidationErrorf("%s[%d].range %q: %v", label, i, op.Range, err)
}
// After padding, the matrix reaches every range that starts at or after
// the anchor; a start left of / above it can't be covered. The endRow /
// endCol checks stay as a defensive backstop (padding should have made
// them unreachable).
if startCol < baseCol || startRow < baseRow || len(rows) == 0 ||
endRow-baseRow >= len(rows) || endCol-baseCol >= len(rows[0]) {
return rows, common.ValidationErrorf("%s[%d].range %q starts outside the write range (its top-left must be at or after %s%d)",
if startCol < baseCol || startRow < baseRow || endRow-baseRow >= len(rows) || len(rows) == 0 || endCol-baseCol >= len(rows[0]) {
return common.ValidationErrorf("%s[%d].range %q is outside the write range %s%d:%s%d",
label, i, op.Range,
columnIndexToLetter(baseCol), baseRow+1)
columnIndexToLetter(baseCol), baseRow+1,
columnIndexToLetter(baseCol+len(rows[0])-1), baseRow+len(rows))
}
for r := startRow - baseRow; r <= endRow-baseRow; r++ {
for c := startCol - baseCol; c <= endCol-baseCol; c++ {
@@ -1473,7 +1378,7 @@ func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCre
}
}
}
return rows, nil
return nil
}
func appendWorkbookCreateVisualOpsDryRun(dry *common.DryRunAPI, token, sheetID, sheetName string, styles *workbookCreateStylePayload) {

View File

@@ -396,37 +396,6 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
t.Errorf("horizontal_alignment occurrences = %d, want 4 in 2x2 range; cells=%s", got, raw)
}
})
t.Run("cell style past the data pads empty cells so blank cells can be styled", func(t *testing.T) {
t.Parallel()
// Data is a single cell (A1), but the style targets A1:C3 — the matrix is
// padded to 3x3 with empty cells so the style range fits, letting blank
// cells carry styling. The written range must reflect the padded extent.
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "X",
"--values", `[["a"]]`,
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:C3","background_color":"#FFEEAA"}]}]}`,
})
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
if input["range"] != "A1:C3" {
t.Errorf("range = %v, want A1:C3 (padded to the style extent)", input["range"])
}
cells, _ := input["cells"].([]interface{})
if len(cells) != 3 {
t.Fatalf("cells rows = %d, want 3 (padded); cells=%#v", len(cells), input["cells"])
}
lastRow, _ := cells[2].([]interface{})
if len(lastRow) != 3 {
t.Fatalf("last row width = %d, want 3 (padded)", len(lastRow))
}
// A blank padded cell (C3) still carries the style.
c3, _ := lastRow[2].(map[string]interface{})
c3s, _ := c3["cell_styles"].(map[string]interface{})
if c3s["background_color"] != "#FFEEAA" {
t.Errorf("padded blank cell C3 style = %#v, want background_color", c3)
}
})
t.Run("style-only payload (cell_merges) still fills and emits merge_cells", func(t *testing.T) {
t.Parallel()
// Previously workbookCreateStyleDimensions only counted cell_styles, so a
@@ -626,60 +595,3 @@ func deepEqualJSON(a, b interface{}) bool {
}
return a == b
}
// TestApplyWorkbookCreateStylesToMatrix covers the pad-then-style behavior
// directly: a style range past the data grows the matrix with empty cells (so
// blank cells can be styled), an in-range style leaves the matrix size alone,
// and a range up/left of the anchor — which padding can't reach — is rejected.
func TestApplyWorkbookCreateStylesToMatrix(t *testing.T) {
t.Parallel()
cell := func() interface{} { return map[string]interface{}{} }
t.Run("pads down and right for a style past the data", func(t *testing.T) {
t.Parallel()
matrix := [][]interface{}{{cell()}} // 1x1 data
styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{
{Range: "A1:C3", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"background_color": "#FFEEAA"}}},
}}
out, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 0, 0, "--styles")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out) != 3 || len(out[0]) != 3 || len(out[2]) != 3 {
t.Fatalf("padded matrix = %d rows x %v cols, want 3x3", len(out), out)
}
// A blank padded corner (C3) carries the style.
c3, _ := out[2][2].(map[string]interface{})
c3s, _ := c3["cell_styles"].(map[string]interface{})
if c3s["background_color"] != "#FFEEAA" {
t.Errorf("padded cell C3 = %#v, want background_color", c3)
}
})
t.Run("no pad when the style is within the data", func(t *testing.T) {
t.Parallel()
matrix := [][]interface{}{{cell(), cell()}, {cell(), cell()}} // 2x2 data
styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{
{Range: "A1:B2", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"font_weight": "bold"}}},
}}
out, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 0, 0, "--styles")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out) != 2 || len(out[0]) != 2 {
t.Errorf("matrix = %dx%d, want 2x2 (no pad)", len(out), len(out[0]))
}
})
t.Run("rejects a range up/left of the anchor", func(t *testing.T) {
t.Parallel()
matrix := [][]interface{}{{cell()}} // anchored at C3 (col 2, row 2)
styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{
{Range: "A1", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"font_weight": "bold"}}},
}}
_, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 2, 2, "--styles")
if err == nil || !strings.Contains(err.Error(), "starts outside the write range") {
t.Errorf("err = %v, want 'starts outside the write range'", err)
}
})
}

View File

@@ -111,10 +111,10 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
// CellsSetStyle stamps a single style block across every cell in --range.
// Style is composed from a dozen flat flags (background-color, font-color,
// font-family, font-size, font-style, font-weight, font-line,
// horizontal-alignment, vertical-alignment, word-wrap, number-format) plus
// --border-styles for the only field that still needs a nested object. At
// least one flag must be set.
// font-size, font-style, font-weight, font-line, horizontal-alignment,
// vertical-alignment, word-wrap, number-format) plus --border-styles for
// the only field that still needs a nested object. At least one flag must
// be set.
var CellsSetStyle = common.Shortcut{
Service: "sheets",
Command: "+cells-set-style",
@@ -165,9 +165,6 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
if err != nil {
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
}
if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil {
return nil, err
}
if err := requireAnyStyleFlag(runtime); err != nil {
return nil, err
}
@@ -453,9 +450,6 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s
if err != nil {
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
}
if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil {
return nil, err
}
validation, err := buildDropdownValidation(runtime)
if err != nil {
return nil, err
@@ -631,23 +625,23 @@ func rangeDimensions(rangeStr string) (rows, cols int, err error) {
}
rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" {
return 0, 0, fmt.Errorf("empty range") //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
return 0, 0, fmt.Errorf("empty range")
}
parts := strings.SplitN(rangeStr, ":", 2)
if len(parts) == 1 {
// single cell, e.g. "A1"
if _, _, ok := splitCellRef(parts[0]); !ok {
return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0])
}
return 1, 1, nil
}
startCol, startRow, ok1 := splitCellRef(parts[0])
endCol, endRow, ok2 := splitCellRef(parts[1])
if !ok1 || !ok2 {
return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr)
}
if endRow < startRow || endCol < startCol {
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0])
}
return endRow - startRow + 1, endCol - startCol + 1, nil
}
@@ -698,30 +692,9 @@ func letterToColumnIndex(letters string) int {
return n - 1
}
// maxStampMatrixCells bounds how many per-cell maps a fan-out / stamp shortcut
// will materialize from a single A1 range. The backing tools take an explicit
// cells matrix, so the CLI must expand a range like "A1:Z100000" into rows×cols
// maps before sending it — an unbounded blow-up (2.6M cells ≈ 900MB heap, then
// doubled again by json.Marshal) that OOMs the process before the request even
// leaves. 200000 matches the documented --max-cells safety cap.
const maxStampMatrixCells = 200000
// checkStampMatrixBudget rejects a range whose materialized cell count would
// exceed maxStampMatrixCells, before fillCellsMatrix allocates it. rows*cols is
// computed in int64 to stay safe against overflow on pathological ranges.
func checkStampMatrixBudget(flagName, rangeStr string, rows, cols int) error {
if total := int64(rows) * int64(cols); total > maxStampMatrixCells {
return sheetsValidationForFlag(flagName,
"range %q covers %d cells, over the %d-cell safety cap; narrow the range or split it across smaller ranges",
rangeStr, total, maxStampMatrixCells)
}
return nil
}
// fillCellsMatrix returns a rows×cols matrix where every cell is the same
// (shallow-copied) prototype map. Use for fan-out shortcuts that stamp a
// single attribute (style / data_validation) across an entire range.
// Callers MUST gate the dimensions through checkStampMatrixBudget first.
func fillCellsMatrix(rows, cols int, prototype map[string]interface{}) [][]interface{} {
cells := make([][]interface{}, rows)
for r := range cells {

View File

@@ -25,8 +25,8 @@ import (
// 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_" or
// "local_office_" synthetic token and must upload with "office_sheet_file".
// 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 {
@@ -36,12 +36,9 @@ func TestSheetMediaParentType(t *testing.T) {
}{
{"native spreadsheet token", "shtcnABC123", sheetImageParentType},
{"empty token", "", sheetImageParentType},
{"fake_office imported token", "fake_office_abc123", officeSheetFileParentType},
{"fake_office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType},
{"local_office imported token", "local_office_abc123", officeSheetFileParentType},
{"local_office token, only the prefix", localOfficeTokenPrefix, officeSheetFileParentType},
{"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
{"local_office prefix mid-string is not matched", "shtlocal_office_abc", 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) {
@@ -65,8 +62,7 @@ func TestUploadSheetImage_ParentType(t *testing.T) {
wantParentType string
}{
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
{"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
{"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType},
{"office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {

View File

@@ -1,272 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"io"
"runtime"
"strings"
"testing"
)
// These benchmarks back the memory review of the sheets fan-out / download
// paths. They measure two hot spots:
//
// 1. fillCellsMatrix — fan-out shortcuts (+cells-set-style, +dropdown-set,
// +cells-batch-set-style, +dropdown-update) expand one A1 range into a
// rows×cols matrix of per-cell maps. A tiny input string ("A1:Z100000")
// explodes into millions of heap maps with no upper bound.
//
// 2. the export-download reader — strings.NewReader(string(rawBody)) copies
// the whole downloaded file once more before saving it.
//
// Run: go test ./shortcuts/sheets -run XXX -bench 'FillCellsMatrix|DownloadReader' -benchmem
var styleProto = map[string]interface{}{
"cell_styles": map[string]interface{}{"bold": true, "fg_color": "#FF0000"},
"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "solid"}},
}
func benchFillCellsMatrix(b *testing.B, rows, cols int) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
m := fillCellsMatrix(rows, cols, styleProto)
if len(m) != rows {
b.Fatalf("bad matrix")
}
}
}
func BenchmarkFillCellsMatrix_100(b *testing.B) { benchFillCellsMatrix(b, 10, 10) } // A1:J10
func BenchmarkFillCellsMatrix_10K(b *testing.B) { benchFillCellsMatrix(b, 1000, 10) } // A1:J1000
func BenchmarkFillCellsMatrix_100K(b *testing.B) { benchFillCellsMatrix(b, 10000, 10) } // A1:J10000
func BenchmarkFillCellsMatrix_2600K(b *testing.B) { benchFillCellsMatrix(b, 100000, 26) } // A1:Z100000
// TestFanoutMatrixPeakMemory reports the concrete resident-heap delta of
// materializing a large fan-out matrix, so the review doc can quote real MB.
// Not an assertion — it prints numbers under `go test -v -run PeakMemory`.
func TestFanoutMatrixPeakMemory(t *testing.T) {
if testing.Short() {
t.Skip("skipping memory probe in -short")
}
cases := []struct {
name string
rows, cols int
}{
{"A1:Z10000 (260K cells)", 10000, 26},
{"A1:Z100000 (2.6M cells)", 100000, 26},
}
for _, c := range cases {
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
m := fillCellsMatrix(c.rows, c.cols, styleProto)
runtime.ReadMemStats(&after)
runtime.KeepAlive(m)
t.Logf("%-26s heap +%6.1f MB (%d total allocs)",
c.name,
float64(after.HeapAlloc-before.HeapAlloc)/(1024*1024),
after.Mallocs-before.Mallocs)
}
}
// --- +table-put / +workbook-create matrix materialization (sibling #1 path) ---
//
// buildSheetMatrix turns the caller's --sheets/--values into a rows×cols matrix
// of per-cell maps, the same unbounded blow-up as fillCellsMatrix but on the
// table-put ingress (tablePutMaxCellsPerWrite only slices the *write*, not this
// in-memory build). checkCellBudget rejects oversized payloads before this runs.
func makeTypelessSpec(rows, cols int) *tableSheetSpec {
c := make([]tableColumnSpec, cols)
r := make([][]interface{}, rows)
for i := range r {
row := make([]interface{}, cols)
for j := range row {
row[j] = "x"
}
r[i] = row
}
return &tableSheetSpec{Columns: c, Rows: r}
}
func benchBuildSheetMatrix(b *testing.B, rows, cols int) {
spec := makeTypelessSpec(rows, cols)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
m, err := buildSheetMatrix(spec, true)
if err != nil || len(m) != rows+1 {
b.Fatalf("bad matrix")
}
}
}
func BenchmarkBuildSheetMatrix_100K(b *testing.B) { benchBuildSheetMatrix(b, 10000, 10) } // 100K cells
func BenchmarkBuildSheetMatrix_2600K(b *testing.B) { benchBuildSheetMatrix(b, 100000, 26) } // 2.6M cells
// TestTablePutMatrixPeakMemory reports the resident-heap delta of materializing
// a large table-put matrix (the cost checkCellBudget now prevents), so the
// review doc can quote real MB. Not an assertion — prints under -v -run PeakMemory.
func TestTablePutMatrixPeakMemory(t *testing.T) {
if testing.Short() {
t.Skip("skipping memory probe in -short")
}
for _, c := range []struct {
name string
rows, cols int
}{
{"100000×26 (2.6M cells)", 100000, 26},
} {
spec := makeTypelessSpec(c.rows, c.cols)
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
m, _ := buildSheetMatrix(spec, true)
runtime.ReadMemStats(&after)
runtime.KeepAlive(m)
t.Logf("%-24s buildSheetMatrix heap +%6.1f MB (%d total allocs)",
c.name,
float64(after.HeapAlloc-before.HeapAlloc)/(1024*1024),
after.Mallocs-before.Mallocs)
}
}
// --- export-download reader copy ---
func benchDownloadReader(b *testing.B, size int, useStringCopy bool) {
raw := bytes.Repeat([]byte("x"), size)
sink := make([]byte, 32*1024)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var r io.Reader
if useStringCopy {
r = strings.NewReader(string(raw)) // current code: extra full-size copy
} else {
r = bytes.NewReader(raw) // fix: no copy
}
for {
if _, err := r.Read(sink); err != nil {
break
}
}
}
}
// --- fan-out cell-budget cap (fix for the unbounded matrix blow-up) ---
func TestStampMatrixBudgetCap(t *testing.T) {
// 199992 cells (7692×26) sits just under the 200000 cap → allowed.
if err := checkStampMatrixBudget("range", "A1:Z7692", 7692, 26); err != nil {
t.Fatalf("199992 cells should pass, got: %v", err)
}
// Exactly at the cap → allowed.
if err := checkStampMatrixBudget("range", "A1:A200000", 200000, 1); err != nil {
t.Fatalf("200000 cells (== cap) should pass, got: %v", err)
}
// Just over the cap → rejected.
if err := checkStampMatrixBudget("range", "A1:A200001", 200001, 1); err == nil {
t.Fatal("200001 cells should be rejected")
}
// The pathological case from the review (2.6M cells) → rejected.
if err := checkStampMatrixBudget("ranges", "Sheet1!A1:Z100000", 100000, 26); err == nil {
t.Fatal("2.6M-cell fan-out should be rejected")
}
}
// --- sibling cap gaps: +table-put/+workbook-create payload, batch aggregate,
// batch-update operation count (follow-up to the single fan-out cap) ---
// TestTablePutCellBudgetCap covers the --sheets/--values materialization cap:
// buildSheetMatrix builds the whole matrix in memory, so the total cell count is
// bounded before that allocation, summed across all sheets.
func TestTablePutCellBudgetCap(t *testing.T) {
// 1000×1000 = 1,000,000 == cap → allowed.
atCap := &tablePayload{Sheets: []tableSheetSpec{{
Columns: make([]tableColumnSpec, 1000),
Rows: make([][]interface{}, 1000),
}}}
if err := atCap.checkCellBudget(); err != nil {
t.Fatalf("1,000,000 cells (== cap) should pass, got: %v", err)
}
// 1000×1001 = 1,001,000 > cap → rejected.
over := &tablePayload{Sheets: []tableSheetSpec{{
Columns: make([]tableColumnSpec, 1000),
Rows: make([][]interface{}, 1001),
}}}
if err := over.checkCellBudget(); err == nil {
t.Fatal("1,001,000 cells should be rejected")
}
// Budget is summed across sheets, not per-sheet: 600k + 600k = 1.2M > cap.
twoSheets := &tablePayload{Sheets: []tableSheetSpec{
{Columns: make([]tableColumnSpec, 1000), Rows: make([][]interface{}, 600)},
{Columns: make([]tableColumnSpec, 1000), Rows: make([][]interface{}, 600)},
}}
if err := twoSheets.checkCellBudget(); err == nil {
t.Fatal("1.2M cells across two sheets should be rejected")
}
}
// TestBatchStampAggregateCap covers the batch fan-out aggregate budget — the
// per-range cap can't stop many ranges from summing past the matrix ceiling.
func TestBatchStampAggregateCap(t *testing.T) {
if err := checkBatchStampBudget(maxStampMatrixCells); err != nil {
t.Fatalf("aggregate == cap should pass, got: %v", err)
}
if err := checkBatchStampBudget(maxStampMatrixCells + 1); err == nil {
t.Fatal("aggregate over cap should be rejected")
}
}
// TestBatchFanoutRangeCountCap drives a fan-out shortcut with > maxBatchRanges
// ranges and expects the shared validateDropdownRanges cap to reject it.
func TestBatchFanoutRangeCountCap(t *testing.T) {
ranges := make([]string, maxBatchRanges+1)
for i := range ranges {
ranges[i] = "sheet1!A1"
}
rangesJSON, _ := json.Marshal(ranges)
_, _, err := runShortcutCapturingErr(t, CellsBatchSetStyle, []string{
"--url", testURL,
"--ranges", string(rangesJSON),
"--font-weight", "bold",
"--dry-run",
})
requireValidation(t, err, "at most")
}
// TestBatchOperationsCountCap covers the +batch-update sub-operation count cap.
func TestBatchOperationsCountCap(t *testing.T) {
ops := make([]interface{}, maxBatchOperations+1)
for i := range ops {
ops[i] = map[string]interface{}{"shortcut": "+cells-set", "input": map[string]interface{}{}}
}
_, err := translateBatchOperations(ops, testURL)
if err == nil || !strings.Contains(err.Error(), "at most") {
t.Fatalf("expected operations count cap error, got: %v", err)
}
}
// BenchmarkStampBudget_RejectsOversized is the "after" side of the fix: the same
// A1:Z100000 input that BenchmarkFillCellsMatrix_2600K shows costing ~917MB /
// 5.3M allocs is now rejected up front, allocating only the error string.
func BenchmarkStampBudget_RejectsOversized(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if err := checkStampMatrixBudget("range", "A1:Z100000", 100000, 26); err == nil {
b.Fatal("expected rejection")
}
}
}
func BenchmarkDownloadReader_StringCopy_1MB(b *testing.B) { benchDownloadReader(b, 1<<20, true) }
func BenchmarkDownloadReader_BytesNoCopy_1MB(b *testing.B) { benchDownloadReader(b, 1<<20, false) }
func BenchmarkDownloadReader_StringCopy_16MB(b *testing.B) { benchDownloadReader(b, 16<<20, true) }
func BenchmarkDownloadReader_BytesNoCopy_16MB(b *testing.B) {
benchDownloadReader(b, 16<<20, false)
}

View File

@@ -70,7 +70,6 @@ func shortcutList() []common.Shortcut {
return []common.Shortcut{
// lark_sheet_workbook
WorkbookInfo,
RevisionGet,
SheetCreate,
SheetDelete,
SheetRename,
@@ -96,9 +95,6 @@ func shortcutList() []common.Shortcut {
DimUngroup,
DimMove,
// lark_sheet_changeset
ChangesetGet,
// lark_sheet_read_data
CellsGet,
CsvGet,
@@ -109,9 +105,6 @@ func shortcutList() []common.Shortcut {
CellsSearch,
CellsReplace,
// lark_sheet_formula_verify
FormulaVerify,
// lark_sheet_write_cells
CellsSet,
CellsSetStyle,
@@ -155,10 +148,5 @@ func shortcutList() []common.Shortcut {
CellsBatchClear,
DropdownUpdate,
DropdownDelete,
// lark_sheet_history
HistoryList,
HistoryRevert,
HistoryRevertStatus,
}
}

View File

@@ -14,5 +14,8 @@ func Shortcuts() []common.Shortcut {
SlidesReplacePages,
SlidesScreenshot,
SlidesXMLGet,
SlidesHistoryList,
SlidesHistoryRevert,
SlidesHistoryRevertStatus,
}
}

View File

@@ -0,0 +1,299 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type slidesHistoryListSpec struct {
PageSize int
PageToken string
}
type slidesHistoryRevertSpec struct {
HistoryVersionID string
WaitTimeoutMs int
}
type slidesHistoryRevertStatusSpec struct {
TaskID string
}
func parseSlidesHistoryPresentation(runtime *common.RuntimeContext) (presentationRef, error) {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return presentationRef{}, err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return presentationRef{}, err
}
}
return ref, nil
}
func validateSlidesHistoryPageSize(pageSize int) error {
if pageSize < 1 || pageSize > 20 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
}
return nil
}
func validateSlidesHistoryVersionID(historyVersionID string) error {
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
if err != nil || version <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by slides +history-list").WithParam("--history-version-id")
}
return nil
}
func validateSlidesHistoryWaitTimeout(timeoutMs int) error {
if timeoutMs < 0 || timeoutMs > 30000 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
}
return nil
}
func slidesHistoryListParams(spec slidesHistoryListSpec) map[string]interface{} {
params := map[string]interface{}{
"page_size": spec.PageSize,
}
if spec.PageToken != "" {
params["page_token"] = spec.PageToken
}
return params
}
func slidesHistoryRevertBody(spec slidesHistoryRevertSpec) map[string]interface{} {
return map[string]interface{}{
"history_version_id": spec.HistoryVersionID,
"wait_timeout_ms": spec.WaitTimeoutMs,
}
}
func slidesHistoryStatusParams(spec slidesHistoryRevertStatusSpec) map[string]interface{} {
return map[string]interface{}{
"task_id": spec.TaskID,
}
}
func slidesHistoryAPIPath(presentationID, suffix string) string {
return fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/%s", validate.EncodePathSegment(presentationID), suffix)
}
func newSlidesHistoryDryRun(ref presentationRef, desc string) (*common.DryRunAPI, string) {
dry := common.NewDryRunAPI()
presentationID := ref.Token
if ref.Kind == "wiki" {
presentationID = "<resolved_slides_token>"
dry.Desc("2-step orchestration: resolve wiki then " + desc).
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("OpenAPI: " + desc)
}
return dry, presentationID
}
// SlidesHistoryList lists history versions of a Slides XML presentation.
var SlidesHistoryList = common.Shortcut{
Service: "slides",
Command: "+history-list",
Description: "List Slides presentation history versions",
Risk: "read",
Scopes: []string{"slides:presentation:read"},
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: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseSlidesHistoryPresentation(runtime); err != nil {
return err
}
return validateSlidesHistoryPageSize(runtime.Int("page-size"))
},
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())
}
spec := slidesHistoryListSpec{
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
}
dry, presentationID := newSlidesHistoryDryRun(ref, "list Slides history versions")
return dry.
GET(slidesHistoryAPIPath(presentationID, "histories")).
Params(slidesHistoryListParams(spec)).
Set("xml_presentation_id", presentationID)
},
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
}
spec := slidesHistoryListSpec{
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
}
data, err := runtime.CallAPITyped(
http.MethodGet,
slidesHistoryAPIPath(presentationID, "histories"),
slidesHistoryListParams(spec),
nil,
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}
// SlidesHistoryRevert reverts a Slides XML presentation to a history version.
var SlidesHistoryRevert = common.Shortcut{
Service: "slides",
Command: "+history-revert",
Description: "Revert a Slides presentation to a historical version",
Risk: "write",
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
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: "history-version-id", Desc: "history_version_id from slides +history-list to revert to", Required: true},
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseSlidesHistoryPresentation(runtime); err != nil {
return err
}
if err := validateSlidesHistoryVersionID(runtime.Str("history-version-id")); err != nil {
return err
}
return validateSlidesHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
},
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())
}
spec := slidesHistoryRevertSpec{
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
}
dry, presentationID := newSlidesHistoryDryRun(ref, "revert Slides history")
return dry.
POST(slidesHistoryAPIPath(presentationID, "history/revert")).
Body(slidesHistoryRevertBody(spec)).
Set("xml_presentation_id", presentationID)
},
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
}
spec := slidesHistoryRevertSpec{
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
}
data, err := runtime.CallAPITyped(
http.MethodPost,
slidesHistoryAPIPath(presentationID, "history/revert"),
nil,
slidesHistoryRevertBody(spec),
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}
// SlidesHistoryRevertStatus gets the status of a Slides history revert task.
var SlidesHistoryRevertStatus = common.Shortcut{
Service: "slides",
Command: "+history-revert-status",
Description: "Get Slides history revert task status",
Risk: "read",
Scopes: []string{"slides:presentation:read"},
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: "task-id", Desc: "task_id returned by slides +history-revert", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseSlidesHistoryPresentation(runtime); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("task-id")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-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())
}
spec := slidesHistoryRevertStatusSpec{
TaskID: strings.TrimSpace(runtime.Str("task-id")),
}
dry, presentationID := newSlidesHistoryDryRun(ref, "get Slides history revert status")
return dry.
GET(slidesHistoryAPIPath(presentationID, "history/revert_status")).
Params(slidesHistoryStatusParams(spec)).
Set("xml_presentation_id", presentationID)
},
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
}
spec := slidesHistoryRevertStatusSpec{
TaskID: strings.TrimSpace(runtime.Str("task-id")),
}
data, err := runtime.CallAPITyped(
http.MethodGet,
slidesHistoryAPIPath(presentationID, "history/revert_status"),
slidesHistoryStatusParams(spec),
nil,
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}

View File

@@ -0,0 +1,440 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"reflect"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestSlidesHistoryDeclaredScopes(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantBase []string
wantFull []string
}{
{
name: "list",
shortcut: SlidesHistoryList,
wantBase: []string{"slides:presentation:read"},
wantFull: []string{"slides:presentation:read", "wiki:node:read"},
},
{
name: "revert",
shortcut: SlidesHistoryRevert,
wantBase: []string{"slides:presentation:update", "slides:presentation:write_only"},
wantFull: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"},
},
{
name: "status",
shortcut: SlidesHistoryRevertStatus,
wantBase: []string{"slides:presentation:read"},
wantFull: []string{"slides:presentation:read", "wiki:node:read"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.shortcut.ScopesForIdentity("user"); !reflect.DeepEqual(got, tt.wantBase) {
t.Fatalf("user preflight scopes = %#v, want %#v", got, tt.wantBase)
}
if got := tt.shortcut.ScopesForIdentity("bot"); !reflect.DeepEqual(got, tt.wantBase) {
t.Fatalf("bot preflight scopes = %#v, want %#v", got, tt.wantBase)
}
if got := tt.shortcut.DeclaredScopesForIdentity("user"); !reflect.DeepEqual(got, tt.wantFull) {
t.Fatalf("declared scopes = %#v, want %#v", got, tt.wantFull)
}
})
}
}
func TestSlidesHistoryValidation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
shortcut common.Shortcut
args []string
param string
}{
{
name: "list rejects unsupported presentation input",
shortcut: SlidesHistoryList,
args: []string{"+history-list", "--presentation", "tmp/wiki/wikcn123", "--as", "bot"},
param: "--presentation",
},
{
name: "list rejects invalid page size",
shortcut: SlidesHistoryList,
args: []string{"+history-list", "--presentation", "presHistory", "--page-size", "0", "--as", "bot"},
param: "--page-size",
},
{
name: "revert rejects invalid history version id",
shortcut: SlidesHistoryRevert,
args: []string{"+history-revert", "--presentation", "presHistory", "--history-version-id", "0", "--as", "bot"},
param: "--history-version-id",
},
{
name: "revert rejects invalid wait timeout",
shortcut: SlidesHistoryRevert,
args: []string{"+history-revert", "--presentation", "presHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
param: "--wait-timeout-ms",
},
{
name: "status rejects empty task id",
shortcut: SlidesHistoryRevertStatus,
args: []string{"+history-revert-status", "--presentation", "presHistory", "--task-id", "", "--as", "bot"},
param: "--task-id",
},
}
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, tt.shortcut, tt.args)
if err == nil {
t.Fatal("expected validation error, got nil")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if validationErr.Param != tt.param {
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
}
})
}
}
func TestSlidesHistoryDryRun(t *testing.T) {
t.Parallel()
listCmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryList, map[string]string{
"presentation": "presHistoryDryRun",
"page-size": "5",
"page-token": "page_token_1",
})
listDry := decodeSlidesHistoryDryRun(t, SlidesHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
if got, want := listDry.API[0].URL, "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/histories"; got != want {
t.Fatalf("list dry-run URL = %q, want %q", got, want)
}
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
t.Fatalf("list page_size = %d, want 5", got)
}
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
t.Fatalf("list page_token = %#v, want page_token_1", got)
}
revertCmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryRevert, map[string]string{
"presentation": "presHistoryDryRun",
"history-version-id": "42",
"wait-timeout-ms": "30000",
})
revertDry := decodeSlidesHistoryDryRun(t, SlidesHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
if got, want := revertDry.API[0].URL, "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/history/revert"; got != want {
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
}
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
t.Fatalf("revert history_version_id = %#v, want 42", got)
}
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
}
statusCmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryRevertStatus, map[string]string{
"presentation": "presHistoryDryRun",
"task-id": "task_1",
})
statusDry := decodeSlidesHistoryDryRun(t, SlidesHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
if got, want := statusDry.API[0].URL, "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/history/revert_status"; got != want {
t.Fatalf("status dry-run URL = %q, want %q", got, want)
}
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
t.Fatalf("status task_id = %#v, want task_1", got)
}
}
func TestSlidesHistoryDryRunWithWikiPresentation(t *testing.T) {
t.Parallel()
cmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryList, map[string]string{
"presentation": "https://example.feishu.cn/wiki/wikcn123",
"page-size": "20",
})
dry := decodeSlidesHistoryDryRun(t, SlidesHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil)))
if len(dry.API) != 2 {
t.Fatalf("api calls = %d, want 2: %#v", len(dry.API), dry.API)
}
if got, want := dry.API[0].URL, "/open-apis/wiki/v2/spaces/get_node"; got != want {
t.Fatalf("wiki dry-run URL = %q, want %q", got, want)
}
if got := dry.API[0].Params["token"]; got != "wikcn123" {
t.Fatalf("wiki node parameter mismatch: got %#v, want placeholder node id", got)
}
if got, want := dry.API[1].URL, "/open-apis/slides_ai/v1/xml_presentations/%3Cresolved_slides_token%3E/histories"; got != want {
t.Fatalf("history dry-run URL = %q, want %q", got, want)
}
}
func TestSlidesHistoryExecuteList(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
var capturedQuery url.Values
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/presHistory/histories",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"entries": []interface{}{
map[string]interface{}{
"revision_id": float64(42),
"history_version_id": "11",
"edit_time": "1780000000",
"type": float64(1),
"editor_ids": []interface{}{"ou_1"},
},
},
"has_more": true,
"page_token": "page_token_2",
},
},
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.Query()
},
})
err := runSlidesShortcut(t, f, stdout, SlidesHistoryList, []string{
"+history-list",
"--presentation", "presHistory",
"--page-size", "5",
"--page-token", "page_token_1",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := capturedQuery.Get("page_size"); got != "5" {
t.Fatalf("page_size query = %q, want 5", got)
}
if got := capturedQuery.Get("page_token"); got != "page_token_1" {
t.Fatalf("page_token query = %q, want page_token_1", got)
}
data := decodeSlidesHistoryEnvelope(t, stdout)
if got := data["page_token"]; got != "page_token_2" {
t.Fatalf("page_token = %#v, want page_token_2", got)
}
entries, _ := data["entries"].([]interface{})
if len(entries) != 1 {
t.Fatalf("entries = %#v, want one entry", data["entries"])
}
}
func TestSlidesHistoryExecuteRevert(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/presHistory/history/revert",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"task_id": "task_1",
"status": "running",
"history_version_id": "42",
"poll_after_ms": float64(10000),
},
},
}
reg.Register(stub)
err := runSlidesShortcut(t, f, stdout, SlidesHistoryRevert, []string{
"+history-revert",
"--presentation", "presHistory",
"--history-version-id", "42",
"--wait-timeout-ms", "0",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
}
if got := body["history_version_id"]; got != "42" {
t.Fatalf("history_version_id = %#v, want 42", got)
}
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
t.Fatalf("wait_timeout_ms = %d, want 0", got)
}
data := decodeSlidesHistoryEnvelope(t, stdout)
if got := data["task_id"]; got != "task_1" {
t.Fatalf("task_id = %#v, want task_1", got)
}
}
func TestSlidesHistoryExecuteRevertStatus(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
var capturedQuery url.Values
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/presHistory/history/revert_status",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"status": "done",
"history_version_id": "11",
},
},
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.Query()
},
})
err := runSlidesShortcut(t, f, stdout, SlidesHistoryRevertStatus, []string{
"+history-revert-status",
"--presentation", "presHistory",
"--task-id", "task_1",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := capturedQuery.Get("task_id"); got != "task_1" {
t.Fatalf("task_id query = %q, want task_1", got)
}
data := decodeSlidesHistoryEnvelope(t, stdout)
if got := data["status"]; got != "done" {
t.Fatalf("status = %#v, want done", got)
}
if got := data["history_version_id"]; got != "11" {
t.Fatalf("history_version_id = %#v, want 11", got)
}
}
func TestSlidesHistoryExecuteResolvesWikiPresentation(t *testing.T) {
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": "presReal",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/presReal/histories",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"entries": []interface{}{},
"has_more": false,
"page_token": "",
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesHistoryList, []string{
"+history-list",
"--presentation", "https://example.feishu.cn/wiki/wikcn123",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesHistoryEnvelope(t, stdout)
if got := data["has_more"]; got != false {
t.Fatalf("has_more = %#v, want false", got)
}
}
type slidesHistoryDryRunOutput 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"`
}
func newSlidesHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: shortcut.Command}
for _, flag := range shortcut.Flags {
switch flag.Type {
case "int":
cmd.Flags().Int(flag.Name, 0, flag.Desc)
default:
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
}
}
for name, value := range values {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return cmd
}
func decodeSlidesHistoryDryRun(t *testing.T, dry *common.DryRunAPI) slidesHistoryDryRunOutput {
t.Helper()
raw, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
var out slidesHistoryDryRunOutput
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode dry-run: %v\nraw=%s", err, raw)
}
return out
}
func decodeSlidesHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in envelope: %#v", envelope)
}
return data
}

View File

@@ -159,7 +159,7 @@ func TestHandleTaskApiResultWithContext_PermissionConsoleURL(t *testing.T) {
if pe.Subtype != errs.SubtypeAppScopeNotApplied {
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypeAppScopeNotApplied)
}
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123/auth") {
t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL)
}
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" {

View File

@@ -1,77 +1,23 @@
所有命令默认 `--as user`(审批是人的动作)。调用前先按需读取 references 下对应的文件,查参数结构,不要猜字段。
## 路由优先级(先判断是不是审批,再选命令)
审批待办不是飞书任务。**只要用户的核心对象是审批单据 / 审批待办 / 审批实例,就优先使用 `lark-approval`,不要让渡给 `lark-task`。**
### 明确归 `lark-approval` 的高优先级语义
出现以下任一语义时,优先走 `lark-approval`
- 审批待办 / 审批单据 / 审批实例 / 审批意见 / 审批定义
- 同意 / 拒绝 / 转交 / 退回 / 撤回 / 催办 / 加签 / 抄送
- 待办列表 / 待办单据 / 已发起审批 / 已办审批 / 审批详情 / 同意可编辑
**判定规则:** 只要最终动作是对审批单据做同意、拒绝、转交、退回、撤回、催办、加签、抄送、查详情、查已发起/已办/待办,就归 `lark-approval`。只有当用户处理的是**非审批类任务/待办**时,才走 [`lark-task`](../lark-task/SKILL.md)。
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
## 选哪个命令
| 想做什么 | 命令 | 按需读取 reference |
|---|---|---------------------------------------------------------------------------------|
| 搜可发起定义 | `approvals search` | [`lark-approval-approvals-search.md`](references/lark-approval-approvals-search.md) |
| 看审批定义详情/提单前确认表单与流程 | `approvals get` | [`lark-approval-approvals-get.md`](references/lark-approval-approvals-get.md) |
| 发起原生审批实例/提交请假审批/提交报销审批/创建审批实例 | `instances create` | [`lark-approval-initiate.md`](references/lark-approval-initiate.md) |
| 查待办/已办 | `tasks query``topic`1待办 2已办 17未读 18已读 | [`lark-approval-tasks-query.md`](references/lark-approval-tasks-query.md) |
| 看表单/进度/当前节点 | `instances get` | [`lark-approval-instances-get.md`](references/lark-approval-instances-get.md) |
| 同意审批 | `tasks approve` | [`lark-approval-tasks-approve.md`](references/lark-approval-tasks-approve.md) |
| 拒绝审批 | `tasks reject` | [`lark-approval-tasks-reject.md`](references/lark-approval-tasks-reject.md) |
| 转交审批 | `tasks transfer` | [`lark-approval-tasks-transfer.md`](references/lark-approval-tasks-transfer.md) |
| 加签审批 | `tasks add_sign` | [`lark-approval-tasks-add-sign.md`](references/lark-approval-tasks-add-sign.md) |
| 退回审批 | `tasks rollback` | [`lark-approval-tasks-rollback.md`](references/lark-approval-tasks-rollback.md) |
| 催办审批 | `tasks remind` | [`lark-approval-tasks-remind.md`](references/lark-approval-tasks-remind.md) |
| 撤回已发起审批 | `instances cancel` | [`lark-approval-instances-cancel.md`](references/lark-approval-instances-cancel.md) |
| 给审批实例追加抄送 | `instances cc` | [`lark-approval-instances-cc.md`](references/lark-approval-instances-cc.md) |
| 按定义查已发起审批 | `instances initiated` | [`lark-approval-instances-initiated.md`](references/lark-approval-instances-initiated.md) |
| 想做什么 | 命令 |
|---|---|
| 搜可发起定义 | `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` → 执行操作
## 执行原则(减少误路由、误重试和无效消耗)
### 1) 先拿最小必要信息,再执行
- 目标只是处理待办时,优先 `tasks query` 获取 `instance_code` + `task_id`
- **只有**用户明确要看详情、当前节点、表单内容、流程进度时,才调用 `instances get`
- 用户已经明确给出 `instance_code` / `task_id` 时,不要先查列表再过滤
### 2) 已知对象时直达动作
- 已拿到 `instance_code` + `task_id` 后,优先直接执行 `tasks approve/reject/transfer/add_sign/rollback/remind`
- 同一轮里如果已有足够的新鲜查询结果,不要重复 `tasks query`
- 不要默认走 `list -> filter -> detail -> write` 全链路;对象已明确时应压缩步骤
### 3) 错误码驱动,而不是盲目重试
- 写操作失败后,先看错误码和报错语义,再决定是否补查或结束
- **除非错误明确提示可恢复或需要补充参数,否则不要重复刷同一个写操作**
- 同一个失败原因不要连续多次重试,避免 token 和耗时失控最多重试1次
## 写操作失败处理1395001 决策树
当拒绝 / 转交 / 退回 / 撤回 / 同意等写操作返回 `1395001`(任务状态异常 / 写前置校验失败)时,按下面规则处理:
1. **先停止盲目重试**不要连续重复提交相同写操作最多重试1次
2. 优先从以下角度解释:
- 任务可能已被他人处理
- 单据状态已变化,当前动作已不再允许
- 当前用户已不具备该任务的操作资格
- 当前节点或单据状态不支持该操作
3. 如需确认,只补 **一次** 状态查询(`tasks query``instances get`),不要陷入 query/write 循环
4. 最终给用户明确结论和下一步建议,而不是继续无意义重试
**特别注意:** 对拒绝 / 转交 / 撤回场景更要严格执行上述规则;这些场景最容易因状态切换而失败。
- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
- 处理审批:`tasks query``instance_code` + `task_id`(操作必须成对带上)→ 需要细节`instances get` → 执行操作
```bash
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
@@ -81,6 +27,14 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
```
## 不在本 skill 范围
## 发起原生审批
创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
发起审批属于高风险写操作,按下表处理:
| 规则 | 处理 |
|---|---|
| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`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` |

View File

@@ -8,83 +8,28 @@ metadata:
cliHelp: "lark-cli approval --help"
---
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
所有命令默认 `--as user`(审批是人的动作)。调用前先按需读取 references 下对应的文件,查参数结构,不要猜字段;**references 是第一信息源**,只有在 reference 未覆盖的原生 / 高级场景下,才额外用 `lark-cli ... --help``lark-cli schema` 等方式补充确认字段。
## 路由优先级(先判断是不是审批,再选命令)
审批待办不是飞书任务。**只要用户的核心对象是审批单据 / 审批待办 / 审批实例,就优先使用 `lark-approval`,不要让渡给 `lark-task`。**
### 明确归 `lark-approval` 的高优先级语义
出现以下任一语义时,优先走 `lark-approval`
- 审批待办 / 审批单据 / 审批实例 / 审批意见 / 审批定义
- 同意 / 拒绝 / 转交 / 退回 / 撤回 / 催办 / 加签 / 抄送
- 待办列表 / 待办单据 / 已发起审批 / 已办审批 / 审批详情 / 同意可编辑
**判定规则:** 只要最终动作是对审批单据做同意、拒绝、转交、退回、撤回、催办、加签、抄送、查详情、查已发起/已办/待办,就归 `lark-approval`。只有当用户处理的是**非审批类任务/待办**时,才走 [`lark-task`](../lark-task/SKILL.md)。
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
## 选哪个命令
| 想做什么 | 命令 | 按需读取 reference |
|---|---|---------------------------------------------------------------------------------|
| 搜可发起定义 | `approvals search` | [`lark-approval-approvals-search.md`](references/lark-approval-approvals-search.md) |
| 看审批定义详情/提单前确认表单与流程 | `approvals get` | [`lark-approval-approvals-get.md`](references/lark-approval-approvals-get.md) |
| 发起原生审批实例/提交请假审批/提交报销审批/创建审批实例 | `instances create` | [`lark-approval-initiate.md`](references/lark-approval-initiate.md) |
| 查待办/已办 | `tasks query``topic`1待办 2已办 17未读 18已读 | [`lark-approval-tasks-query.md`](references/lark-approval-tasks-query.md) |
| 看表单/进度/当前节点 | `instances get` | [`lark-approval-instances-get.md`](references/lark-approval-instances-get.md) |
| 同意审批 | `tasks approve` | [`lark-approval-tasks-approve.md`](references/lark-approval-tasks-approve.md) |
| 拒绝审批 | `tasks reject` | [`lark-approval-tasks-reject.md`](references/lark-approval-tasks-reject.md) |
| 转交审批 | `tasks transfer` | [`lark-approval-tasks-transfer.md`](references/lark-approval-tasks-transfer.md) |
| 加签审批 | `tasks add_sign` | [`lark-approval-tasks-add-sign.md`](references/lark-approval-tasks-add-sign.md) |
| 退回审批 | `tasks rollback` | [`lark-approval-tasks-rollback.md`](references/lark-approval-tasks-rollback.md) |
| 催办审批 | `tasks remind` | [`lark-approval-tasks-remind.md`](references/lark-approval-tasks-remind.md) |
| 撤回已发起审批 | `instances cancel` | [`lark-approval-instances-cancel.md`](references/lark-approval-instances-cancel.md) |
| 给审批实例追加抄送 | `instances cc` | [`lark-approval-instances-cc.md`](references/lark-approval-instances-cc.md) |
| 按定义查已发起审批 | `instances initiated` | [`lark-approval-instances-initiated.md`](references/lark-approval-instances-initiated.md) |
| 想做什么 | 命令 |
|---|---|
| 搜可发起定义 | `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` → 执行操作
## 执行原则(减少误路由、误重试和无效消耗)
### 1) 先拿最小必要信息,再执行
- 目标只是处理待办时,优先 `tasks query` 获取 `instance_code` + `task_id`
- **只有**用户明确要看详情、当前节点、表单内容、流程进度时,才调用 `instances get`
- 用户已经明确给出 `instance_code` / `task_id` 时,不要先查列表再过滤
### 2) 已知对象时直达动作
- 已拿到 `instance_code` + `task_id` 后,优先直接执行 `tasks approve/reject/transfer/add_sign/rollback/remind`
- 同一轮里如果已有足够的新鲜查询结果,不要重复 `tasks query`
- 不要默认走 `list -> filter -> detail -> write` 全链路;对象已明确时应压缩步骤
### 3) 错误码驱动,而不是盲目重试
- 写操作失败后,先看错误码和报错语义,再决定是否补查或结束
- **除非错误明确提示可恢复或需要补充参数,否则不要重复刷同一个写操作**
- 同一个失败原因不要连续多次重试,避免 token 和耗时失控最多重试1次
## 写操作失败处理1395001 决策树
当拒绝 / 转交 / 退回 / 撤回 / 同意等写操作返回 `1395001`(任务状态异常 / 写前置校验失败)时,按下面规则处理:
1. **先停止盲目重试**不要连续重复提交相同写操作最多重试1次
2. 优先从以下角度解释:
- 任务可能已被他人处理
- 单据状态已变化,当前动作已不再允许
- 当前用户已不具备该任务的操作资格
- 当前节点或单据状态不支持该操作
3. 如需确认,只补 **一次** 状态查询(`tasks query``instances get`),不要陷入 query/write 循环
4. 最终给用户明确结论和下一步建议,而不是继续无意义重试
**特别注意:** 对拒绝 / 转交 / 撤回场景更要严格执行上述规则;这些场景最容易因状态切换而失败。
- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
- 处理审批:`tasks query``instance_code` + `task_id`(操作必须成对带上)→ 需要细节`instances get` → 执行操作
```bash
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
@@ -94,6 +39,18 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","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 范围
创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)

View File

@@ -1,128 +0,0 @@
# approval approvals get
获取单个审批定义详情(用户级只读操作)。适合在发起审批实例前,先确认审批名称、表单控件结构、选项值范围以及流程节点信息。
需要的 scopes: ["approval:approval:read"]
## 命令
```bash
# 按 approval_code 查询审批定义详情
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user
# 表格格式输出,便于快速浏览顶层字段
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --format table --as user
# 预览 API 调用,不执行
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--params '{...}'` | 是 | 查询参数,使用 JSON 传入 |
| `approval_code` | 是 | 审批定义 Code通常来自 `approval approvals search` 的结果 |
| `locale` | 否 | 返回语言,例如 `zh-CN``en-US``ja-JP` |
| `--as user` | 否 | 建议显式指定用户身份;审批定义详情通常按当前用户可见范围读取 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 常见输入来源
如果你已经有 `approval_code`,可直接查询:
```bash
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user
```
如果你还没有 `approval_code`,先搜索可发起审批定义:
```bash
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
```
## 输出重点字段
返回结果中,优先关注以下字段:
| 字段 | 说明 |
|------|------|
| `approval_code` | 审批定义 Code |
| `approval_name` | 审批定义名称;确认是不是用户想发起的那张单 |
| `form` | 表单定义快照;用于识别控件 `id``type`、选项值范围、明细子控件结构 |
| `node_list` | 流程节点列表;用于识别节点 key、是否需要补充审批人、是否允许多人 |
## form 的使用重点
`form` 最重要的作用是帮助 agent **识别怎么组装 `instances.create.data.form`**,而不是直接把它原样提交出去。
重点看:
| 字段 / 结构 | 说明 |
|------|------|
| `form[].id` | 控件 ID后续创建实例时必须使用 |
| `form[].type` | 控件类型,例如 `input``date``radio``checkbox``fieldList` |
| `form[].value` / 选项定义 | 用来识别可选值范围、默认值或选项值 |
| 明细 / 子控件结构 | 用于识别 `fieldList`、控件组等复杂控件的子字段结构 |
**注意:`approvals.get.form` 不是 `instances.create` 可直接复用的 payload 模板。** 它是“定义快照”,主要用于识别字段结构与选项值范围。
## node_list 的使用重点
`node_list` 主要用于后续决定是否要补 `node_approver_list` / `node_cc_list`
重点看:
| 字段 | 说明 |
|------|------|
| `node_list[].custom_node_id` | 自定义节点标识;后续补节点参数时优先作为 key |
| `node_list[].node_id` | 节点 ID若没有 `custom_node_id`,通常退回用它做 key |
| `node_list[].need_approver` | 是否要求发起人补充审批人 |
| `node_list[].approver_chosen_multi` | 是否允许为该节点选择多个审批人 |
## 使用建议
- **这是发起原生审批实例前的必要只读步骤。** 推荐固定走:`approvals search` -> `approvals get` -> `instances create`
- **如果用户已经明确给了 `approval_code`,直接用这个命令。** 不必再走 `approvals search`
- **先确认 `approval_name`。** 避免把相似名称的审批定义搞混。
- **先用 `form` 识别控件结构,再组装创建 payload。** 不要在未看详情时猜控件 `id``type` 或选项值。
- **先用 `node_list` 看是否需要补审批人。** 若某节点 `need_approver=true`,创建实例时通常要补 `node_approver_list`
- **`node_list` 的 key 优先取 `custom_node_id`。** 若不存在,再使用 `node_id`
- **`approver_chosen_multi=false` 时,一个节点通常只能补一个审批人。**
## 输出与后续操作
读取定义详情后,常见下一步:
```bash
# 发起原生审批实例
lark-cli approval instances create --data '{"approval_code":"<APPROVAL_CODE>","form":"[...]"}' --as user --yes
```
如果需要进一步理解控件取值与节点参数,优先参考:
- `lark-approval-instance-form-control-parameters.md`
- `lark-approval-instance-value-sourcing.md`
- `lark-approval-initiate.md`
## 结果整理方式
**将结果整理为“审批定义概览 + 表单结构摘要 + 节点要求摘要”。**
建议输出成下面这种结构:
```text
审批定义:请假申请
approval_code: 7C468A54-8745-2245-9675-08B7C63E7A85
表单控件摘要:
- leave_type: radio可选值 [annual_leave, sick_leave]
- reason: textarea
- start_end: dateInterval
节点要求摘要:
- manager_nodeneed_approver=trueapprover_chosen_multi=false
- hr_nodeneed_approver=false
```

View File

@@ -1,103 +0,0 @@
# approval approvals search
搜索**当前用户可发起**的审批定义launchable approvals。只读操作不会创建审批实例。
需要的 scopes: ["approval:approval:read"]
## 命令
```bash
# 按关键词搜索可发起审批定义
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
# 使用 page_token 翻页
lark-cli approval approvals search --data '{"keyword":"请假", "page_token":"example_page_token"}' --as user
# 表格格式输出,便于快速浏览候选定义
lark-cli approval approvals search --data '{"keyword":"出差"}' --format table --as user
# 预览 API 调用,不执行
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 查询参数,使用 JSON 传入 |
| `keyword` | 是 | 搜索关键词,例如 `请假``报销``出差``采购` |
| `locale` | 否 | 返回语言,例如 `zh-CN``en-US``ja-JP` |
| `page_size` | 否 | 分页大小 |
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
| `--as user` | 否 | 建议显式指定用户身份;“可发起审批定义”是面向当前用户的查询 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 这个命令解决什么问题
当用户只有自然语言意图,还没有 `approval_code` 时,先用它把“可发起的审批定义候选项”找出来。
典型场景:
- “帮我找一下请假审批”
- “有哪些可以发起的报销单?”
- “先搜一下出差审批,再帮我提单”
## 输出重点字段
返回结果里,优先关注以下字段:
| 字段 | 说明 |
|------|------|
| `approval_code` | 审批定义 Code后续 `approvals get``instances create` 都要用它 |
| `approval_name` | 审批定义名称;给用户做候选选择时最关键 |
| `is_external` | 是否为三方审批定义;`true` 表示不能走原生 `instances.create` |
| `create_link` | 三方审批定义的发起链接;`is_external=true` 时优先返回给用户 |
## 使用规则
- **这是发起审批工作流的第一步。** 标准顺序是:`approvals search` -> `approvals get` -> `instances create`
- **搜索结果为空时,不要猜。** 直接告诉用户当前关键词下没有可发起定义,并建议用户换关键词。
- **命中多个结果时,不要替用户拍板。** 先把候选定义列出来,让用户选择目标审批定义。
- **`is_external=true` 时不要调用 `approval instances create`。** 这类定义属于三方审批,优先返回 `create_link` 并说明需要通过链接发起。
- **只有 `is_external=false` 的原生定义,才继续 `approvals get`。**
- **如果用户已经明确给出 `approval_code`,不要再 search。** 直接执行 `approval approvals get`
## 结果整理方式
**将结果整理为候选清单,优先展示“名称 + approval_code + 是否三方定义 + 下一步建议”。**
建议输出成下面这种结构:
```text
找到 3 个可发起审批定义:
1. 请假申请
- approval_code: 7C468A54-8745-2245-9675-08B7C63E7A85
- is_external: false
- next: 可继续读取 definitions 详情approvals get
2. 差旅报销
- approval_code: 99887766-xxxx
- is_external: true
- next: 返回 create_link引导用户通过链接发起
```
## 常见后续操作
### 1用户选中了某个定义继续查看详情
```bash
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user
```
### 2确认是原生定义后再准备发起审批实例
```bash
lark-cli approval instances create --data '{"approval_code":"<APPROVAL_CODE>","form":"[...]"}' --as user --yes
```
### 3确认是三方定义时直接返回链接
`is_external=true` 时,优先向用户返回 `create_link`,说明该审批需在三方系统或跳转页面中发起,而不是通过原生 `instances.create`

View File

@@ -2,15 +2,14 @@
## 执行摘要
- **原生审批提单如果用户未明确给出 `approval_code`必须固定走 `approvals search` -> `approvals get` -> `instances create`** 不要跳过 `get` 直接拼请求。
- **原生审批提单如果用户明确给出 `approval_code`,固定走 `approvals get` -> `instances create`** 不要跳过 `get` 直接拼请求
- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances create`,应优先使用 `create_link`
- **原生审批提单必须固定走 `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读本文里的创建参数规则。** 提单前必须先阅读 [`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)。
- **`approvals.get.form` 不是创建 payload 的原样模板。** 它主要用于识别控件 `id``type`、选项值范围和明细子控件结构;真正的 `instances create --data.form` 中,控件 `value` 结构以 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 为准。
- **节点参数只从 `node_list`本文里的节点参数规则里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,不要混用姓名或其他身份标识。
- **先读控件参数 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`
- **创建实例前先确认。** `approval instances create` 是写操作,真正执行时显式传 `--yes`
## 适用场景
@@ -21,10 +20,11 @@
## 严禁行为
- **严禁在未先阅读本文中的创建参数规则、[`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) 的情况下直接提单。**
- **严禁跳过 `approvals.get`。** 未拿到 `form``node_list` 前,不得调用 `instances create`
- **严禁在未先查看 `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`。**
- **严禁对三方定义调用 `instances.create`。**
- **严禁对 API 不支持的控件硬提单。** 如果目标定义包含创建实例 API 不支持的控件,应明确告诉用户该定义不能仅通过 API 完整发起。
- **严禁把 `approvals.get.form` 当成可直接提交的原样模板。**
- **严禁在未得到用户确认前直接执行真实提单。**
@@ -33,9 +33,10 @@
### 1. 搜索可发起审批定义
先搜索定义:
`schema` 看参数,再搜索定义:
```bash
lark-cli schema approval.approvals.search
lark-cli approval approvals search --data '{"keyword":"请假"}'
```
@@ -43,7 +44,7 @@ lark-cli approval approvals search --data '{"keyword":"请假"}'
- 若结果为空,告诉用户当前关键词下没有可发起定义。
- 若命中多个定义,必须把候选项列给用户选择,不要自行猜测。
- 若目标定义 `is_external=true`,优先返回 `create_link`,说明这是三方定义,不能走原生 `instances create`
- 若目标定义 `is_external=true`,优先返回 `create_link`,说明这是三方定义,不能走原生 `instances.create`
- 只有 `is_external=false` 的原生定义才继续下一步。
### 2. 获取审批定义详情
@@ -51,6 +52,7 @@ lark-cli approval approvals search --data '{"keyword":"请假"}'
拿到 `approval_code` 后,读取定义详情:
```bash
lark-cli schema approval.approvals.get
lark-cli approval approvals get \
--params '{"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85"}'
```
@@ -61,30 +63,12 @@ lark-cli approval approvals get \
- `form`: 表单定义快照,用于识别控件 `id``type`、选项值范围以及明细子控件结构;不是创建实例时可直接原样提交的 payload 模板。
- `node_list`: 流程节点信息,是后续 `node_approver_list` / `node_cc_list` 的唯一可靠来源。
### 3. 创建请求参数速查
### 3. 组装 `form`
输入参数如下
`instances.create.data.form` 是一个 JSON 数组字符串。组装原则
| 参数 | 必填 | 说明 |
|---|---|---|
| `--data '{...}'` | 是 | 请求体,使用 JSON 传入 |
| `approval_code` | 是 | 审批定义 Code必须先通过 `approvals search` / `approvals get` 确认 |
| `form` | 是 | 表单值,**JSON 数组字符串**,不是普通对象 |
| `node_approver_list` | 否 | 节点审批人列表;仅在定义要求补充审批人时传 |
| `node_cc_list` | 否 | 节点抄送人列表;仅在用户明确需要补充节点抄送人时传 |
| `uuid` | 否 | 幂等标识;重复重试同一请求时建议显式传入 |
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入 |
| `user_id_type` | 否 | 用户 ID 类型:`user_id``union_id``open_id`;涉及人员类 ID 时建议显式传 `open_id` |
| `--as user` | 否 | 建议显式指定用户身份;审批发起通常应使用用户身份 |
| `--yes` | 是 | 写操作确认;真实执行时必须显式传入 |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
### 4. 组装 `form`
`instances create --data.form` 是一个 JSON 数组字符串。组装原则:
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按本文中的创建参数规则与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
- 提交时必须至少保证每个控件的 `id``type``value` 符合当前接口要求;不要假设定义快照里出现的其他字段都能直接照搬。
- 先用 `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) 单独组装,不要套用文本控件的写法。
@@ -116,7 +100,7 @@ lark-cli approval approvals get \
- `input` / `textarea`: `value` 是字符串
- `date`: `value` 是 RFC3339 时间字符串
- `dateInterval`: `value` 是对象,包含 `start` / `end` / `interval`
- `radio` / `radioV2`: `value` 是单个选项值,取定义详情里的 `option.value`;关联外部选项时传 `options.id`
- `radio` / `radioV2`: `value` 是单个选项值,取定义详情里的 option.value关联外部选项时传 `options.id`
- `checkbox` / `checkboxV2`: `value` 是选项值数组
- `number`: `value` 是数字
- `amount`: `value` 是数字,还要带 `currency`
@@ -145,7 +129,7 @@ lark-cli approval approvals get \
- 再严格按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 的示例组装 `value`
- 不要把控件组整体当成普通字符串或扁平对象提交
### 5. 组装节点参数
### 4. 组装节点参数
`node_list` 推导节点参数:
@@ -155,13 +139,13 @@ lark-cli approval approvals get \
-`approver_chosen_multi=false`,该节点只允许一个审批人 `open_id`
- `node_cc_list` 仅在用户明确需要补充节点抄送人时才填写;其 `key/value` 规则与 `node_approver_list` 相同。
### 6. 创建审批实例
### 5. 创建审批实例
创建命令使用 `approval instances create`,需要的 scopes: ["approval:instance:write"]
确认最终表单值和节点参数后再执行:
先看 `schema`,确认最终结构后再执行:
```bash
lark-cli schema approval.instances.create
lark-cli approval instances create \
--data '{
"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85",
@@ -173,8 +157,6 @@ lark-cli approval instances create \
}
]
}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
```
@@ -188,7 +170,7 @@ lark-cli approval instances create \
优先级固定如下:
1. 本文中的创建请求参数、节点参数和返回结果说明:决定 `instances create` 要传哪些字段、怎么执行、成功后回什么
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`、选项值范围、明细子控件结构。
@@ -202,8 +184,8 @@ lark-cli approval instances create \
|---|---|
| 只有口语需求,比如“帮我提个请假审批” | 先 `approvals.search` |
| 已经拿到 `approval_code` | 直接 `approvals.get` |
| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances create` |
| `is_external=true` | 返回 `create_link`,不要调 `instances create` |
| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances.create` |
| `is_external=true` | 返回 `create_link`,不要调 `instances.create` |
## 返回结果
@@ -212,13 +194,3 @@ lark-cli approval instances create \
- `approval_name`
- `instance_code`
- `instance_link`
建议整理为下面这种结构:
```text
审批已创建成功:
- approval_name: 请假申请
- instance_code: 19EAC829-F1CB-527F-BE2A-1330422E60C0
- instance_link: https://...
```

View File

@@ -6,14 +6,14 @@
阅读顺序固定如下:
1. [`lark-approval-initiate.md`](./lark-approval-initiate.md) 中的创建请求参数、节点参数和返回结果说明
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. 本文
## 总原则
- `lark-approval-initiate.md` 决定创建请求字段名、字段层级、节点参数结构。
- `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` 的最终结构。

View File

@@ -1,78 +0,0 @@
# approval instances cancel
撤回一个已发起的审批实例(用户级写操作)。通常先通过 `instances initiated``tasks query``instances get` 确认目标审批实例,拿到 `instance_code` 后再执行撤回。
> [!CAUTION]
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要撤回该审批实例且目标实例无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
需要的 scopes: ["approval:instance:write"]
## 命令
```bash
# 先预览请求,不实际执行
lark-cli approval instances cancel \
--data '{"instance_code":"<INSTANCE_CODE>"}' \
--as user \
--dry-run
# 撤回一个审批实例
lark-cli approval instances cancel \
--data '{"instance_code":"<INSTANCE_CODE>"}' \
--as user \
--yes
# 通过文件传入请求体
lark-cli approval instances cancel \
--data @./cancel-body.json \
--as user \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `instances initiated``tasks query``instances get` 获取 |
| `--as user` | 否 | 建议显式指定用户身份;审批实例撤回通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 典型前置步骤
如果你要找“我发起的审批实例”,可先查询已发起列表:
```bash
lark-cli approval instances initiated --params '{"page_size":20}' --as user
```
如果你已经在任务列表中定位到某个审批,也可以从任务里拿到实例 Code
```bash
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
常用到的字段:
| 字段 | 说明 |
|------|------|
| `instances[].instance_code` | 审批实例 Code撤回时必须提供 |
| `tasks[].instance_code` | 审批任务关联的审批实例 Code也可作为撤回输入 |
| `tasks[].instance_status` | 审批实例状态;可用于判断是否仍处于可撤回阶段 |
如需先确认审批表单、当前节点、流转状态,可继续查看实例详情:
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
## 使用建议
- **撤回的是审批实例,不是单个任务**`instances cancel` 只需要 `instance_code`,不需要 `task_id`
- **优先确认实例是否仍可撤回**:已经通过、已拒绝、已撤销或已终止的实例通常不适合继续撤回。
- **优先从 `instances initiated` 获取目标实例**:因为撤回通常针对“我发起的审批”,这个入口最直接。
- **也可从 `tasks query` 反查 `instance_code`**:当你是从某个待办/已办上下文进入时,这样更方便。
- **先 `--dry-run` 再执行**:尤其在实例来源不明确、用户只给了标题关键字,或一次要核对多个实例时,先预览更安全。

View File

@@ -1,105 +0,0 @@
# approval instances cc
给一个审批实例追加抄送人(用户级写操作)。通常先通过 `instances initiated``tasks query``instances get` 确认目标审批实例,拿到 `instance_code` 后,再提供抄送人的用户 ID 执行抄送。
> [!CAUTION]
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要抄送该审批实例且目标实例、抄送对象都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
需要的 scopes: ["approval:instance:write"]
## 命令
```bash
# 先预览请求,不实际执行
lark-cli approval instances cc \
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["ou_xxx"],"comment":"抄送给项目 owner 了解进展"}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--dry-run
# 按 open_id 抄送一个人
lark-cli approval instances cc \
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["ou_xxx"],"comment":"抄送给你知悉"}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
# 一次抄送多个人
lark-cli approval instances cc \
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["ou_xxx","ou_yyy"],"comment":"请相关同学同步关注"}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
# 按 user_id 抄送
lark-cli approval instances cc \
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["123456789"],"comment":"抄送给财务负责人"}' \
--params '{"user_id_type":"user_id"}' \
--as user \
--yes
# 通过文件传入请求体
lark-cli approval instances cc \
--data @./cc-body.json \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `instances initiated``tasks query``instances get` 获取 |
| `cc_user_ids` | 是 | 抄送人的用户 ID 数组;需要和 `user_id_type` 保持一致 |
| `comment` | 否 | 抄送留言,例如 `抄送给你知悉``请同步关注该审批进展` |
| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON用于声明 `cc_user_ids` 内用户 ID 的类型 |
| `user_id_type` | 否 | 用户 ID 类型:`user_id``union_id``open_id`;未显式指定时要特别确认抄送人的 ID 类型 |
| `--as user` | 否 | 建议显式指定用户身份;审批实例抄送通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 典型前置步骤
如果你要找“我发起的审批实例”,可先查询已发起列表:
```bash
lark-cli approval instances initiated --params '{"page_size":20}' --as user
```
如果你已经在任务列表中定位到某个审批,也可以从任务里拿到实例 Code
```bash
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
常用到的字段:
| 字段 | 说明 |
|------|------|
| `instances[].instance_code` | 审批实例 Code抄送时必须提供 |
| `tasks[].instance_code` | 审批任务关联的审批实例 Code也可作为抄送输入 |
| `tasks[].title` | 任务标题,可用于确认是否是要操作的那个审批 |
| `tasks[].instance_status` | 审批实例状态;可用于判断当前审批是否仍处于进行中 |
如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID再执行抄送。
如需先确认审批表单、当前节点、流转状态,可继续查看实例详情:
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
## 使用建议
- **抄送的是审批实例,不是单个任务**`instances cc` 只需要 `instance_code`,不需要 `task_id`
- **`cc_user_ids``user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。
- **`cc_user_ids` 是数组**:即使只抄送一个人,也要按数组形式传入。
- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。
- **优先从 `instances initiated` 获取目标实例**:因为抄送常见于“我发起的审批”场景,这个入口最直接。
- **也可从 `tasks query` 反查 `instance_code`**:当你是从某个审批上下文进入时,这样更方便。
- **`comment` 建议简洁明确**:例如 `抄送给你知悉``请同步关注审批进展`。避免过长或模糊描述。
- **先 `--dry-run` 再执行**:尤其在抄送对象较多、抄送人来源不明确,或需要让用户先核对实例标题时,先预览更安全。

View File

@@ -1,145 +0,0 @@
# approval instances get
获取单个审批实例详情(用户级只读操作)。适合在执行 approve / reject / transfer / rollback / cancel / cc / remind 之前,先查看审批表单、当前节点、任务列表、审批动态和整体状态。
需要的 scopes: ["approval:instance:read"]
## 命令
```bash
# 按实例 Code 查询详情
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
# 表格格式输出,便于快速浏览顶层字段
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --format table --as user
# 预览 API 调用,不执行
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--params '{...}'` | 是 | 查询参数,使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code |
| `locale` | 否 | 返回语言,例如 `zh-CN``en-US``ja-JP` |
| `user_id_type` | 否 | 用户 ID 类型:`user_id``union_id``open_id` |
| `--as user` | 否 | 建议显式指定用户身份;审批实例详情查询通常应使用用户身份 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 常见输入来源
如果你已经有实例 Code可直接查询
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
如果你还没有实例 Code可先从以下命令获取
```bash
# 查询我发起的审批实例
lark-cli approval instances initiated --params '{"page_size":20}' --as user
# 或从任务列表里拿到关联实例 Code
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
## 输出重点字段
返回结果中常见字段:
| 字段 | 说明 |
|------|------|
| `instance_code` | 审批实例 Code |
| `serial_number` | 审批单编号 |
| `definition_code` | 审批定义 Code |
| `definition_name` | 审批名称 |
| `user_id` | 发起审批的用户 ID |
| `department_id` | 发起人所在部门 ID |
| `status` | 审批实例状态见下方“status 枚举” |
| `reverted` | 单据是否已被撤销 |
| `start_time` | 审批创建时间 |
| `end_time` | 审批完成时间,未完成时通常为 `0` |
| `form` | 表单数据JSON 字符串 |
| `current_nodes` | 当前审批节点列表 |
| `tasks` | 审批任务列表 |
| `operation_records` | 审批动态,例如通过、拒绝、转交、加签、回退、撤回、抄送 |
| `comments` | 评论列表 |
## status 枚举
| 值 | 含义 |
|----|------|
| `PENDING` | 审批中 |
| `APPROVED` | 已通过 |
| `REJECTED` | 已拒绝 |
| `CANCELED` | 已撤回 |
| `DELETED` | 已删除 |
## current_nodes 重点字段
`current_nodes` 常用于判断审批流当前卡在哪一层:
| 字段 | 说明 |
|------|------------------------------------------|
| `current_nodes[].node_id` | 当前审批节点 ID |
| `current_nodes[].node_name` | 当前审批节点名称 |
| `current_nodes[].type` | 审批方式:`AND` 会签、`OR` 或签、`SEQUENTIAL` 依次审批等 |
| `current_nodes[].approvers[].task_id` | 当前审批人关联任务 ID |
| `current_nodes[].approvers[].user_id` | 当前审批人用户 ID |
## tasks 重点字段
`tasks` 常用于把实例和具体审批任务关联起来:
| 字段 | 说明 |
|------|------|
| `tasks[].id` | 审批任务 ID |
| `tasks[].node_id` | 任务所属节点 ID |
| `tasks[].node_name` | 任务所属节点名称 |
| `tasks[].user_id` | 审批人用户 ID |
| `tasks[].status` | 任务状态:`PENDING``APPROVED``REJECTED``TRANSFERRED``DONE` |
| `tasks[].start_time` | 任务开始时间 |
| `tasks[].end_time` | 任务完成时间 |
## operation_records 重点字段
`operation_records` 常用于审计审批过程:
| 字段 | 说明 |
|------|------|
| `operation_records[].type` | 事件类型,如 `PASS``REJECT``TRANSFER``ROLLBACK``CANCEL``CC` |
| `operation_records[].create_time` | 事件发生时间 |
| `operation_records[].user_id` | 触发该事件的用户 ID |
| `operation_records[].task_id` | 关联任务 ID |
| `operation_records[].node_id` | 关联节点 ID |
| `operation_records[].comment` | 理由 / 备注 |
| `operation_records[].cc_user_ids` | 被抄送人列表(抄送事件时) |
## 使用建议
- **这是最适合做“详情确认”的只读命令**:当你已经拿到 `instance_code`,需要确认表单、当前节点、任务状态、审批动态时,优先使用它。
- **在执行写操作前先看详情**:例如做 `tasks rollback` 前确认可退回节点,做 `instances cancel` 前确认实例状态,做 `tasks remind` 前确认当前任务是否仍待处理。
- **`form` 是 JSON 字符串**:调用方通常还需要再解析一层,才能拿到表单字段值。
- **`current_nodes``tasks` 可以联动看**:前者看“当前卡在哪个节点”,后者看“每个任务目前由谁处理、状态如何”。
- **`operation_records` 适合做时间线回溯**:例如排查谁转交过、谁加签过、什么时候撤回或抄送过。
- **优先显式传 `locale``user_id_type`**:这样 agent 更容易理解返回文本和 ID 语义,减少歧义。
## 输出与后续操作
读取详情后,常见下一步:
```bash
# 同意审批任务
lark-cli approval tasks approve --data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>"}' --as user --yes
# 撤回审批实例
lark-cli approval instances cancel --data '{"instance_code":"<INSTANCE_CODE>"}' --as user --yes
# 催办审批任务
lark-cli approval tasks remind --data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID>"]}' --as user --yes
```

View File

@@ -1,122 +0,0 @@
# approval instances initiated
查询当前用户已发起的审批实例列表(用户级只读操作)。适合在需要查看“我发起了哪些审批”、筛选某类审批定义、获取 `instance_code` 供后续 `instances get` / `instances cancel` / `instances cc` 等命令使用时调用。
需要的 scopes: ["approval:instance:read"]
## 命令
```bash
# 查询我发起的审批列表
lark-cli approval instances initiated --params '{"page_size":20}' --as user
# 只看某个审批定义下我发起的实例
lark-cli approval instances initiated --params '{"definition_code":"<DEFINITION_CODE>","page_size":20}' --as user
# 使用 page_token 翻页
lark-cli approval instances initiated --params '{"page_size":20,"page_token":"example_page_token"}' --as user
# 表格格式输出,便于快速浏览
lark-cli approval instances initiated --params '{"page_size":20}' --format table --as user
# 预览 API 调用,不执行
lark-cli approval instances initiated --params '{"page_size":20}' --as user --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入;不传时使用默认分页与筛选 |
| `definition_code` | 否 | 审批定义 Code用于只查看某个审批定义下我发起的实例 |
| `locale` | 否 | 返回语言:`zh-CN``en-US``ja-JP` |
| `page_size` | 否 | 分页大小 |
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
| `user_id_type` | 否 | 用户 ID 类型:`user_id``union_id``open_id` |
| `--as user` | 否 | 建议显式指定用户身份;已发起审批列表查询通常应使用用户身份 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 输出重点字段
返回结果中常见字段:
| 字段 | 说明 |
|------|------|
| `count` | 列表计数,只在第一页返回;大于等于 100 个实例时返回 `99` |
| `has_more` | 是否还有更多数据 |
| `page_token` | 下一页翻页 Token |
| `instances[].instance_code` | 审批实例 Code后续查询详情或执行撤回 / 抄送时通常需要 |
| `instances[].definition_code` | 审批定义 Code |
| `instances[].definition_name` | 审批定义名称 |
| `instances[].definition_group_id` | 审批定义分组 ID |
| `instances[].definition_group_name` | 审批定义分组名称 |
| `instances[].initiator` | 发起人 ID |
| `instances[].initiator_name` | 发起人姓名 |
| `instances[].instance_status` | 审批实例状态见下方“instance_status 枚举” |
| `instances[].instance_external_id` | 第三方审批实例 ID仅第三方审批实例存在 |
| `instances[].link` | 三方审批跳转链接 |
| `instances[].summaries` | 摘要字段列表 |
## instance_status 枚举
| 值 | 含义 |
|----|------|
| `0` | 无流程状态,不展示对应标签 |
| `1` | 流程实例流转中 |
| `2` | 已通过 |
| `3` | 已拒绝 |
| `4` | 已撤销 |
| `5` | 已终止 |
## 常见使用场景
### 1) 找到我要操作的审批实例
```bash
lark-cli approval instances initiated --params '{"page_size":20}' --format table --as user
```
拿到 `instances[].instance_code` 后,可继续:
```bash
# 查看审批实例详情
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
# 撤回审批实例
lark-cli approval instances cancel --data '{"instance_code":"<INSTANCE_CODE>"}' --as user --yes
```
### 2) 只看某类审批
```bash
lark-cli approval instances initiated \
--params '{"definition_code":"<DEFINITION_CODE>","page_size":20}' \
--as user
```
## 使用建议
- **这是定位“我发起的审批实例”的首选命令**:如果你的目标是撤回、抄送、查看某个已发起审批,优先从这里拿 `instance_code`
- **优先用 `definition_code` 缩小范围**:当你已知审批定义时,先筛掉无关实例,可显著提升可读性。
- **结果很多时优先 `--format table`**:适合人工快速浏览。
- **`count` 只在第一页返回**:做分页处理时不要假设后续页还会带总数。
- **`instance_status` 可直接判断下一步**:例如状态为 `1` 时通常可继续查看详情或考虑撤回,状态为 `4` 表示已经撤销,无需重复撤回。
- **摘要字段 `summaries` 很适合做列表预览**:当审批标题不够明确时,可结合摘要值帮助识别目标实例。
## 输出与后续操作
拿到列表后,常见下一步:
```bash
# 查看单个审批实例详情
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
# 撤回审批实例
lark-cli approval instances cancel --data '{"instance_code":"<INSTANCE_CODE>"}' --as user --yes
# 给审批实例追加抄送人
lark-cli approval instances cc --data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["<USER_ID>"]}' --params '{"user_id_type":"open_id"}' --as user --yes
```

View File

@@ -1,120 +0,0 @@
# approval tasks add_sign
给一个审批任务加签(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id``instance_code`,确认目标任务后,再提供被加签人的用户 ID、加签方式等参数执行加签。
> [!CAUTION]
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要对该审批任务加签且目标任务、加签对象、加签方式都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
需要的 scopes: ["approval:task:write"]
## 命令
```bash
# 先预览请求,不实际执行
lark-cli approval tasks add_sign \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":1,"add_sign_user_ids":["ou_xxx"],"approval_method":1,"comment":"前加签给财务复核"}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--dry-run
# 前加签(需要 approval_method
lark-cli approval tasks add_sign \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":1,"add_sign_user_ids":["ou_xxx"],"approval_method":1,"comment":"请先补充审核"}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
# 后加签(需要 approval_method
lark-cli approval tasks add_sign \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":2,"add_sign_user_ids":["ou_xxx","ou_yyy"],"approval_method":2,"comment":"当前审批完成后请两位继续审核"}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
# 并加签(常见场景可不传 approval_method
lark-cli approval tasks add_sign \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":3,"add_sign_user_ids":["123456789"],"comment":"并加签给项目 owner"}' \
--params '{"user_id_type":"user_id"}' \
--as user \
--yes
# 通过文件传入请求体,适合较长 comment 或较多加签人
lark-cli approval tasks add_sign \
--data @./add-sign-body.json \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `tasks query``instances initiated` / `instances get` 获取 |
| `task_id` | 是 | 审批任务 ID通常先通过 `tasks query` 获取 |
| `add_sign_type` | 是 | 加签类型:`1` 前加签、`2` 后加签、`3` 并加签 |
| `add_sign_user_ids` | 是 | 被加签人 ID 数组;需要和 `user_id_type` 保持一致 |
| `approval_method` | 否 | 审批方式:`1` 或签、`2` 会签、`3` 依次审批;**仅在前加签、后加签时需要填写** |
| `comment` | 否 | 审批意见或加签说明,例如 `前加签给财务复核``请项目 owner 一并确认` |
| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON用于声明 `add_sign_user_ids` 内用户 ID 的类型 |
| `user_id_type` | 否 | 用户 ID 类型:`user_id``union_id``open_id`;未显式指定时要特别确认被加签人的 ID 类型 |
| `--as user` | 否 | 建议显式指定用户身份;审批加签通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 枚举说明
### add_sign_type
| 值 | 含义 |
|----|------|
| `1` | 前加签 |
| `2` | 后加签 |
| `3` | 并加签 |
### approval_method
| 值 | 含义 | 适用场景 |
|----|------|----------|
| `1` | 或签 | 前加签 / 后加签 |
| `2` | 会签 | 前加签 / 后加签 |
| `3` | 依次审批 | 前加签 / 后加签 |
## 典型前置步骤
先查到待办任务:
```bash
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
常用到的字段:
| 字段 | 说明 |
|------|------|
| `tasks[].instance_code` | 审批实例 Code执行 approve / reject / transfer / rollback / add_sign 等操作时通常都需要 |
| `tasks[].task_id` | 审批任务 ID`instance_code` 配对使用 |
| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;加签前建议先检查 |
如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID再执行加签。
如需先确认表单、节点、审批流进度,可继续查看实例详情:
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
## 使用建议
- **`instance_code``task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行加签操作。
- **`add_sign_user_ids``user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。
- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。
- **`add_sign_type` 要和业务意图一致**:前加签是在当前审批前插入审批人,后加签是在当前审批后追加审批人,并加签则是增加并行审批人。
- **前加签 / 后加签要补 `approval_method`**:不要遗漏,否则请求可能无法准确表达审批方式。
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 add_sign 的输入来源。
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate``false`,说明该任务可能不支持通过 API 执行处理动作,加签前应谨慎验证。
- **`comment` 建议写明加签原因**:例如 `增加财务复核``增加项目 owner 并行确认`,方便相关人员理解上下文。
- **先 `--dry-run` 再执行**:尤其在多人加签、跨部门加签或加签对象来源不明确时,先预览更安全。

View File

@@ -1,81 +0,0 @@
# approval tasks approve
同意一个审批任务(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id``instance_code`,必要时再用 `instances get` 查看详情,然后再执行同意。
> [!CAUTION]
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确同意审批且目标任务无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
需要的 scopes: ["approval:task:write"]
## 命令
```bash
# 先预览请求,不实际执行
lark-cli approval tasks approve \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"同意"}' \
--as user \
--dry-run
# 同意审批任务,并附带审批意见
lark-cli approval tasks approve \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"同意"}' \
--as user \
--yes
# 需要回填表单时,传入 form按当前命令定义form 为字符串化 JSON
lark-cli approval tasks approve \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"同意并补充信息","form":"[{\"id\":\"user_name\",\"type\":\"input\",\"value\":\"Alice\"}]"}' \
--as user \
--yes
# 通过文件传入请求体,适合较长 comment / form
lark-cli approval tasks approve \
--data @./approve-body.json \
--as user \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `tasks query``instances initiated` / `instances get` 获取 |
| `task_id` | 是 | 审批任务 ID通常先通过 `tasks query` 获取 |
| `comment` | 否 | 审批意见,例如 `同意``已确认` |
| `form` | 否 | 表单数据;按当前命令定义,字段类型为 `string`,通常传字符串化 JSON仅在审批动作需要同时回填表单时使用 |
| `--as user` | 否 | 建议显式指定用户身份;审批同意通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 典型前置步骤
先查到待办任务:
```bash
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
常用到的两个字段:
| 字段 | 说明 |
|------|------|
| `tasks[].instance_code` | 审批实例 Code执行 approve / reject / rollback 等操作时通常都需要 |
| `tasks[].task_id` | 审批任务 ID`instance_code` 配对使用 |
如需先确认表单、节点、审批流进度,可继续查看实例详情:
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
## 使用建议
- **`instance_code``task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行同意操作。
- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 approve 的输入来源。
- **先检查是否支持 API 操作**:如果上一步 `tasks query` 返回的 `tasks[].support_api_operate``false`,说明该任务可能不支持通过 API 同意/拒绝。
- **`comment` 建议简洁明确**:例如 `同意``同意,信息已核对`。没有审批意见要求时可省略。
- **`form` 只在确有需要时传**:大多数简单同意场景只传 `instance_code``task_id`、可选 `comment` 即可。
- **先 `--dry-run` 再执行**:尤其在批量处理、表单回填或任务来源不明确时,先预览更安全。

View File

@@ -1,76 +0,0 @@
# approval tasks query
查询当前用户的审批任务列表,可用于查看待办、已办、知会等分组。只读操作,不会修改审批状态。
需要的 scopes: ["approval:task:read"]
## 命令
```bash
# 查询待办审批
lark-cli approval tasks query --params '{"topic":"1"}' --as user
# 查询已办审批
lark-cli approval tasks query --params '{"topic":"2"}' --as user
# 使用 page_token 翻页
lark-cli approval tasks query --params '{"topic":"1","page_token":"example_page_token"}' --as user
# 表格格式输出,便于快速浏览
lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--params '{"topic":"..."}'` | 是 | 查询参数,使用 JSON 传入 |
| `topic` | 是 | 任务分组主题见下方“topic 枚举” |
| `definition_code` | 否 | 审批定义 Code用于仅查询某个审批定义下的任务 |
| `locale` | 否 | 返回语言:`zh-CN``en-US``ja-JP` |
| `page_size` | 否 | 分页大小 |
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
| `user_id_type` | 否 | 用户 ID 类型:`user_id``union_id``open_id` |
| `--as user` | 否 | 建议显式指定用户身份;审批任务查询通常应使用用户身份 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## topic 枚举
| 值 | 含义 |
|----|------|
| `1` | 待办审批 |
| `2` | 已办审批 |
| `17` | 未读知会 |
| `18` | 已读知会 |
## 输出重点字段
返回结果中常见字段:
| 字段 | 说明 |
|------|------|
| `count` | 列表计数,只在第一页返回;当任务数大于等于 100 时返回 `99` |
| `has_more` | 是否还有更多数据 |
| `page_token` | 下一页翻页 Token |
| `tasks[].task_id` | 任务 ID全局唯一 |
| `tasks[].instance_code` | 审批实例 Code后续执行 approve / reject / rollback 等操作时通常需要与 `task_id` 成对使用 |
| `tasks[].title` | 任务标题 |
| `tasks[].status` | 任务状态:`1` 待办、`2` 已办、`17` 未读、`18` 已读、`33` 处理中、`34` 撤回 |
| `tasks[].topic` | 任务所属分组主题 |
| `tasks[].instance_status` | 审批实例状态:`0` 无状态、`1` 流转中、`2` 已通过、`3` 已拒绝、`4` 已撤销、`5` 已终止 |
| `tasks[].definition_code` | 审批定义 Code |
| `tasks[].definition_name` | 审批定义名称 |
| `tasks[].initiator` | 发起人 ID |
| `tasks[].initiator_name` | 发起人姓名 |
| `tasks[].summaries` | 表单摘要字段列表 |
| `tasks[].support_api_operate` | 是否支持通过 API 同意或拒绝该任务 |
| `tasks[].user_id` | 任务所属用户 ID |
## 使用建议
- 常见处理链:先用 `tasks query` 拿到 `task_id``instance_code`,若用户需要查看详情、当前节点、表单内容、流程进度等内容,则调用 `instances get` 查看详情,最后执行 `tasks approve` / `tasks reject` / `tasks transfer` / `tasks add_sign` / `tasks rollback`
- 如果你只想看“已发起的审批实例”,使用 `instances initiated``tasks query` 更适合围绕“任务分组”来拉取列表。
- 需要继续翻页时,直接把上一次返回的 `page_token` 放回 `--params`
- 当结果量较大时,优先使用 `--format table` 提升可读性。

View File

@@ -1,73 +0,0 @@
# approval tasks reject
拒绝一个审批任务(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id``instance_code`,必要时再用 `instances get` 查看详情,然后再执行拒绝。
> [!CAUTION]
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要拒绝该审批且目标任务无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
需要的 scopes: ["approval:task:write"]
## 命令
```bash
# 先预览请求,不实际执行
lark-cli approval tasks reject \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"拒绝"}' \
--as user \
--dry-run
# 拒绝审批任务,并附带审批意见
lark-cli approval tasks reject \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"拒绝,信息不完整"}' \
--as user \
--yes
# 通过文件传入请求体,适合较长 comment
lark-cli approval tasks reject \
--data @./reject-body.json \
--as user \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `tasks query``instances initiated` / `instances get` 获取 |
| `task_id` | 是 | 审批任务 ID通常先通过 `tasks query` 获取 |
| `comment` | 否 | 审批意见,例如 `拒绝``拒绝,信息不完整` |
| `--as user` | 否 | 建议显式指定用户身份;审批拒绝通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 典型前置步骤
先查到待办任务:
```bash
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
常用到的两个字段:
| 字段 | 说明 |
|------|------|
| `tasks[].instance_code` | 审批实例 Code执行 approve / reject / rollback 等操作时通常都需要 |
| `tasks[].task_id` | 审批任务 ID`instance_code` 配对使用 |
如需先确认表单、节点、审批流进度,可继续查看实例详情:
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
## 使用建议
- **`instance_code``task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行拒绝操作。
- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 reject 的输入来源。
- **先检查是否支持 API 操作**:如果上一步 `tasks query` 返回的 `tasks[].support_api_operate``false`,说明该任务可能不支持通过 API 同意/拒绝。
- **`comment` 建议写清拒绝原因**:例如 `拒绝,缺少合同附件``拒绝,预算字段填写不完整`。这有助于发起人理解原因并补充材料。
- **先 `--dry-run` 再执行**:尤其在批量处理或任务来源不明确时,先预览更安全。

View File

@@ -1,82 +0,0 @@
# approval tasks remind
对审批实例中的指定任务发起催办(用户级写操作)。通常先通过 `tasks query` 找到待办任务,拿到 `instance_code` 和要催办的 `task_ids`,必要时再用 `instances get` 查看详情,然后执行催办。
> [!CAUTION]
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要催办该审批且目标实例、目标任务都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
需要的 scopes: ["approval:instance:write"]
## 命令
```bash
# 先预览请求,不实际执行
lark-cli approval tasks remind \
--data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID>"],"comment":"请尽快处理"}' \
--as user \
--dry-run
# 催办单个审批任务
lark-cli approval tasks remind \
--data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID>"],"comment":"请尽快审批该单据"}' \
--as user \
--yes
# 同一实例下催办多个任务
lark-cli approval tasks remind \
--data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID_1>","<TASK_ID_2>"],"comment":"请相关审批人尽快处理"}' \
--as user \
--yes
# 通过文件传入请求体,适合较长 comment 或多个 task_ids
lark-cli approval tasks remind \
--data @./remind-body.json \
--as user \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `tasks query``instances get` 获取 |
| `task_ids` | 是 | 被催办的任务 ID 数组;应与 `instance_code` 属于同一审批实例 |
| `comment` | 否 | 催办说明,例如 `请尽快处理``该单据较急,请优先审批` |
| `--as user` | 否 | 建议显式指定用户身份;审批催办通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 典型前置步骤
先查到待办任务:
```bash
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
常用到的字段:
| 字段 | 说明 |
|------|------|
| `tasks[].instance_code` | 审批实例 Code催办时必须提供 |
| `tasks[].task_id` | 审批任务 ID放入 `task_ids` 数组中 |
| `tasks[].title` | 任务标题,可用于确认催办对象是否正确 |
| `tasks[].status` | 任务状态;一般优先催办仍处于待处理状态的任务 |
如需进一步确认当前审批流、节点和人员信息,可继续查看实例详情:
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
## 使用建议
- **`instance_code``task_ids` 要对应同一个审批实例**:不要把不同实例下的任务 ID 混在同一次催办请求中。
- **`task_ids` 是数组**:即使只催办一个任务,也要按数组形式传入。
- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 remind 的输入来源。
- **催办前先确认任务仍需处理**:已经审批完成、已撤回或已终止的任务一般不适合继续催办。
- **`comment` 建议简洁且明确**:例如 `该单据较急,请优先审批``请今天内处理`。避免过长或模糊描述。
- **先 `--dry-run` 再执行**:尤其在一次催办多个任务、任务来源不明确或需让用户复核催办对象时,先预览更安全。

View File

@@ -1,83 +0,0 @@
# approval tasks rollback
将一个审批任务退回到指定节点(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id``instance_code`,再结合实例详情确认可退回的目标节点 `node_ids`,最后执行退回。
> [!CAUTION]
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要退回该审批且目标任务、退回节点都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
需要的 scopes: ["approval:task:write"]
## 命令
```bash
# 先预览请求,不实际执行
lark-cli approval tasks rollback \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID>"],"comment":"退回补充材料"}' \
--as user \
--dry-run
# 退回到单个节点
lark-cli approval tasks rollback \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID>"],"comment":"请补充附件后重新提交"}' \
--as user \
--yes
# 传多个候选节点 ID以实际审批定义支持情况为准
lark-cli approval tasks rollback \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID_1>","<NODE_ID_2>"],"comment":"退回上一处理节点"}' \
--as user \
--yes
# 通过文件传入请求体,适合较长 comment 或较多 node_ids
lark-cli approval tasks rollback \
--data @./rollback-body.json \
--as user \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `tasks query``instances initiated` / `instances get` 获取 |
| `task_id` | 是 | 审批任务 ID通常先通过 `tasks query` 获取 |
| `node_ids` | 是 | 退回目标节点 ID 数组;执行前应先确认这些节点确实可作为退回目标 |
| `comment` | 否 | 审批意见或退回说明,例如 `请补充附件后重新提交``预算说明不完整,请补充` |
| `--as user` | 否 | 建议显式指定用户身份;审批退回通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 典型前置步骤
先查到待办任务:
```bash
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
常用到的字段:
| 字段 | 说明 |
|------|------|
| `tasks[].instance_code` | 审批实例 Code执行 approve / reject / transfer / rollback 等操作时通常都需要 |
| `tasks[].task_id` | 审批任务 ID`instance_code` 配对使用 |
| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;退回前建议先检查 |
如需确认流程节点、当前进度和可退回位置,可先查看实例详情:
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
## 使用建议
- **`instance_code``task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行退回操作。
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组。
- **先确认节点是否可退回**:不同审批定义支持的退回目标可能不同;在不确定时,先通过 `instances get` 或业务侧流程信息核实。
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 rollback 的输入来源。
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate``false`,说明该任务可能不支持通过 API 执行处理动作,退回前应谨慎验证。
- **`comment` 建议写清退回原因**:例如 `附件缺失,请补齐后重新提交``费用说明不完整,请补充明细`,方便发起人或上一步处理人理解原因。
- **先 `--dry-run` 再执行**:尤其在节点来源不明确、审批链路复杂或批量处理时,先预览更安全。

View File

@@ -1,91 +0,0 @@
# approval tasks transfer
转交一个审批任务给其他用户处理(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id``instance_code`,确认目标任务后,再提供被转交人的用户 ID 执行转交。
> [!CAUTION]
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要转交该审批且目标任务、转交对象都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
需要的 scopes: ["approval:task:write"]
## 命令
```bash
# 先预览请求,不实际执行
lark-cli approval tasks transfer \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","transfer_user_id":"ou_xxx","comment":"请你继续处理"}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--dry-run
# 按 open_id 转交审批任务
lark-cli approval tasks transfer \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","transfer_user_id":"ou_xxx","comment":"转交给你处理"}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
# 按 user_id 转交审批任务
lark-cli approval tasks transfer \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","transfer_user_id":"123456789","comment":"请补充审核"}' \
--params '{"user_id_type":"user_id"}' \
--as user \
--yes
# 通过文件传入请求体,适合较长 comment
lark-cli approval tasks transfer \
--data @./transfer-body.json \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `tasks query``instances initiated` / `instances get` 获取 |
| `task_id` | 是 | 审批任务 ID通常先通过 `tasks query` 获取 |
| `transfer_user_id` | 是 | 被转交人的用户 ID需要和 `user_id_type` 保持一致 |
| `comment` | 否 | 审批意见或转交说明,例如 `转交给你处理``请继续审核该单据` |
| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON用于声明 `transfer_user_id` 的 ID 类型 |
| `user_id_type` | 否 | 用户 ID 类型:`user_id``union_id``open_id`;未显式指定时要特别确认 `transfer_user_id` 的真实类型 |
| `--as user` | 否 | 建议显式指定用户身份;审批转交通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 典型前置步骤
先查到待办任务:
```bash
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
常用到的字段:
| 字段 | 说明 |
|------|------|
| `tasks[].instance_code` | 审批实例 Code执行 approve / reject / transfer / rollback 等操作时通常都需要 |
| `tasks[].task_id` | 审批任务 ID`instance_code` 配对使用 |
| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;转交前建议先检查 |
如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID再执行转交。
如需先确认表单、节点、审批流进度,可继续查看实例详情:
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
## 使用建议
- **`instance_code``task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行转交操作。
- **`transfer_user_id``user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。
- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 transfer 的输入来源。
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate``false`,说明该任务可能不支持通过 API 执行同意/拒绝等处理动作,转交前也应谨慎验证。
- **`comment` 建议写明转交原因**:例如 `你更熟悉该项目,请继续处理``转交给预算 owner 审核`,方便接收人理解上下文。
- **先 `--dry-run` 再执行**:尤其在跨部门转交、批量处理或转交对象来源不明确时,先预览更安全。

View File

@@ -1,11 +1,11 @@
---
name: lark-doc
version: 2.0.0
description: "飞书云文档Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
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 --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; lark-cli mindnotes nodes list --help; lark-cli mindnotes nodes create --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
@@ -45,7 +45,6 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
- 用户需要统计文档的**总字数 / 总字符数**word count / character count先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理

View File

@@ -60,7 +60,6 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
| ------------------- | -- |---------------------------------------------|
| `--title` | 否 | 文档标题Markdown 导入时使用XML 创建推荐在 `--content` 开头写 `<title>...</title>`;多个标题仅保留第一个并在 `warnings` / `degrade_details` 提示 |
| `--content` | 视情况 | 文档内容XML 或 Markdown 格式);不传 `--content` 时必须传 `--title` |
| `--reference-map` | 否 | 结构化 `reference_map` JSON object必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
| `--parent-token` | 否 | 父文件夹或知识库节点 token`--parent-position` 互斥) |
| `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) |

View File

@@ -1,113 +0,0 @@
# 飞书思维笔记Mindnote
> **前置条件:** 先阅读 [`../SKILL.md`](../SKILL.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和路由规则。
当用户要操作思维笔记时,入口属于 `lark-doc`,但实际执行命令使用 `lark-cli mindnotes nodes list/create`,不是 `docs +...`
> [!IMPORTANT]
> 当前这条链路只支持**读取已有思维笔记**,以及在**已有思维笔记**里读取节点、创建子节点。
> `mindnotes nodes create` 是新增/更新节点命令,**不是**新建一个新的思维笔记。
> 如果用户要**新建思维笔记**,不要走本链路,改走 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
## 命令
```bash
# 先看命令帮助
lark-cli mindnotes nodes list --help
lark-cli mindnotes nodes create --help
# 读取节点列表
lark-cli mindnotes nodes list --mindnote-id "<mindnote_token>"
# 创建子节点
lark-cli mindnotes nodes create \
--mindnote-id "<mindnote_token>" \
--data '{"client_token":"<client_token>","nodes":[{"parent_id":"node_parent123","texts":[{"element_type":"text","text":{"content":"子节点内容"}}],"highlight":"yellow","finish":false}]}'
# 更新已有节点
lark-cli mindnotes nodes create \
--mindnote-id "<mindnote_token>" \
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","texts":[{"element_type":"text","text":{"content":"更新后的节点内容"}}],"highlight":"blue","finish":true}]}'
```
## 参数
### `mindnotes nodes list`
| 参数 | 必填 | 说明 |
|------|------|------|
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
返回重点:`data.nodes` 中常见字段有 `node_id``parent_id``texts``notes``images``finish``highlight`
### `mindnotes nodes create`
命令参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
| `--data` | 是 | JSON 请求体 |
请求体字段:
| 字段 | 必填 | 说明 |
|------|------|------|
| `client_token` | 否 | 幂等 token建议写操作传入推荐使用时间戳或 UUID |
| `nodes` | 是 | 待创建或更新的节点数组 |
| `nodes[].node_id` | 否 | 节点 ID传入已有 `node_id` 时表示更新对应节点 |
| `nodes[].parent_id` | 否 | 父节点 ID创建子节点时传入 |
| `nodes[].texts` | 否 | 节点正文富文本数组 |
| `nodes[].notes` | 否 | 节点备注富文本数组 |
| `nodes[].images` | 否 | 节点图片列表 |
| `nodes[].highlight` | 否 | `red` / `yellow` / `pink` / `blue` / `cyan` / `olive` / `grey` |
| `nodes[].finish` | 否 | 节点完成状态 |
富文本字段 `texts` / `notes` 是元素数组。最常见的是:
```json
[{"element_type":"text","text":{"content":"节点内容"}}]
```
### 节点图片(`nodes[].images`
`nodes[].images` 接收的是**图片 token**,不是本地文件路径,也不是 URL。
```bash
# 先上传图片,拿到 token
lark-cli docs +media-upload --file ./image.png --parent-type mindnote_image --parent-node <mindnote_token>
# 再把 token 写进节点
lark-cli mindnotes nodes create \
--mindnote-id "<mindnote_token>" \
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","images":[{"token":"canonical_token"}]}]}'
```
参数说明:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file` | 是 | 本地图片路径 |
| `--parent-type` | 是 | 上传目标类型;图片使用 `mindnote_image` |
| `--parent-node` | 是 | 传 Mindnote 的 token |
| `nodes[].images[].token` | 是 | 上传后返回的图片 token |
## 推荐工作流
1. 先判断用户目标是不是“新建一个思维笔记”。
2. 如果是新建思维笔记,切到 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
3. 如果是操作已有思维笔记,先通过 token 类别判断。
4. 确认是 **Mindnote** 后再拿到 `mindnote_id`
5. 先执行 `mindnotes nodes list`,确认目标 `parent_id`
6. 新增子节点时,在 `nodes[]` 里传 `parent_id`;更新已有节点时,在 `nodes[]` 里传已有 `node_id`
7. 再执行 `mindnotes nodes create`
8. 写操作优先带 `client_token`,推荐使用时间戳或 UUID避免重试时重复创建或重复更新。
> [!CAUTION]
> `mindnotes nodes create` 是写操作。创建时确认插入位置,更新时确认 `node_id` 指向的就是目标节点。
## 参考
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容
- [lark-doc-whiteboard](lark-doc-whiteboard.md) — 新建思维笔记走画板链路
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -24,7 +24,7 @@
| `--command` | 是 | 操作指令(见下方指令速查表) |
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
| `--content` | 视指令 | 写入内容(`str_replace` 传空字符串可实现删除) |
| `--reference-map` | 否 | 结构化 `reference_map` JSON object必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
| `--reference-map` | 否 | 结构化 `reference_map` JSON object `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map` |
| `--pattern` | 视指令 | 匹配文本str_replace |
| `--block-id` | 视指令 | 目标 block IDblock_* 操作),逗号分隔可批量删除,-1 表示末尾 |
| `--src-block-ids` | 视指令 | 源 block ID逗号分隔用于 block_copy_insert_after / block_move_after |

View File

@@ -24,8 +24,8 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
脚本输出 JSON。对用户汇报时默认只读两个核心字段
- `word_count`:总字数。按语义单位统计汉字、英文单词/URL/code path、数字、中文标点普通贴着英文的英文标点不计入但独立 ASCII 符号、中文之间的 `/` 等以脚本结果为准
- `char_count`:总字符数。统计汉字、英文字母、数字、中英文标点和脚本识别的可见符号;空格不计入。
- `word_count`:总字数。按语义单位统计汉字、英文单词、数字、中文标点;英文标点不计入
- `char_count`:总字符数。统计汉字、英文字母、数字、中英文标点;空格不计入。
其余字段用于排查或解释:

View File

@@ -78,10 +78,6 @@ ENGLISH_PUNCTUATION = set(
LexemeKind = Literal["english", "number"]
URL_TOKEN_RE = re.compile(r"https?://[!-~]+")
ASCII_COMPOUND_TOKEN_RE = re.compile(
r"[A-Za-z0-9]+(?:[._/@:-][A-Za-z0-9]+)+"
)
@dataclass
@@ -185,17 +181,8 @@ class Counter:
return self.stats
def write(self, text: str) -> None:
i = 0
while i < len(text):
consumed = self._write_ascii_compound_token(text, i)
if consumed:
i += consumed
continue
if self._write_visible_ascii_separator(text, i):
i += 1
continue
self._write_char(text[i])
i += 1
for ch in text:
self._write_char(ch)
def write_marker(self, text: str) -> None:
for ch in text:
@@ -349,65 +336,6 @@ class Counter:
self._end_symbol_run(count_word=False)
self._at_boundary = False
def _write_visible_ascii_separator(self, text: str, index: int) -> bool:
ch = text[index]
if ch != "/" or index == 0 or index + 1 >= len(text):
return False
if not is_han(text[index - 1]) or not is_han(text[index + 1]):
return False
self._end_unit()
self.stats.english_punctuations += 1
self.stats.symbol_words += 1
self.stats.word_count += 1
self.stats.char_count += 1
self._at_boundary = False
return True
def _write_ascii_compound_token(self, text: str, start: int) -> int:
token = self._match_ascii_compound_token(text, start)
if not token:
return 0
self._end_unit()
self.stats.english_words += 1
self.stats.word_count += 1
for ch in token:
if is_ascii_letter(ch):
self.stats.english_letters += 1
self.stats.char_count += 1
elif is_digit(ch):
self.stats.digits += 1
self.stats.char_count += 1
elif is_english_punctuation(ch):
self.stats.english_punctuations += 1
self.stats.char_count += 1
elif is_unicode_symbol(ch):
units = utf16_units(ch)
self.stats.symbol_chars += units
self.stats.char_count += units
elif is_chinese_punctuation(ch):
self.stats.chinese_punctuations += 1
self.stats.char_count += 1
elif is_han(ch):
self.stats.han_chars += 1
self.stats.char_count += 1
self._at_boundary = False
return len(token)
def _match_ascii_compound_token(self, text: str, start: int) -> str | None:
match = URL_TOKEN_RE.match(text, start)
if match:
return match.group(0)
match = ASCII_COMPOUND_TOKEN_RE.match(text, start)
if not match:
return None
token = match.group(0)
if any(is_ascii_letter(ch) for ch in token):
return token
return None
def _write_symbol_char(self, ch: str) -> None:
self._end_lexeme()
self._end_symbol_run(count_word=False)
@@ -433,7 +361,7 @@ class Counter:
self._lexeme_has_digit = False
def _end_symbol_run(self, *, count_word: bool) -> None:
if self._symbol_run_length >= 1 and count_word:
if self._symbol_run_length >= 2 and count_word:
self.stats.symbol_words += 1
self.stats.word_count += 1
if self._symbol_run_length:

View File

@@ -59,8 +59,6 @@ The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+m
### Card Messages (Interactive)
**Before sending or replying with any `interactive` card (`+messages-send` / `+messages-reply`), you MUST read [`references/card/lark-im-card-create.md`](references/card/lark-im-card-create.md) and follow its workflow.** The card JSON passed to `--msg-type interactive --content` must be the output of that workflow — never hand-write or copy a card payload.
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.
`interactive` cards support callback events (`card.action.trigger`) — see [`references/lark-im-card-action-reply.md`](references/lark-im-card-action-reply.md).
@@ -104,7 +102,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|----------|------|
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) |
| [`+chat-members-list`](references/lark-im-chat-members-list.md) | List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket |
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) |
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
@@ -142,8 +139,10 @@ lark-cli im <resource> <method> [flags] # 调用 API
### chat.members
- `bots` — 获取群内机器人列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
- `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`.
- `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request.
- `get` — 获取群成员列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
### chat.user_setting
@@ -214,10 +213,10 @@ lark-cli im <resource> <method> [flags] # 调用 API
| `chats.get` | `im:chat:read` |
| `chats.link` | `im:chat:read` |
| `chats.update` | `im:chat:update` |
| `chat.members.bots` | `im:chat.members:read` |
| `chat.members.create` | `im:chat.members:write_only` |
| `chat.members.delete` | `im:chat.members:write_only` |
| `chat.members.get` | `im:chat.members:read` |
| `+chat-members-list` | `im:chat.members:read` |
| `chat.user_setting.batch_query` | `im:chat.user_setting:read` |
| `chat.user_setting.batch_update` | `im:chat.user_setting:write` |
| `chat.managers.add_managers` | `im:chat.managers:write_only` |

View File

@@ -1,107 +0,0 @@
# 卡片 2.0 组件大纲
Card 2.0 组件按**容器 / 展示 / 交互**三类,均通过 `tag` 字段声明。先在下表按用途选组件,再点明细看字段:有明细文件的点 `components/<tag>.md`(完整字段+示例+易错点),低频组件点链接看官方文档。
## 根结构
顶层固定四字段,先搭骨架再往 `body.elements` 填组件。以下为**推荐完整骨架**(含 type scale、light/dark color token、header 三件套):
```json
{
"schema": "2.0",
"config": {
"update_multi": true,
"width_mode": "default",
"style": {
"text_size": {
"title": { "default": "heading-2", "pc": "heading-2", "mobile": "heading-3" },
"body": { "default": "normal", "pc": "normal", "mobile": "normal" },
"caption": { "default": "notation", "pc": "notation", "mobile": "notation" }
},
"color": {
"cus-primary": { "light_mode": "rgba(30,120,255,1)", "dark_mode": "rgba(80,150,255,1)" },
"cus-primary-bg": { "light_mode": "rgba(30,120,255,0.08)", "dark_mode": "rgba(80,150,255,0.12)" },
"cus-muted": { "light_mode": "rgba(100,106,115,1)", "dark_mode": "rgba(150,155,163,1)" }
}
}
},
"header": {
"title": { "tag": "plain_text", "content": "卡片标题" },
"subtitle": { "tag": "plain_text", "content": "副标题:一句上下文(时间/来源/状态)" },
"template": "blue",
"icon": { "tag": "standard_icon", "token": "notice_colorful" },
"text_tag_list": [
{ "tag": "text_tag", "text": { "tag": "plain_text", "content": "状态标签" }, "color": "blue" }
]
},
"body": { "direction": "vertical", "padding": "12px 12px 20px 12px", "elements": [] }
}
```
> **按需裁剪**`subtitle` / `text_tag_list` / color token 按实际诉求取舍,不强制全用。组件里用 `"text_size": "title"` / `"caption"` 引用 token用 `"font_color": "cus-muted"` 引用颜色 token主色系变化时只需改 config 里的 RGBA全卡自动跟随。
- `schema` 必须显式为 `"2.0"`,否则按 1.0 渲染。`header` 详见 `components/header.md`
- **元素通用字段**(所有 `elements[]` 组件):`tag`(必填) · `element_id`(卡内唯一字母开头、≤20 字符) · `margin`(外边距 [-99,99]px)。
- `card_link`(整卡跳转):`{url, pc_url, ios_url, android_url}`,至少填 `url`;某端禁跳设 `lark://msgcard/unsupported_action`
- 硬限制:单卡 ≤ **200** 元素;需客户端 **≥ 7.20**(旧版仅显示 header
- 颜色 / 图标枚举见 `resource/colors.md` · `resource/icons.md`
**config**(全局行为,可整体省略):
| 字段 | 默认 | 说明 |
|---|---|---|
| `update_multi` | true | 共享卡片v2 仅支持 true |
| `width_mode` | default | `default`(≤600px) / `compact`(400px) / `fill`(撑满) |
| `enable_forward` | true | 是否允许转发 |
| `summary` | — | 会话列表预览:`{content, i18n_content:{zh_cn,en_us,…}}` |
| `streaming_mode` | false | 流式更新模式(配 `streaming_config` |
| `style.text_size` | — | 自定义字号 token格式 `{"<名称>":{default,pc,mobile}}`;名称可自定义(如 `title`/`caption`),组件 `text_size` 引用该名称 |
| `style.color` | — | 自定义颜色 token格式 `{"<名称>":{light_mode,dark_mode}}`RGBA名称可自定义`cus-primary`),组件 `font_color`/`background_style` 等字段引用 |
> 多语言:`config.locales` 限定生效语种、`use_custom_translation` 优先用自带 i18n。
**body 布局字段**(均 v2 新增):`direction`(vertical/horizontal) · `padding`([0,99]px) · `horizontal_spacing`/`vertical_spacing`(`small`4/`medium`8/`large`12/`extra_large`16 或 px) · `horizontal_align`/`vertical_align`
---
## 容器类(布局 / 组织交互)
| 组件 | 用途 |
|---|---|
| [column_set](components/column_set.md) | 横向分栏,多列图文对齐(数据表、字段对、列表) |
| [collapsible_panel](components/collapsible_panel.md) | 折叠面板,收纳备注/长文本等次要信息 |
| [form](components/form.md) | 表单容器,批量录入表单项后一次提交 |
| [interactive_container](components/interactive_container.md) | 整块可点击区域,可统一定义样式与交互 |
| [循环容器](components/recycling_container.md) | 批量渲染同版式不同数据(仅搭建工具) |
## 展示类(无交互)
| 组件 | 用途 |
|---|---|
| [header](components/header.md) | 卡片标题区:主/副标题、后缀标签、主题色 |
| [div](components/div.md) | 普通文本,带前缀图标、字段对 |
| [markdown](components/markdown.md) | 富文本,最常用;@人、彩色、链接、列表、表格等 |
| [img](components/img.md) | 单图 |
| [img_combination](components/img_combination.md) | 多图拼排(双图/三图/宫格) |
| [person](components/person.md) | 单个人员头像/姓名 |
| [person_list](components/person_list.md) | 多个人员头像/姓名 |
| [chart](components/chart.md) | VChart 图表(折线/柱/饼/词云等) |
| [table](components/table.md) | 多列数据表(只能放根节点) |
| [hr](components/hr.md) | 分割线 |
## 交互类
| 组件 | 用途 |
|---|---|
| [button](components/button.md) | 按钮:回调 / 跳转 / 表单提交 |
| [input](components/input.md) | 文本输入框(多嵌在 form 内) |
| [overflow](components/overflow.md) | 折叠按钮组,收纳多个操作 |
| [select_static](components/select_static.md) | 下拉单选 |
| [multi_select_static](components/multi_select_static.md) | 下拉多选 |
| [select_person](components/select_person.md) | 人员单选 |
| [multi_select_person](components/multi_select_person.md) | 人员多选 |
| [date_picker](components/date_picker.md) | 日期选择器 |
| [picker_time](components/picker_time.md) | 时间选择器 |
| [picker_datetime](components/picker_datetime.md) | 日期时间选择器 |
| [select_img](components/select_img.md) | 图片选择(单/多选) |
| [checker](components/checker.md) | 勾选器,任务勾选回调 |

View File

@@ -1,63 +0,0 @@
# 按钮 `button`
交互按钮,支持跳转 / 回调 / 表单提交三类行为。**Card 2.0**。
## 最小示例
```json
{
"tag": "button",
"text": { "tag": "plain_text", "content": "确定" },
"type": "primary",
"behaviors": [{ "type": "callback", "value": { "action": "ok" } }]
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `button` |
| `text` | 否 | Object | / | `{tag:"plain_text", content}`≤100 字符 |
| `type` | 否 | String | default | 见下方 type 枚举 |
| `size` | 否 | String | medium | `tiny` / `small` / `medium` / `large` |
| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` |
| `behaviors` | 是* | Array | / | 交互行为,见下;表单内按钮不用 behaviors 而用 `form_action_type` |
| `icon` | 否 | Object | / | 前缀图标(同 `div.icon` |
| `hover_tips` | 否 | Object | / | PC 端悬浮提示plain_text |
| `disabled` | 否 | Boolean | false | 是否禁用 |
| `disabled_tips` | 否 | Object | / | 禁用后悬浮提示plain_text |
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}`(均 plain_texttitle 必填) |
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
**type 枚举**`default`(黑字描边) / `primary`(蓝字描边) / `danger`(红字描边) / `text` / `primary_text` / `danger_text`(无边框) / `primary_filled`(蓝底白字) / `danger_filled`(红底白字) / `laser`(镭射)。
## 按钮主次(强制)
- 全卡仅 1 个按钮 → `type: "primary_filled"`,并 `width: "fill"` 撑满成强焦点。
- 多个并列按钮 → 第一个(主操作)`primary_filled`,其余一律 `default`,形成「一主多次」层级。
- 删除 / 拒绝等危险操作用 `danger` 系(`danger``danger_filled`)。
## behaviors交互行为
```json
// 1. 服务端回调
{ "type": "callback", "value": { "key": "v" } }
// 2. 跳转链接(可与 callback 同数组共存)
{ "type": "open_url", "default_url": "https://x", "pc_url": "", "ios_url": "", "android_url": "" }
```
表单容器内的按钮 **不用 behaviors**,改用根字段:
| 字段 | 必填 | 说明 |
|---|---|---|
| `name` | 是 | 表单内唯一标识 |
| `form_action_type` | 是 | `submit`(提交表单)/ `reset`(重置) |
## 嵌套 / 易错点
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。
- 2.0 已废弃 `action` 交互模块,按钮直接放 `elements`,用间距控制排列。
- 旧式 `url`/`value` 顶层字段是 1.0 写法2.0 一律用 `behaviors`
- 点击触发 `card.action.trigger`,回传 `action.tag="button"` + `action.value`(即 callback 的 value

View File

@@ -1,57 +0,0 @@
# 图表 `chart`
基于 VChart 的可视化图表(折线/柱/饼/词云等)。**Card 2.0**。
## 最小示例
```json
{
"tag": "chart",
"chart_spec": {
"type": "line",
"title": { "text": "趋势" },
"data": { "values": [
{ "time": "周一", "value": 8 },
{ "time": "周二", "value": 14 }
] },
"xField": "time",
"yField": "value"
}
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `chart` |
| `chart_spec` | 是 | Object | / | VChart 图表定义,见下 |
| `aspect_ratio` | 否 | String | 16:9(PC)/1:1(移动) | `1:1` / `2:1` / `4:3` / `16:9` |
| `color_theme` | 否 | String | brand | `brand` / `rainbow` / `complementary` / `converse` / `primary`chart_spec 里声明了样式则此项无效 |
| `height` | 否 | String | auto | `auto`(按宽高比) 或 `[1,999]px`(设固定高则 aspect_ratio 失效) |
| `preview` | 否 | Boolean | true | 是否可独立窗口/全屏查看 |
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
## chart_spec 常用类型
`chart_spec` 是标准 VChart spec。核心字段`type``data.values`(数据数组)、`xField`/`yField`(轴字段)、`seriesField`(分组)、`title.text``legends`
| 图表 | type | 关键字段 |
|---|---|---|
| 折线 | `line` | `xField`, `yField` |
| 面积 | `area` | `xField`, `yField` |
| 柱状 | `bar` | `xField`, `yField`,分组加 `seriesField` |
| 条形(横向) | `bar` | `direction:"horizontal"``xField`=值,`yField`=类别 |
| 饼/环 | `pie` | `valueField`, `categoryField`,环图加 `innerRadius` |
| 散点 | `scatter` | `xField`, `yField` |
| 词云 | `wordCloud` | `nameField`, `valueField` |
完整属性参考 [VChart 官方文档](https://www.visactor.io/vchart/option/barChart)。
## 易错点
- 不支持 JavaScript 语法,`chart_spec` 必须是纯 JSON。
- 单卡建议 ≤5 个图表。
- 移动端不支持部分 VChart 属性(纹理 texture、conical 渐变、grid 词云布局等),用了会在移动端加载失败。
- 平台默认给 chart_spec 追加 media query 自适应;要自控可设 `"media": []`

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