Compare commits

..

8 Commits

Author SHA1 Message Date
liangshuo-1
29a97dbde8 chore: release v1.0.80 (#2101) 2026-07-29 21:37:15 +08:00
R0bynZhu
29a6a7b600 docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
* docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由

trace 里 +create 的三类高频错误(--yes、--name、--slides 塞文件路径)
共同点是调用前没读 lark-slides-create.md。原因不是文档缺内容,而是
SKILL.md 里 +create 的信息「够又不够」:给了半截参数描述,模型觉得
够用就直接拼命令,不再打开文档。

- 删掉「创建方式选择」整节(表格 + 两条 WARNING),下沉到 create.md,
  由生成流程 Step 3 和核心规则 2 指向那份文档
- Shortcuts 表 +create 行、核心规则 2 不再复述参数
- Quick Reference 顶部说明参数以文档和 --help 为准,「新建 PPT」行补上
  create.md
- PPTX 一行改写为 drive +import 导入路径;create.md 里写明本命令不读
  本地文件
- create.md 增加「--slides 不接受的形态」对照表,并合并开头零散的
  禁止/推荐/最稳/注意条目
- @ 占位符统一写成 <img src="@./path">,消除「--slides 支持 @ 路径」的歧义

* docs(slides): 去掉 create.md 里的「--slides 不接受的形态」对照表

* docs(slides): 模板一行的触发条件补上「已有 PPTX 要改」

* docs(slides): create.md 澄清「不读取本地文件」的歧义

原句「本命令只从零创建演示文稿,不读取本地文件」与本文档
「本地图片:@<path> 占位符」一节自相矛盾——@ 占位符恰恰会读
本地图片并自动上传。改为只否定「导入本地 PPT 文件的参数」,
不波及图片占位符能力。

* docs(slides): 两步创建的第二步补上 slide create 文档路由

生成流程 Step 3 和「执行前必做」的创建一行原来只指向
lark-slides-create.md,而两步创建的第二步用的是
xml_presentation.slide create,文档没被路由到,模型只能凭
记忆拼参数。
2026-07-29 20:50:24 +08:00
liangshuo-1
c167163d70 feat: propagate invocation metadata (#2097) 2026-07-29 19:39:53 +08:00
zhaojiaxing-coding
7988515e1c feat(drive): add +permission-get-setting shortcut (#1738)
* feat(drive): add +permission-get-setting shortcut

Add a Drive shortcut for reading public permission settings across supported documents, files, folders, and wiki nodes. Resolve URLs into typed resources, preserve permission_public output for machine consumers, and document the shortcut in the permission-governance workflow.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Query the Drive v2 public permission endpoint with typed validation and user or bot identity

- Support folder permission inspection without recursing into child resources

- Add unit, dry-run E2E, live workflow, output, and skill guidance coverage

* fix(drive): harden permission get setting contract

Harden +permission-get-setting after review findings so callers receive only the documented permission payload and folder support is verified against the live workflow. This prevents malformed responses from being presented as permission settings and keeps the command guidance aligned with the shortcut contract.

Key fixes:
- Reject responses without data.permission_public instead of projecting arbitrary payload fields
- Render complete permission settings in pretty output and mark --token required
- Exercise a created Drive folder in the live workflow and add the command reference
- Correct folder resolution guidance while retaining the shortcut's documented URL forms

* feat/drive-folder-permission-get
2026-07-29 17:57:24 +08:00
zhaojiaxing-coding
c7adff7a3b feat(drive): add +member-list shortcut (#1795)
* feat(drive): add +member-list shortcut

Add a Drive shortcut for listing collaborators on documents, files, folders, and wiki nodes. Resolve supported resource URLs into typed permission requests, preserve raw API data for machine consumers, and keep invalid flag combinations on typed validation paths.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Validate optional member fields and wiki-only permission type filters

- Provide pretty output, skill guidance, unit coverage, and dry-run/live E2E workflows

- Read dry-run assertions from the standard data.api success envelope

* feat/drive-member-list
2026-07-29 17:04:59 +08:00
ethan-zhx
59237f3104 Feat/detect line text overlap (#2069)
* fix: report ghost text canvas overflow

* fix(slides): detect text-line overlap in xml_text_overlap_lint
2026-07-29 16:20:59 +08:00
R0bynZhu
358cd06838 docs(slides): 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
* docs(slides): consolidate CWD-relative path rule into one global rule

State the "all local file path args must be CWD-relative (absolute
rejected)" rule once in SKILL.md 权威经验, and trim the per-command
repetitions in media-upload / create / screenshot / xml-presentations-get.
Also fix the stale xml-presentations-get param table: --output is optional
(relative), not required.

* feat: try common solution

* chore: 优化措辞

* feat: 优化措辞

* feat: 优化措辞

* docs(slides): 强调调用命令前必读对应命令文档

- 「调用命令前再读」改为「调用相关命令前必须读取相关的文档以了解命令的使用方式」,
  并把原「按需再读」列表合并进来,去掉可选语义
- 移除 lark-shared 的 CRITICAL 前置阅读要求
- Step 4 回读示例补全 `--presentation <xml_presentation_id>` 参数

* docs(slides): Shortcuts 表补充 +screenshot 并写明本地路径参数

- 新增 +screenshot 行:--slide-number 页号(从 1 开始,可重复,一次最多 10 页)、
  --output-dir 保存目录(CWD 内相对路径,默认 .lark-slides/screenshots)
- +xml-get 行补上 --presentation 和 --output(CWD 内相对路径),
  并说明省略 --output 时 XML 返回在 JSON 信封里

* revert(slides): 回退 references 下的文档改动,只保留 SKILL.md

把 lark-slides-create.md、lark-slides-media-upload.md、lark-slides-screenshot.md、
lark-slides-xml-presentations-get.md 还原为 main 的版本,本分支只改 SKILL.md。

* docs(slides): 恢复开始前必读 lark-shared 的 CRITICAL 要求

认证、权限和全局参数以 lark-shared 为准,这条前置阅读不该在本分支被删掉。

* chore: 移除output省略的说明
2026-07-29 10:55:05 +08:00
Yuxuan Zhao
b0b1ca4b5d test(e2e): wait for base role update visibility (#2087) 2026-07-28 21:45:00 +08:00
27 changed files with 2339 additions and 108 deletions

3
.github/CODEOWNERS vendored
View File

@@ -1,4 +1,7 @@
/go.mod @liangshuo-1
/go.sum @liangshuo-1
/internal/ @liangshuo-1
/shortcuts/common/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1

View File

@@ -2,6 +2,27 @@
All notable changes to this project will be documented in this file.
## [v1.0.80] - 2026-07-29
### Features
- **drive**: add +member-list shortcut (#1795)
- **drive**: add +permission-get-setting shortcut (#1738)
- propagate invocation metadata (#2097)
### Documentation
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
- **slides**: +create 的参数下沉到 create.md主 skill 只留路由 (#2096)
### Tests
- **e2e**: wait for base role update visibility (#2087)
### Misc
- Feat/detect line text overlap (#2069)
## [v1.0.79] - 2026-07-28
### Features
@@ -1701,6 +1722,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77

View File

@@ -26,6 +26,7 @@ const (
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
HeaderAgentTrace = "X-Agent-Trace"
HeaderAgentName = "X-Agent-Name"
SourceValue = "lark-cli"
@@ -55,6 +56,9 @@ func BaseSecurityHeaders() http.Header {
if v := envvars.AgentTrace(); v != "" {
h.Set(HeaderAgentTrace, v)
}
if v := envvars.AgentName(); v != "" {
h.Set(HeaderAgentName, v)
}
return h
}

View File

@@ -263,9 +263,34 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
}
// ---------------------------------------------------------------------------
// HeaderAgentTrace injection (via BaseSecurityHeaders)
// Agent headers injected via BaseSecurityHeaders
// ---------------------------------------------------------------------------
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentName, "")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
}
}
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
const agentName = "sample-agent"
t.Setenv(envvars.CliAgentName, agentName)
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != agentName {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
}
}
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders()

View File

@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
}
func TestAgentName_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentName, "claude-code")
if got := AgentName(); got != "claude-code" {
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
const agentName = "sample-agent"
t.Setenv(CliAgentName, agentName)
if got := AgentName(); got != agentName {
t.Fatalf("AgentName() = %q, want %q", got, agentName)
}
}
func TestAgentName_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentName, " cursor ")
if got := AgentName(); got != "cursor" {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
const agentName = "sample-agent"
t.Setenv(CliAgentName, " "+agentName+" ")
if got := AgentName(); got != agentName {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
}
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@larksuite/cli",
"version": "1.0.79",
"version": "1.0.80",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.79",
"version": "1.0.80",
"cpu": [
"x64",
"arm64",

View File

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

View File

@@ -0,0 +1,325 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"io"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type driveMemberListSpec struct {
Token string
Type string
Fields string
PermType string
}
var driveMemberListTypes = []string{
"doc", "sheet", "file", "wiki", "bitable", "docx",
"mindnote", "minutes", "slides", "folder",
}
var driveMemberListFields = []string{"name", "type", "avatar", "external_label"}
var driveMemberListPermTypes = []string{"container", "single_page"}
var driveMemberListURLPathToType = []struct {
Prefix string
Type string
}{
{"/drive/folder/", "folder"},
{"/docx/", "docx"},
{"/doc/", "doc"},
{"/sheets/", "sheet"},
{"/base/", "bitable"},
{"/bitable/", "bitable"},
{"/wiki/", "wiki"},
{"/file/", "file"},
{"/mindnotes/", "mindnote"},
{"/slides/", "slides"},
{"/minutes/", "minutes"},
}
func readDriveMemberListSpec(runtime *common.RuntimeContext) (driveMemberListSpec, error) {
token, resourceType, err := resolveDriveMemberListTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveMemberListSpec{}, err
}
fields, err := normalizeDriveMemberListFields(runtime.Str("fields"), runtime.Changed("fields"))
if err != nil {
return driveMemberListSpec{}, err
}
permType, err := normalizeDriveMemberListPermType(runtime.Str("perm-type"), resourceType, runtime.Changed("perm-type"))
if err != nil {
return driveMemberListSpec{}, err
}
return driveMemberListSpec{
Token: token,
Type: resourceType,
Fields: fields,
PermType: permType,
}, nil
}
func resolveDriveMemberListTarget(raw, explicitType string) (token, resourceType string, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
}
explicitType, err = normalizeDriveMemberListEnumValue(explicitType, driveMemberListTypes, "--type")
if err != nil {
return "", "", err
}
if strings.Contains(raw, "://") {
parsed, parseErr := url.Parse(raw)
if parseErr != nil || parsed.Hostname() == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token")
}
ref, ok := parseDriveMemberListResourceURLPath(parsed.Path)
if !ok {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
raw,
).WithParam("--token")
}
if explicitType != "" && explicitType != ref.Type {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
explicitType,
ref.Type,
).WithParam("--type")
}
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
return ref.Token, ref.Type, nil
}
if explicitType == "" {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type is required when --token is a bare token; accepted values: %s",
strings.Join(driveMemberListTypes, ", "),
).WithParam("--type")
}
if err := validate.ResourceName(raw, "--token"); err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
return raw, explicitType, nil
}
func parseDriveMemberListResourceURLPath(path string) (common.ResourceRef, bool) {
for _, mapping := range driveMemberListURLPathToType {
if !strings.HasPrefix(path, mapping.Prefix) {
continue
}
token := path[len(mapping.Prefix):]
token = strings.TrimRight(token, "/")
if idx := strings.IndexByte(token, '/'); idx >= 0 {
token = token[:idx]
}
token = strings.TrimSpace(token)
if token == "" {
return common.ResourceRef{}, false
}
return common.ResourceRef{Type: mapping.Type, Token: token}, true
}
return common.ResourceRef{}, false
}
func normalizeDriveMemberListFields(raw string, changed bool) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
if changed {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields cannot be blank; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
}
return "", nil
}
parts := strings.Split(raw, ",")
fields := make([]string, 0, len(parts))
seen := make(map[string]bool, len(parts))
for _, part := range parts {
field := strings.ToLower(strings.TrimSpace(part))
if field == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields contains an empty field; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
}
if field == "*" {
if len(parts) != 1 {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields=* cannot be combined with other fields").WithParam("--fields")
}
return "*", nil
}
if !driveMemberListFieldAllowed(field) {
return "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid value %q for --fields, allowed: %s, *",
strings.TrimSpace(part),
strings.Join(driveMemberListFields, ", "),
).WithParam("--fields")
}
if !seen[field] {
fields = append(fields, field)
seen[field] = true
}
}
return strings.Join(fields, ","), nil
}
func driveMemberListFieldAllowed(field string) bool {
for _, allowed := range driveMemberListFields {
if field == allowed {
return true
}
}
return false
}
func normalizeDriveMemberListPermType(raw, resourceType string, changed bool) (string, error) {
permType, err := normalizeDriveMemberListEnumValue(raw, driveMemberListPermTypes, "--perm-type")
if err != nil {
return "", err
}
if resourceType != "wiki" && changed {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type")
}
return permType, nil
}
func normalizeDriveMemberListEnumValue(raw string, allowed []string, flagName string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return "", nil
}
for _, candidate := range allowed {
if strings.EqualFold(value, candidate) {
return candidate, nil
}
}
return "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid value %q for %s, allowed: %s",
value,
flagName,
strings.Join(allowed, ", "),
).WithParam(flagName)
}
func (s driveMemberListSpec) apiPath() string {
return fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(s.Token))
}
func (s driveMemberListSpec) params() map[string]interface{} {
params := map[string]interface{}{"type": s.Type}
if s.Fields != "" {
params["fields"] = s.Fields
}
if s.PermType != "" {
params["perm_type"] = s.PermType
}
return params
}
// DriveMemberList lists collaborator/member permissions on a Drive resource.
var DriveMemberList = common.Shortcut{
Service: "drive",
Command: "+member-list",
Description: "List collaborator/member permissions on a Drive document, file, folder, or wiki node",
Risk: "read",
Scopes: []string{"docs:permission.member:retrieve"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens"},
{Name: "fields", Desc: "optional collaborator fields to return: name,type,avatar,external_label or *"},
{Name: "perm-type", Desc: "wiki permission scope filter; one of container|single_page"},
},
Tips: []string{
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
"Use --type folder for Drive folders.",
"--fields is omitted by default; pass --fields '*' or a comma-separated subset when extra collaborator fields are needed.",
"--perm-type only applies to wiki nodes.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveMemberListSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveMemberListSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("List Drive collaborator/member permissions").
GET(spec.apiPath()).
Params(spec.params())
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveMemberListSpec(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive members for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
data, err := runtime.CallAPITyped("GET", spec.apiPath(), spec.params(), nil)
if err != nil {
return err
}
if items, ok := data["items"].([]interface{}); ok {
fmt.Fprintf(runtime.IO().ErrOut, "Found %d Drive member(s)\n", len(items))
}
runtime.OutFormat(data, nil, func(w io.Writer) {
renderDriveMemberListPretty(w, data)
})
return nil
},
}
func renderDriveMemberListPretty(w io.Writer, data map[string]interface{}) {
items, _ := data["items"].([]interface{})
if len(items) == 0 {
fmt.Fprintln(w, "No Drive members found.")
return
}
for i, raw := range items {
member, _ := raw.(map[string]interface{})
fmt.Fprintf(w, "[%d] %s\n", i+1, driveMemberListValue(member["member_id"]))
fmt.Fprintf(w, " member_type: %s\n", driveMemberListValue(member["member_type"]))
fmt.Fprintf(w, " perm: %s\n", driveMemberListValue(member["perm"]))
if permType := driveMemberListValue(member["perm_type"]); permType != "-" {
fmt.Fprintf(w, " perm_type: %s\n", permType)
}
if memberType := driveMemberListValue(member["type"]); memberType != "-" {
fmt.Fprintf(w, " type: %s\n", memberType)
}
if name := driveMemberListValue(member["name"]); name != "-" {
fmt.Fprintf(w, " name: %s\n", name)
}
if avatar := driveMemberListValue(member["avatar"]); avatar != "-" {
fmt.Fprintf(w, " avatar: %s\n", avatar)
}
if label, ok := member["external_label"]; ok {
fmt.Fprintf(w, " external_label: %v\n", label)
}
fmt.Fprintln(w)
}
}
func driveMemberListValue(v interface{}) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return "-"
}

View File

@@ -0,0 +1,426 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"net/http"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func newDriveMemberListRuntime(t *testing.T, token, docType, fields, permType string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "drive +member-list"}
cmd.Flags().String("token", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("fields", "", "")
cmd.Flags().String("perm-type", "", "")
for name, value := range map[string]string{
"token": token,
"type": docType,
"fields": fields,
"perm-type": permType,
} {
if value == "" {
continue
}
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return common.TestNewRuntimeContext(cmd, driveTestConfig())
}
func TestDriveMemberListSpecResolvesTargets(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
wantTok string
wantType string
}{
{
name: "folder URL",
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
wantTok: "fldTok",
wantType: "folder",
},
{
name: "docx URL",
token: "https://example.feishu.cn/docx/doxTok",
wantTok: "doxTok",
wantType: "docx",
},
{
name: "bare folder token",
token: " fldTok ",
docType: " folder ",
wantTok: "fldTok",
wantType: "folder",
},
{
name: "mindnotes URL",
token: "https://example.feishu.cn/mindnotes/mndTok",
wantTok: "mndTok",
wantType: "mindnote",
},
{
name: "minutes URL",
token: "https://example.feishu.cn/minutes/obTok",
wantTok: "obTok",
wantType: "minutes",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, "", "")
spec, err := readDriveMemberListSpec(runtime)
if err != nil {
t.Fatalf("read spec: %v", err)
}
if spec.Token != tt.wantTok || spec.Type != tt.wantType {
t.Fatalf("spec token/type = %q/%q, want %q/%q", spec.Token, spec.Type, tt.wantTok, tt.wantType)
}
})
}
}
func TestDriveMemberListSpecValidationErrorsAreTyped(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
fields string
permType string
wantParam string
wantMessage string
}{
{
name: "missing token",
wantParam: "--token",
wantMessage: "--token is required",
},
{
name: "bare token without type",
token: "doxTok",
wantParam: "--type",
wantMessage: "--type is required",
},
{
name: "unsupported URL",
token: "https://example.feishu.cn/calendar/calTok",
wantParam: "--token",
wantMessage: "unsupported --token URL",
},
{
name: "URL type conflict",
token: "https://example.feishu.cn/docx/doxTok",
docType: "folder",
wantParam: "--type",
wantMessage: "conflicts with URL path type",
},
{
name: "invalid bare token",
token: "../bad",
docType: "folder",
wantParam: "--token",
wantMessage: "--token",
},
{
name: "invalid type",
token: "doxTok",
docType: "comment",
wantParam: "--type",
wantMessage: "invalid value",
},
{
name: "invalid fields",
token: "doxTok",
docType: "docx",
fields: "name,unknown",
wantParam: "--fields",
wantMessage: "invalid value",
},
{
name: "star mixed with fields",
token: "doxTok",
docType: "docx",
fields: "*,name",
wantParam: "--fields",
wantMessage: "cannot be combined",
},
{
name: "perm type rejected for non-wiki",
token: "doxTok",
docType: "docx",
permType: "single_page",
wantParam: "--perm-type",
wantMessage: "only applies when resource type is wiki",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
_, err := readDriveMemberListSpec(runtime)
if err == nil {
t.Fatal("expected validation error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if validationErr.Param != tt.wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
}
if !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
}
})
}
}
func TestDriveMemberListSpecParams(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
fields string
permType string
want map[string]interface{}
}{
{
name: "default omits optional params",
token: "doxTok",
docType: "docx",
want: map[string]interface{}{"type": "docx"},
},
{
name: "fields canonicalized and deduplicated",
token: "doxTok",
docType: "docx",
fields: "Name,avatar,name",
want: map[string]interface{}{"type": "docx", "fields": "name,avatar"},
},
{
name: "wiki accepts perm type",
token: "wikTok",
docType: "WIKI",
fields: "*",
permType: "SINGLE_PAGE",
want: map[string]interface{}{"type": "wiki", "fields": "*", "perm_type": "single_page"},
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
spec, err := readDriveMemberListSpec(runtime)
if err != nil {
t.Fatalf("read spec: %v", err)
}
if got := spec.params(); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("params = %#v, want %#v", got, tt.want)
}
})
}
}
func TestDriveMemberListDryRunIncludesGETRequest(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveMemberList, []string{
"+member-list",
"--token", "https://example.feishu.cn/drive/folder/fldTok",
"--fields", "*",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got struct {
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if len(got.Data.API) != 1 {
t.Fatalf("api count = %d, want 1", len(got.Data.API))
}
api := got.Data.API[0]
if api.Method != "GET" || api.URL != "/open-apis/drive/v1/permissions/fldTok/members" {
t.Fatalf("api = %#v", api)
}
if api.Params["type"] != "folder" || api.Params["fields"] != "*" {
t.Fatalf("params = %#v", api.Params)
}
if _, ok := api.Params["perm_type"]; ok {
t.Fatalf("perm_type should be omitted for folder: %#v", api.Params)
}
}
func TestDriveMemberListExecutePreservesRawData(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
var capturedQuery string
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/permissions/doxTok/members",
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.RawQuery
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"member_id": "ou_x",
"member_type": "openid",
"perm": "view",
"type": "user",
"name": "zhangsan",
"server_future": "preserved",
"external_label": true,
},
},
"server_top_level": "preserved",
},
},
})
err := mountAndRunDrive(t, DriveMemberList, []string{
"+member-list",
"--token", "doxTok",
"--type", "docx",
"--fields", "name,type,external_label",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(capturedQuery, "type=docx") ||
!strings.Contains(capturedQuery, "fields=name%2Ctype%2Cexternal_label") {
t.Fatalf("captured query = %q", capturedQuery)
}
data := decodeDriveEnvelope(t, stdout)
if data["server_top_level"] != "preserved" {
t.Fatalf("server_top_level = %#v", data["server_top_level"])
}
for _, key := range []string{"token", "type", "count"} {
if _, ok := data[key]; ok {
t.Fatalf("data[%s] = %#v, want omitted", key, data[key])
}
}
items, _ := data["items"].([]interface{})
if len(items) != 1 {
t.Fatalf("items = %#v, want one item", data["items"])
}
item, _ := items[0].(map[string]interface{})
if item["server_future"] != "preserved" || item["external_label"] != true {
t.Fatalf("item future fields not preserved: %#v", item)
}
if !strings.Contains(stderr.String(), "Found 1 Drive member") {
t.Fatalf("stderr = %q, want count log", stderr.String())
}
}
func TestDriveMemberListDeclaresScopeAndIdentities(t *testing.T) {
t.Parallel()
if !reflect.DeepEqual(DriveMemberList.Scopes, []string{"docs:permission.member:retrieve"}) {
t.Fatalf("Scopes = %v, want docs:permission.member:retrieve", DriveMemberList.Scopes)
}
if !reflect.DeepEqual(DriveMemberList.AuthTypes, []string{"user", "bot"}) {
t.Fatalf("AuthTypes = %v, want [user bot]", DriveMemberList.AuthTypes)
}
}
func TestDriveMemberListPrettyOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/permissions/wikTok/members",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"member_id": "ou_x",
"member_type": "openid",
"perm": "view",
"perm_type": "single_page",
"type": "user",
"name": "zhangsan",
},
},
},
},
})
err := mountAndRunDrive(t, DriveMemberList, []string{
"+member-list",
"--token", "wikTok",
"--type", "wiki",
"--perm-type", "single_page",
"--format", "pretty",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"[1] ou_x", "member_type: openid", "perm_type: single_page", "name: zhangsan"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q:\n%s", want, out)
}
}
}

View File

@@ -0,0 +1,241 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"fmt"
"io"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type drivePermissionGetSettingSpec struct {
Token string
Type string
}
var drivePermissionGetSettingTypes = []string{
"doc", "sheet", "file", "wiki", "bitable", "docx",
"mindnote", "minutes", "slides", "folder",
}
var drivePermissionGetSettingURLPathToType = []struct {
Prefix string
Type string
}{
{"/drive/folder/", "folder"},
{"/docx/", "docx"},
{"/doc/", "doc"},
{"/sheets/", "sheet"},
{"/base/", "bitable"},
{"/bitable/", "bitable"},
{"/wiki/", "wiki"},
{"/file/", "file"},
{"/mindnotes/", "mindnote"},
{"/slides/", "slides"},
{"/minutes/", "minutes"},
}
func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePermissionGetSettingSpec, error) {
rawToken := strings.TrimSpace(runtime.Str("token"))
explicitType := strings.ToLower(strings.TrimSpace(runtime.Str("type")))
if rawToken == "" {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--token is required",
).WithParam("--token")
}
if explicitType != "" && !drivePermissionGetSettingTypeAllowed(explicitType) {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid --type %q: allowed values are %s",
explicitType,
strings.Join(drivePermissionGetSettingTypes, ", "),
).WithParam("--type")
}
if strings.Contains(rawToken, "://") {
ref, ok := parseDrivePermissionGetSettingResourceURL(rawToken)
if !ok {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
rawToken,
).WithParam("--token")
}
if explicitType != "" && explicitType != ref.Type {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
explicitType,
ref.Type,
).WithParam("--type")
}
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
return drivePermissionGetSettingSpec{Token: ref.Token, Type: ref.Type}, nil
}
if explicitType == "" {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type is required when --token is a bare token (allowed: %s)",
strings.Join(drivePermissionGetSettingTypes, ", "),
).WithParam("--type")
}
if err := validate.ResourceName(rawToken, "--token"); err != nil {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
return drivePermissionGetSettingSpec{Token: rawToken, Type: explicitType}, nil
}
func parseDrivePermissionGetSettingResourceURL(rawURL string) (common.ResourceRef, bool) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Hostname() == "" {
return common.ResourceRef{}, false
}
for _, mapping := range drivePermissionGetSettingURLPathToType {
if !strings.HasPrefix(parsed.Path, mapping.Prefix) {
continue
}
token := parsed.Path[len(mapping.Prefix):]
token = strings.TrimRight(token, "/")
if idx := strings.IndexByte(token, '/'); idx >= 0 {
token = token[:idx]
}
token = strings.TrimSpace(token)
if token == "" {
return common.ResourceRef{}, false
}
return common.ResourceRef{Type: mapping.Type, Token: token}, true
}
return common.ResourceRef{}, false
}
func drivePermissionGetSettingTypeAllowed(docType string) bool {
for _, allowed := range drivePermissionGetSettingTypes {
if docType == allowed {
return true
}
}
return false
}
func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) string {
if runtime != nil && runtime.Config != nil {
if u := common.BuildResourceURL(runtime.Config.Brand, s.Type, s.Token); u != "" {
return u
}
}
return common.BuildResourceURL("", s.Type, s.Token)
}
func (s drivePermissionGetSettingSpec) params() map[string]interface{} {
return map[string]interface{}{"type": s.Type}
}
func (s drivePermissionGetSettingSpec) apiPath() string {
return drivePermissionPublicV2Path(s.Token)
}
func drivePermissionPublicV2Path(token string) string {
return fmt.Sprintf("/open-apis/drive/v2/permissions/%s/public", validate.EncodePathSegment(token))
}
func drivePermissionGetSettingPermissionPublic(data map[string]interface{}) (map[string]interface{}, error) {
permissionPublic := common.GetMap(data, "permission_public")
if permissionPublic == nil {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"drive permission get response missing data.permission_public",
)
}
return permissionPublic, nil
}
// DrivePermissionGetSetting queries permission_public settings for a Drive
// document, file, wiki node, or folder.
var DrivePermissionGetSetting = common.Shortcut{
Service: "drive",
Command: "+permission-get-setting",
Description: "Get public access, sharing, collaborator management, security, and comment permission settings",
Risk: "read",
Scopes: []string{"docs:permission.setting:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens", Enum: drivePermissionGetSettingTypes},
},
Tips: []string{
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
"Use --type folder for Drive folders. This shortcut reads the target's own permission settings; it does not recurse into child documents.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDrivePermissionGetSettingSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDrivePermissionGetSettingSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("Get Drive permission settings").
GET(spec.apiPath()).
Params(spec.params())
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDrivePermissionGetSettingSpec(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Getting permission settings for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
data, err := runtime.CallAPITyped(
"GET",
spec.apiPath(),
spec.params(),
nil,
)
if err != nil {
return err
}
permissionPublic, err := drivePermissionGetSettingPermissionPublic(data)
if err != nil {
return err
}
permissionPublicPretty, err := json.MarshalIndent(permissionPublic, "", " ")
if err != nil {
return errs.NewInternalError(
errs.SubtypeInvalidResponse,
"encode drive permission settings for pretty output",
).WithCause(err)
}
out := map[string]interface{}{"permission_public": permissionPublic}
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "Type: %s\n", spec.Type)
fmt.Fprintf(w, "Token: %s\n", spec.Token)
if url := spec.url(runtime); url != "" {
fmt.Fprintf(w, "URL: %s\n", url)
}
fmt.Fprintf(w, "Permission settings:\n%s\n", permissionPublicPretty)
})
return nil
},
}

View File

@@ -0,0 +1,438 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func newDrivePermissionGetSettingRuntime(t *testing.T, token, docType string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "drive +permission-get-setting"}
cmd.Flags().String("token", "", "")
cmd.Flags().String("type", "", "")
if token != "" {
if err := cmd.Flags().Set("token", token); err != nil {
t.Fatalf("set --token: %v", err)
}
}
if docType != "" {
if err := cmd.Flags().Set("type", docType); err != nil {
t.Fatalf("set --type: %v", err)
}
}
return common.TestNewRuntimeContext(cmd, driveTestConfig())
}
func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
wantTok string
wantType string
}{
{
name: "folder URL",
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
wantTok: "fldTok",
wantType: "folder",
},
{
name: "docx URL",
token: "https://example.feishu.cn/docx/doxTok",
wantTok: "doxTok",
wantType: "docx",
},
{
name: "file URL",
token: "https://example.feishu.cn/file/boxTok",
wantTok: "boxTok",
wantType: "file",
},
{
name: "wiki URL",
token: "https://example.feishu.cn/wiki/wikTok",
wantTok: "wikTok",
wantType: "wiki",
},
{
name: "minutes URL",
token: "https://example.feishu.cn/minutes/obTok",
wantTok: "obTok",
wantType: "minutes",
},
{
name: "mindnotes URL",
token: "https://example.feishu.cn/mindnotes/mndTok",
wantTok: "mndTok",
wantType: "mindnote",
},
{
name: "bare folder token",
token: " fldTok ",
docType: " folder ",
wantTok: "fldTok",
wantType: "folder",
},
{
name: "bare file token",
token: "boxTok",
docType: "file",
wantTok: "boxTok",
wantType: "file",
},
{
name: "bare wiki token",
token: "wikTok",
docType: "wiki",
wantTok: "wikTok",
wantType: "wiki",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
spec, err := readDrivePermissionGetSettingSpec(runtime)
if err != nil {
t.Fatalf("read spec: %v", err)
}
if spec.Token != tt.wantTok {
t.Fatalf("Token = %q, want %q", spec.Token, tt.wantTok)
}
if spec.Type != tt.wantType {
t.Fatalf("Type = %q, want %q", spec.Type, tt.wantType)
}
})
}
}
func TestDrivePermissionGetSettingSpecValidationErrorsAreTyped(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
wantParam string
wantMessage string
}{
{
name: "missing token",
wantParam: "--token",
wantMessage: "--token is required",
},
{
name: "bare token without type",
token: "doxTok",
wantParam: "--type",
wantMessage: "--type is required",
},
{
name: "unsupported URL",
token: "https://example.feishu.cn/calendar/calTok",
wantParam: "--token",
wantMessage: "unsupported --token URL",
},
{
name: "URL type conflict",
token: "https://example.feishu.cn/docx/doxTok",
docType: "sheet",
wantParam: "--type",
wantMessage: "conflicts with URL path type",
},
{
name: "invalid bare token",
token: "../bad",
docType: "folder",
wantParam: "--token",
wantMessage: "--token",
},
{
name: "invalid type",
token: "doxTok",
docType: "comment",
wantParam: "--type",
wantMessage: "invalid --type",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
_, err := readDrivePermissionGetSettingSpec(runtime)
if err == nil {
t.Fatal("expected validation error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
if validationErr, ok := err.(*errs.ValidationError); ok {
if validationErr.Param != tt.wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
}
} else {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
}
})
}
}
func TestDrivePermissionGetSettingDryRunIncludesGETRequest(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
wantURL string
wantType string
}{
{
name: "folder URL",
token: "https://example.feishu.cn/drive/folder/fldTok",
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
wantType: "folder",
},
{
name: "bare folder token",
token: "fldTok",
docType: "folder",
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
wantType: "folder",
},
{
name: "docx URL",
token: "https://example.feishu.cn/docx/doxTok",
wantURL: "/open-apis/drive/v2/permissions/doxTok/public",
wantType: "docx",
},
{
name: "bare wiki token",
token: "wikTok",
docType: "wiki",
wantURL: "/open-apis/drive/v2/permissions/wikTok/public",
wantType: "wiki",
},
{
name: "file URL",
token: "https://example.feishu.cn/file/boxTok",
wantURL: "/open-apis/drive/v2/permissions/boxTok/public",
wantType: "file",
},
{
name: "minutes URL",
token: "https://example.feishu.cn/minutes/obTok",
wantURL: "/open-apis/drive/v2/permissions/obTok/public",
wantType: "minutes",
},
{
name: "mindnotes URL",
token: "https://example.feishu.cn/mindnotes/mndTok",
wantURL: "/open-apis/drive/v2/permissions/mndTok/public",
wantType: "mindnote",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
dry := DrivePermissionGetSetting.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
out := string(data)
for _, want := range []string{
`"` + tt.wantURL + `"`,
`"GET"`,
`"type":"` + tt.wantType + `"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, `"folder_token"`) {
t.Fatalf("dry-run output contains folder_token, want omitted:\n%s", out)
}
})
}
}
func TestDrivePermissionGetSettingExecutePreservesPermissionPublic(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"permission_public": map[string]interface{}{
"link_share_entity": "closed",
"external_access_entity": "closed",
"security_entity": "anyone_can_view",
"comment_entity": "anyone_can_view",
"share_entity": "anyone",
"manage_collaborator_entity": "collaborator_can_view",
"lock_switch": false,
"server_future_field": "preserved",
},
},
},
})
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
"+permission-get-setting",
"--token", "doxTok",
"--type", "docx",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
for _, key := range []string{"type", "token", "url"} {
if _, ok := data[key]; ok {
t.Fatalf("data[%s] = %#v, want field omitted", key, data[key])
}
}
permissionPublic, _ := data["permission_public"].(map[string]interface{})
if permissionPublic == nil {
t.Fatalf("permission_public missing in output: %#v", data)
}
for key, want := range map[string]interface{}{
"link_share_entity": "closed",
"external_access_entity": "closed",
"security_entity": "anyone_can_view",
"comment_entity": "anyone_can_view",
"share_entity": "anyone",
"manage_collaborator_entity": "collaborator_can_view",
"lock_switch": false,
"server_future_field": "preserved",
} {
if permissionPublic[key] != want {
t.Fatalf("permission_public[%s] = %#v, want %#v", key, permissionPublic[key], want)
}
}
}
func TestDrivePermissionGetSettingExecuteRejectsMissingPermissionPublic(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{"unexpected": "response"},
},
})
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
"+permission-get-setting",
"--token", "doxTok",
"--type", "docx",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected invalid response error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("problem = %#v, want internal/invalid_response", problem)
}
if stdout.Len() != 0 {
t.Fatalf("stdout should be empty on invalid response, got %s", stdout.String())
}
}
func TestDrivePermissionGetSettingExecutePrettyFormatIncludesPermissionPublic(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"permission_public": map[string]interface{}{
"link_share_entity": "closed",
"server_future_field": "preserved",
},
},
},
})
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
"+permission-get-setting",
"--token", "doxTok",
"--type", "docx",
"--format", "pretty",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, want := range []string{
"Permission settings:",
`"link_share_entity": "closed"`,
`"server_future_field": "preserved"`,
} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("pretty output missing %q:\n%s", want, stdout.String())
}
}
}
func TestDrivePermissionGetSettingDeclaresScopeAndIdentities(t *testing.T) {
t.Parallel()
if !reflect.DeepEqual(DrivePermissionGetSetting.Scopes, []string{"docs:permission.setting:read"}) {
t.Fatalf("Scopes = %v, want docs:permission.setting:read", DrivePermissionGetSetting.Scopes)
}
if !reflect.DeepEqual(DrivePermissionGetSetting.AuthTypes, []string{"user", "bot"}) {
t.Fatalf("AuthTypes = %v, want [user bot]", DrivePermissionGetSetting.AuthTypes)
}
for _, flag := range DrivePermissionGetSetting.Flags {
if flag.Name == "token" && !flag.Required {
t.Fatal("--token must be declared required")
}
}
}

View File

@@ -32,6 +32,8 @@ func Shortcuts() []common.Shortcut {
DriveTaskResult,
DriveApplyPermission,
DriveMemberAdd,
DriveMemberList,
DrivePermissionGetSetting,
DriveSecureLabelList,
DriveSecureLabelUpdate,
DriveSearch,

View File

@@ -39,6 +39,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+task_result",
"+apply-permission",
"+member-add",
"+member-list",
"+permission-get-setting",
"+secure-label-list",
"+secure-label-update",
"+search",

View File

@@ -1,7 +1,7 @@
---
name: lark-drive
version: 1.0.0
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本、飞书文档密级标签secure labels和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责文档内容编辑走 lark-doc、表格/Base 表内数据操作(走 lark-sheets/lark-base、知识空间节点/成员管理(走 lark-wiki、原生 Markdown 文件读写/patch/diff走 lark-markdown。"
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、查询权限设置、评论/权限/订阅、标题、版本、飞书文档密级标签secure labels和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责文档内容编辑走 lark-doc、表格/Base 表内数据操作(走 lark-sheets/lark-base、知识空间节点/成员管理(走 lark-wiki、原生 Markdown 文件读写/patch/diff走 lark-markdown。"
metadata:
requires:
bins: ["lark-cli"]
@@ -27,6 +27,7 @@ metadata:
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要为指定飞书文档**设置 / 修改密级标签secure label**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置**,优先使用 `lark-cli drive +permission-get-setting`;它只读取目标自身设置,不递归审计文件夹子文档权限。裸 token 必须显式传 `--type`
- 用户要**按特定主题、关键词或内容线索跨容器查找资料,并统一收集到 Drive 文件夹或 Wiki 节点**,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 负责搜索召回、内容验证、相关性分类、移动计划、写前确认和结果验证;禁止直接从 `drive +search``drive +move` 开始。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案创建目录、移动资源、申请权限都必须单独确认。
- 按主题跨范围查找并集中归档,进入 `topic_move_collector`;对已知文件夹、文档库或知识库做目录盘点和结构重组,进入 `knowledge_organize`;只移动一个已明确资源时仍使用原子移动命令。
@@ -120,6 +121,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
### 权限能力入口
- 用户要管理 Drive 文档/文件协作者、公开权限、授权当前应用访问文档,或处理 `permission.public.patch``91009` / `91010` / `91011` / `91012` 错误时,先读 [`lark-drive-permission-guide.md`](references/lark-drive-permission-guide.md)。
- 用户要查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置,使用 [`+permission-get-setting`](references/lark-drive-permission-get-setting.md);如果要递归审计文件夹下子文档权限,再进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户只是没有访问权限并希望向 owner 申请访问,优先使用 [`+apply-permission`](references/lark-drive-apply-permission.md)。
- 普通 scope、身份或登录问题仍按 [`lark-shared`](../lark-shared/SKILL.md) 处理;不要把租户安全策略、对外分享、密级拦截简单归类为缺 scope。
@@ -163,6 +165,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical tokenwiki URL 会自动解包到底层文档。 |
| [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 |
| [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create真实写入需要 `--yes`。 |
| [`+member-list`](references/lark-drive-member-list.md) | 查询 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。 |
| [`+permission-get-setting`](references/lark-drive-permission-get-setting.md) | 查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置;支持 URL 或裸 token + `--type`;不递归读取文件夹子文档权限。 |
| [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 |
| [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 |

View File

@@ -0,0 +1,65 @@
# drive +member-list查询协作者/授权成员列表)
本 skill 对应 shortcut`lark-cli drive +member-list`。它读取 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。
## 命令
```bash
# URL 自动推断 type
lark-cli drive +member-list \
--token 'https://example.feishu.cn/drive/folder/<folder_token>' \
--as user --format json
# 查询附加字段
lark-cli drive +member-list \
--token '<token>' \
--type docx \
--fields 'name,type,external_label' \
--as user --format json
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--token` | 是 | 裸 token 或完整 URL。URL 路径支持 `/folder/``/docx/``/doc/``/sheets/``/base/``/bitable/``/wiki/``/file/``/mindnotes/``/slides/``/minutes/`。 |
| `--type` | 裸 token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`CLI 会拒绝。 |
| `--fields` | 否 | 默认不传。可取 `name` / `type` / `avatar` / `external_label`,支持逗号分隔;也可传 `*` 请求当前支持的所有附加字段。该参数只声明期望返回的字段,不授予字段级权限。 |
| `--perm-type` | 否 | 仅 `--type wiki` 有效;取值 `container` / `single_page`。 |
| `--dry-run` | 否 | 只打印请求,不调用 API。 |
## 输出
JSON 输出原样透传 API 的 `data`
```json
{
"ok": true,
"identity": "user",
"data": {
"items": [
{
"member_type": "openid",
"member_id": "ou_xxx",
"perm": "view",
"perm_type": "container",
"type": "user",
"name": "zhangsan",
"external_label": false
}
]
}
}
```
`--format pretty` 会轻量展示成员 ID、成员类型、权限、wiki `perm_type` 和已返回的附加字段。机器读取优先使用 `--format json`
## 行为说明
- **身份支持**`--as user``--as bot` 均可用;缺 scope 或目标权限时按统一 permission 错误路径处理。
- **接口 scope**:查询成员列表需要 `docs:permission.member:retrieve`
- **fields 默认**:不传 `--fields` 时按官方 API 默认,不请求姓名、头像、外部标签等附加字段;需要时显式指定。
- **字段级权限**`--fields` 只控制请求哪些附加字段,不保证服务端一定返回。请求用户的 `name` / `avatar` 时,应用还需开通 `contact:user.base:readonly`(“获取用户基本信息”;已具备官方兼容的历史通讯录权限也可满足要求)。
- **缺字段语义**:字段级权限或数据可见性不足时,接口仍可能成功,但会省略相应敏感字段。响应中缺少已请求字段表示“服务端未返回”,不能解释为字段值为空,也不能据此认定成员信息完整。
- **folder 支持**CLI 支持 `--type folder` 并会按需求发送 `type=folder`;部分环境的后端如果尚未放开 folder 枚举,可能返回 `99992402 field validation failed`

View File

@@ -0,0 +1,48 @@
# drive +permission-get-setting查询权限设置
本 skill 对应 shortcut`lark-cli drive +permission-get-setting`。它读取单个 Drive 资源自身的公开访问、分享、协作者管理、安全与评论权限设置,不递归读取文件夹中的子资源。
## 命令
```bash
# 通过 URL 自动推断 type
lark-cli drive +permission-get-setting \
--token 'https://example.feishu.cn/drive/folder/<folder_token>' \
--as user --format json
# 通过 bare token 显式指定 type
lark-cli drive +permission-get-setting \
--token '<folder_token>' \
--type folder \
--as user --format json
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--token` | 是 | bare token 或完整 URL。URL 路径支持 `/folder/``/docx/``/doc/``/sheets/``/base/``/bitable/``/wiki/``/file/``/mindnotes/``/slides/``/minutes/`。 |
| `--type` | bare token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`CLI 会拒绝。 |
| `--dry-run` | 否 | 只打印请求,不调用 API。 |
## 输出
JSON 输出中的 `data.permission_public` 是目标当前的权限设置;服务端未返回该字段时,命令会报响应结构错误,而不会把其他字段伪装成权限设置。
```json
{
"ok": true,
"identity": "user",
"data": {
"permission_public": {}
}
}
```
`--format pretty` 会展示完整的 `permission_public` 对象,包括服务端将来新增的字段。
## 行为说明
- **身份支持**`--as user``--as bot` 均可用。
- **所需 scope**`docs:permission.setting:read`
- **单目标读取**:命令只读取 `--token` 指向资源自身的权限设置;`--type folder` 不会递归读取子资源。

View File

@@ -28,7 +28,7 @@ lark-cli drive +secure-label-list --page-size 10 --lang zh
```bash
lark-cli drive +secure-label-update \
--token "https://example.feishu.cn/docx/doxcnxxxx" \
--label-id "7217780879644737539"
--label-id '<label-id>' # replace $LABEL_ID before running
```
参数:

View File

@@ -15,6 +15,8 @@
lark-cli drive +inspect --url '<url>' --as user --format json
```
`drive +inspect` 支持 Drive folder并且是受支持 Drive URL 的统一解析入口。对文件夹自身权限设置,先通过 `+inspect` 解析 URL或直接使用 `drive +permission-get-setting --token '<folder_url>'`;传 bare folder token 时必须显式传 `--type folder`
`/wiki/space/<space_id>` URL 是 Wiki space 范围,不要用 `drive +inspect` 当作单文档解析;直接提取 `space_id` 后进入 `DISCOVER_TARGETS`
## 目标发现
@@ -25,16 +27,16 @@ lark-cli drive +inspect --url '<url>' --as user --format json
lark-cli wiki +node-list \
--space-id '<space_id>' --page-size 50 \
--page-all --page-limit 0 \
--as user --format json
--as user --format json # replace $SPACE_ID before running
lark-cli wiki +node-list \
--space-id '<space_id>' --parent-node-token '<node_token>' --page-size 50 \
--page-all --page-limit 0 \
--as user --format json
--as user --format json # replace $SPACE_ID before running
lark-cli wiki +node-list \
--space-id '<space_id>' --page-token '<PAGE_TOKEN>' --page-size 50 \
--as user --format json
--as user --format json # replace $SPACE_ID before running
```
解析返回时使用 `data.nodes`,不要读取顶层 `items``--page-limit 0` 表示当前层分页不设页数上限;`--page-all` 只覆盖当前 `space-id` / `parent-node-token` 范围内的分页,不会递归子节点。节点 `has_child=true` 时,必须继续以该节点的 `node_token` 作为 `--parent-node-token` 递归读取。
@@ -61,14 +63,42 @@ lark-cli drive metas batch_query \
--as user --format json
```
读取 public permission
读取权限设置
```bash
lark-cli drive permission.public get \
--params '{"token":"<token>","type":"<type>"}' \
lark-cli drive +permission-get-setting \
--token '<url-or-token>' --type '<type>' \
--as user --format json
```
裸 folder token 必须显式传 `--type folder`
```bash
lark-cli drive +permission-get-setting \
--token '<folder_token>' --type folder \
--as user --format json
```
通过 URL 读取权限设置时可以省略 `--type`
```bash
lark-cli drive +permission-get-setting \
--token '<url>' \
--as user --format json # replace $LARK_DRIVE_URL before running
```
按需读取直接协作者/授权成员列表:
```bash
lark-cli drive +member-list \
--token '<token_or_url>' \
--type '<type>' \
--fields 'name,type,external_label' \
--as user --format json
```
`--fields` 默认不传;只有需要名称、协作者类型、头像或外部标签时才显式传。它只声明期望返回的字段,不授予字段级权限:请求用户的 `name` / `avatar` 时还需 `contact:user.base:readonly`(“获取用户基本信息”)。字段权限或数据可见性不足时,接口仍可能成功但省略相应字段;缺字段不能解释为空值。
按需读取访问统计:
```bash
@@ -160,9 +190,9 @@ lark-cli drive +secure-label-list \
```bash
lark-cli drive +secure-label-update \
--token '<url>' \
--label-id '<label-id>' --as user --format json
--label-id '<label-id>' --as user --format json # replace $LABEL_ID before running
lark-cli drive +secure-label-update \
--token '<bare-token>' --type '<type>' \
--label-id '<label-id>' --as user --format json
--label-id '<label-id>' --as user --format json # replace $LABEL_ID before running
```

View File

@@ -27,7 +27,7 @@
- 多目标明确列表默认输出逐目标诊断摘要;不要因为目标数大于 1 就套用容器递归发现报告。
- 用户可见结论默认跟随用户当前语言。用户用中文提问时输出中文,用户用英文提问时输出英文;混合语言时跟随主要语言。
- 单目标公开性判断默认输出业务表达,不直接展示 `link_share_entity``external_access_entity``external_access` 等底层字段名;只有用户要求 raw evidence、排障或完整清单 / artifact 场景才展示底层字段。
- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“文档公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。它在官方语义中包含链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论;具体可判断字段以当前 CLI schema 和实际响应为准。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。
- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“目标公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。优先按实际返回字段解释公开访问、分享、协作者管理、安全与评论设置;复制内容、创建副本、打印、下载等字段只有在当前 CLI schema 和实际响应返回时才可判断。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。
- 容器目标默认输出安全诊断报告摘要:一句话结论、覆盖情况、风险分级、优先处理对象、建议下一步和剩余限制。
- 容器目标不要把风险按数量机械排序;外部公开、允许对外分享、缺失密级标签优先于复制 / 下载 / 评论这类依赖策略的候选项。
- 用户没有提供明确 policy 时,使用“候选风险 / 待复核 / 待策略确认”,不要写“违规 / 已泄露 / 已外部访问”。
@@ -36,7 +36,7 @@
- 当摘要未展示全部风险对象时,必须明确“完整清单包含 <count> 条”,并提供生成 Markdown / CSV / 飞书文档风险清单或整改 dry-run 的下一步。
- 只要发现需要处理的对象,最终回复必须给出可执行下一步 CTA。不能因为默认只读就只报告风险后结束。
- 完整风险清单是后续治理选择的输入Markdown / CSV / 飞书文档报告必须使用同一套字段和稳定 `risk_id`
- 写入前必须使用确认模板;权限申请、文档公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。
- 写入前必须使用确认模板;权限申请、目标公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。
- 最终回复必须包含已完成事项、验证结果和剩余限制;异步权限申请审批不能表述为已完成授权。
## Semantic Rendering
@@ -75,7 +75,7 @@
| `lock_switch=true` | `lock_state=locked_not_inheriting` | 已限制权限,不再继承父级页面权限 | The node is locked and no longer inherits parent-page permissions |
| `lock_switch=false` | `lock_state=not_locked_or_inheriting` | 未限制权限,可能继承父级页面权限 | The node is not locked and may inherit parent-page permissions |
| field absent / unsupported | `<state>=unknown` | 当前 schema 未返回,无法判断 | The current schema did not return this field, so it is unknown |
| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前文档公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers current public access and collaboration settings, not collaborator-list or historical permission-change auditing |
| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前目标公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers the target's current public access and collaboration settings, not collaborator-list or historical permission-change auditing |
| `sec_label_name` missing | `sec_label=missing` | 缺少密级标签 | Security label is missing |
## 定位与治理动作
@@ -165,7 +165,7 @@ Evidence fields:
覆盖情况:
- 用户提供目标:<input_target_count>;成功解析:<resolved_count>
- 成功读取文档公共访问和协作权限设置:<permission_checked_count>;读取失败 / 不支持 / 无权限:<failed_or_unsupported_count>
- 成功读取目标公共访问和协作权限设置:<permission_checked_count>;读取失败 / 不支持 / 无权限:<failed_or_unsupported_count>
逐目标结果1-10 个目标默认全部展示;超过 10 个时按 `摘要清单展开规则` 展示,并提示生成完整风险清单):
@@ -233,7 +233,7 @@ URL<url-or-token-if-url-unavailable>
覆盖情况:
- 当前身份可见目标:<visible_count>
- 已成功检查文档公共访问和协作权限设置:<permission_checked_count>
- 已成功检查目标公共访问和协作权限设置:<permission_checked_count>
- 读取失败 / 已删除 / 无权限:<failed_count>
- 未覆盖能力:<collaborator_list / inheritance / audit_log / view_records / none>
@@ -355,8 +355,8 @@ Agent 必须回复:
- 字段变更:
- <risk_id> <path> (<url-or-token>): <field> <old> -> <new>
- 跳过项:<unsupported / no manage_public / unsupported type / missing policy>
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
- 有限回滚范围:<文档公共访问和协作权限设置快照字段 / 不适用>
- 验证方式:执行后重新读取 <元数据 / 目标公共访问和协作权限设置>
- 有限回滚范围:<目标公共访问和协作权限设置快照字段 / 不适用>
请确认是否进入写入确认。
```
@@ -407,8 +407,8 @@ Agent 必须回复:
- 风险:<risk_level>
- 字段变更:
- <field>: <old> -> <new>
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
- 有限回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
- 验证方式:执行后重新读取 <元数据 / 目标公共访问和协作权限设置>
- 有限回滚材料:<目标公共访问和协作权限设置快照 / 不适用>
请确认是否执行。
```
@@ -419,6 +419,6 @@ Agent 必须回复:
已完成:<read checks / writes>
验证:<fresh read result or async permission-request approval note>
清单状态:<risk_id status updates / not applicable>
回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
回滚材料:<目标公共访问和协作权限设置快照 / 不适用>
剩余限制:<unsupported_checks / partial facts / approvals>
```

View File

@@ -38,11 +38,11 @@ Risk / Structure: `R2` / `S2`
- 目录组织、迁移、归档或清理;这类需求应使用知识整理 workflow。
- 内容审查、过期内容判断或知识质量评分。
- backup owner 补充、部门 / 项目负责人绑定、协作者创建 / 撤销、成员列表审计;本 workflow 只支持把 owner 转移给每个目标明确指定的新 owner不建模 backup owner 或负责人绑定关系。
- 文件夹自身公开权限审计或修复。`drive permission.public get` / `patch` 不支持 `type=folder`;必须记录到 `unsupported_checks`,然后继续读取文件夹下其他支持的文档事实
- 文件夹自身公开权限审计或修复。文件夹自身权限设置可以用 `drive +permission-get-setting` 读取;写入是否支持必须以运行时 schema 和明确需求为准,不能猜测执行 `patch type=folder`
- 当前身份无法枚举到的不可见文档的完整发现;只能处理已发现目标,或用户显式提供的 URL / token。
- 未按范围确认的批量写入。
不要声称已完成协作者列表验证:当前 CLI surface 没有 `permission.members list` shortcut
协作者列表读取只覆盖当前目标的直接协作者/授权成员:可使用 `drive +member-list`
## Progressive Load Map
@@ -53,7 +53,7 @@ Risk / Structure: `R2` / `S2`
| `PARSE_INTENT` | 本文件、[`lark-drive-workflow.md`](lark-drive-workflow.md)、[`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) |
| `TARGET_INSPECT` | [`lark-drive-inspect.md`](lark-drive-inspect.md) |
| `DISCOVER_TARGETS` | 容器范围时读取 [`../../lark-wiki/references/lark-wiki-node-list.md`](../../lark-wiki/references/lark-wiki-node-list.md) 或 [`lark-drive-files-list.md`](lark-drive-files-list.md) |
| `FACT_READ` | `lark-cli schema drive.metas.batch_query`;涉及公开权限时再读取 `lark-cli schema drive.permission.public.get`;涉及活跃度、访问复核或生命周期判断时再读取 `lark-cli schema drive.file.statistics.get``lark-cli schema drive.file.view_records.list` |
| `FACT_READ` | `lark-cli schema drive.metas.batch_query`;涉及权限设置读取时使用 `drive +permission-get-setting`;涉及活跃度、访问复核或生命周期判断时再读取 `lark-cli schema drive.file.statistics.get``lark-cli schema drive.file.view_records.list` |
| `RISK_ASSESS` | 本文件的 `Risk Classification` |
| `EXEC_CONFIRM` | 只为用户选择的动作读取 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)、[`lark-drive-secure-label.md`](lark-drive-secure-label.md),或 `lark-cli schema drive.permission.public.patch` / `lark-cli schema drive.permission.members.transfer_owner`;需要确认模板时读取 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) |
| `EXECUTE` | 复用 `EXEC_CONFIRM` 已加载且已确认的写命令上下文 |
@@ -76,9 +76,9 @@ Risk / Structure: `R2` / `S2`
| State | Protocol Step | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|-------|---------------|---------------|--------------------|---------------|------------|
| `PARSE_INTENT` | `route` / `scope` | 解析 intent、target scope、desired policy以及只读审计、单目标公开性判断、权限申请、owner 转移还是修复模式;单目标公开性判断设置 `intent=public_exposure_check``target_scope=single_resource` | 范围确认;如果缺少目标、新 owner 或期望动作,只问一个澄清问题 | 缺少 target / new owner / action或容器范围需要用户确认时为 `true` | `TARGET_INSPECT` |
| `TARGET_INSPECT` | `scope` | 解析单资源、明确列表、Wiki space / node、Drive folder保留原始 URL、scope type、canonical token/type | 目标范围表,包含 scope、title/type/token status | 除非解析失败,否则为 `false` | `DISCOVER_TARGETS` or `FACT_READ` |
| `TARGET_INSPECT` | `scope` | 解析单资源、明确列表、Wiki space / node、Drive folderDrive folder 直接从 URL 路径或显式 `type=folder` 解析,不调用 `drive +inspect`保留原始 URL、scope type、canonical token/type | 目标范围表,包含 scope、title/type/token status | 除非解析失败,否则为 `false` | `DISCOVER_TARGETS` or `FACT_READ` |
| `DISCOVER_TARGETS` | `scope` / `read` | 对 Wiki space / node 或 Drive folder 递归只读枚举,归一化为 `discovered_targets`;记录 `discovery_blockers` | 发现进度和覆盖摘要;不展示内部 cursor/token除非用户要求 | 除非发现范围无法确认或全部被阻断,否则为 `false` | `FACT_READ` |
| `FACT_READ` | `read` | 对直接目标或 `discovered_targets` 执行 `drive metas batch_query`;对支持的非 folder 目标执行 `drive permission.public get`;当 `intent=public_exposure_check``target_scope=single_resource` 时,可复用 `drive +inspect` 返回的 title / URL / type只补读文档公共访问和协作权限设置;在用户要求活跃度 / 访问复核 / 生命周期判断时读取访问统计和访问记录 | 权限事实摘要、coverage summary、activity facts 和 unsupported checks | 除非所有目标都被 auth 阻断,否则为 `false` | `RISK_ASSESS` |
| `FACT_READ` | `read` | 对直接目标或 `discovered_targets` 执行 `drive metas batch_query`;对支持的文件、文件夹或云文档目标执行 `drive +permission-get-setting` 读取自身权限设置;当 `intent=public_exposure_check``target_scope=single_resource` 时,可复用 `drive +inspect` 返回的 title / URL / type只补读目标公共访问和协作权限设置;在用户要求活跃度 / 访问复核 / 生命周期判断时读取访问统计和访问记录 | 权限事实摘要、coverage summary、activity facts 和 unsupported checks | 除非所有目标都被 auth 阻断,否则为 `false` | `RISK_ASSESS` |
| `RISK_ASSESS` | `assess/plan` | 对每个可审计目标生成 `per_target_permission_assessment` 并分类证据;如用户提供 policy则对照 policy`public_exposure_check + single_resource` 只渲染单目标结论,不生成 `risk_id`owner 转移路径生成 `owner_transfer_candidates` / `owner_transfer_plan`治理路径构建可定位风险清单、访问复核清单、dry-run 整改计划或候选修复计划,完整清单必须生成稳定 `risk_id` | 带 priority、URL、risk_id、owner、sec_label 的 findings、confidence、review items、建议动作和下一步 CTA单目标公开性判断只输出结论和关键字段 | 治理路径为 `true`,单目标公开性判断为 `false` | `EXEC_CONFIRM` or `DONE` |
| `EXEC_CONFIRM` | `confirm` | 展示准确写入范围、command family、target count、risk、verification method | 确认请求 | `true` | `EXECUTE` or `DONE` |
| `EXECUTE` | `execute` | 只执行 `Command Map` 中已确认的写入 | 进度 / 结果摘要 | 除非被阻断,否则为 `false` | `VERIFY` |
@@ -91,21 +91,23 @@ Risk / Structure: `R2` / `S2`
| State | Allowed Command Families | Purpose |
|-------|--------------------------|---------|
| `TARGET_INSPECT` | `drive +inspect` | 解析 URL、type、canonical token、title 和 wiki unwrap data |
| `TARGET_INSPECT` | `drive +inspect` | 解析非 folder URL、type、canonical token、title 和 wiki unwrap dataDrive folder 不支持 `+inspect`,必须从 URL 路径或显式 `type=folder` 直接解析 |
| `DISCOVER_TARGETS` | `wiki +node-list` | 递归发现 Wiki space / node 下当前身份可见的节点 |
| `DISCOVER_TARGETS` | `drive files list` | 递归发现 Drive folder 下当前身份可见的文件和子文件夹 |
| `FACT_READ` | `drive metas batch_query` | 读取 title、URL、owner 和 secure-label metadata |
| `FACT_READ` | `drive permission.public get` | 读取支持类型的文档公共访问和协作权限设置,包括链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论 |
| `FACT_READ` | `drive +member-list` | 读取用户显式要求的单目标直接协作者/授权成员列表;不代表完整继承链或历史权限审计 |
| `FACT_READ` | `drive +permission-get-setting` | 读取支持类型的文件、文件夹或云文档自身权限设置,包括公开访问、分享、协作者管理、安全与评论 |
| `FACT_READ` | `drive file.statistics get` | 在用户要求活跃度、闲置暴露、生命周期或访问复核时读取文件访问统计 |
| `FACT_READ` | `drive file.view_records list` | 在用户要求最近访问人、访问复核或低活跃证据时读取访问记录 |
| `EXEC_CONFIRM` | `drive +secure-label-list` | 提议 label update 前解析可用 secure-label IDs |
| `EXEC_CONFIRM` | `drive permission.members auth` | 文档公共访问和协作权限设置修改前检查 `action=manage_public` |
| `EXEC_CONFIRM` | `drive permission.members auth` | 目标公共访问和协作权限设置修改前检查 `action=manage_public` |
| `EXEC_CONFIRM` | `lark-cli schema drive.permission.members.transfer_owner` | owner 转移前读取当前字段、支持类型和高风险写入门禁 |
| `EXECUTE` | `drive +apply-permission` | 向 owner 提交 view/edit access request只允许单目标、小列表或已明确确认的候选列表逐个执行 |
| `EXECUTE` | `drive permission.public patch` | 修改已确认的 public/link settings必须传 `--yes` |
| `EXECUTE` | `drive permission.members transfer_owner` | 转移已确认目标的 owner必须传 `--yes` |
| `EXECUTE` | `drive +secure-label-update` | 设置已确认的 secure-label ID |
| `VERIFY` | `drive metas batch_query`, `drive permission.public get` | 验证支持的 metadata包括 owner、secure-label 和文档公共访问与协作权限设置变更;权限申请只能表述为已发起 |
| `VERIFY` | `drive metas batch_query`, `drive +permission-get-setting` | 验证支持的 metadata包括 owner、secure-label 和目标公共访问与协作权限设置变更;权限申请只能表述为已发起 |
## Command Patterns
@@ -119,9 +121,9 @@ Risk / Structure: `R2` / `S2`
1. "所有文档"只表示当前身份在确认范围内可枚举到的文档。不可见、无权限、API 不返回或工具预算不足的部分必须进入 `discovery_blockers``unsupported_checks`
2. 发现阶段必须生成稳定 `path`。不要只保存 title同名文档必须能通过 path 或 token 区分。
3. 只把 `drive.permission.public.get` 当前 schema 支持的类型加入公开权限可审计目标。已知支持包括 `doc``sheet``file``wiki``bitable``docx``mindnote``minutes``slides`;未来新增类型以运行时 schema 为准。
3. 权限设置读取使用 `drive +permission-get-setting`,目标类型包括 `doc``sheet``file``wiki``bitable``docx``mindnote``minutes``slides``folder`;未来新增类型以 shortcut 和 OpenAPI 元数据为准。
4. `minutes` 只能作为 `partial_public_permission` 目标:可读取 / 修改公开权限和 owner 转移能力以运行时 schema 为准,但 `drive metas batch_query` 当前不支持 `minutes`URL、owner、密级等 metadata 可能进入 `unsupported_checks`
5. `folder` 作为递归容器,不执行 `permission.public get` / `patch`。如果用户明确要求 owner 转移且 schema 支持 `folder`,必须按 owner-transfer 写入规则单独确认`shortcut``catalog` 或缺少 stable token/type 的条目必须记录为 unsupported除非后续 API 明确解析出支持目标。
5. `folder` 作为递归容器时先枚举子资源;如用户明确要查询文件夹自身权限设置,可对该文件夹单独执行 `drive +permission-get-setting --token <folder_token> --type folder`。不要执行 raw `permission.public patch type=folder`,除非 schema 和需求都明确支持`shortcut``catalog` 或缺少 stable token/type 的条目必须记录为 unsupported除非后续 API 明确解析出支持目标。
6. 对大范围目标输出进度时,只展示已扫描容器数、已发现目标数、已审计目标数、剩余队列或 blocker不要默认展示内部 page token / cursor。
Wiki space / node 发现:
@@ -133,7 +135,7 @@ Wiki space / node 发现:
Drive folder 发现:
1. `/drive/folder/<folder_token>` 解析为 `target_scope=drive_folder`文件夹自身公开权限不支持;继续枚举其子文档
1. `/drive/folder/<folder_token>` 解析为 `target_scope=drive_folder`默认继续枚举其子文档;只有用户明确要求文件夹自身权限设置时,才额外调用 `drive +permission-get-setting --token <folder_token> --type folder` 读取该文件夹自身设置
2. 按 [`lark-drive-files-list.md`](lark-drive-files-list.md) 递归处理 `data.files``has_more``next_page_token`。不要把第一页数量当作完整范围。
3. 只对返回项中的 `folder` 继续递归;对子文档按 `type + token` 归一化为 `discovered_targets`
4. 如果某个目录分页失败、无 continuation token、权限不足或 API 报错,只阻断该目录分支,并在 `discovery_blockers` 中记录;继续处理其他可枚举分支。
@@ -141,11 +143,11 @@ Drive folder 发现:
## Fact Read Rules
1. `drive metas batch_query` 单次最多 200 个 `request_docs`;当 `targets``discovered_targets` 超过 200 个时,必须分批读取并合并结果。
2. `drive permission.public get` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks``partial`,不要阻断其他目标。
2. `drive +permission-get-setting` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks``partial`,不要阻断其他目标。
3. 对 Wiki 发现目标,公开权限读取优先使用 `type=wiki` + `node_token`metadata 可使用 `obj_type` + `obj_token` 补充 title、owner、URL 和 `sec_label_name`
4. 当 intent 是 `list_permission_settings` 时,只输出权限设置清单和覆盖限制,不主动生成修复计划。
5. 单目标、多目标明确列表和容器发现目标都必须复用同一套逐目标事实读取与语义归一逻辑差异只体现在目标来源、coverage summary 和输出聚合。
6. `permission_public` 用户可见含义是“文档公共访问和协作权限设置”,语义以官方 OpenAPI 字段说明为准,同时兼容当前 CLI schema 返回的字段:优先使用 `external_access_entity`,缺失时才用 `external_access` boolean 映射为 `open` / `closed``manage_collaborator_entity``copy_entity``lock_switch` 等字段缺失时标记为 unknown不要伪造未识别字段保留在 raw evidence / partial note 中。
6. `permission_public` 用户可见含义是“目标公共访问和协作权限设置”,语义以官方 OpenAPI 字段说明为准,同时兼容当前 CLI schema 返回的字段:优先使用 `external_access_entity`,缺失时才用 `external_access` boolean 映射为 `open` / `closed``manage_collaborator_entity``copy_entity``lock_switch` 等字段缺失时标记为 unknown不要伪造未识别字段保留在 raw evidence / partial note 中。
7. `drive file.statistics get``drive file.view_records list` 只在用户要求最近访问、活跃度、闲置暴露、访问复核,或用户提供的 policy 明确依赖活跃度时执行;不要为普通权限审计默认读取访问记录。
8. 访问统计 / 访问记录当前只对 `doc``docx``sheet``bitable``mindnote``wiki``file` 作为支持类型处理。其他类型必须进入 `unsupported_checks`,不能推断活跃度。
9. `view_records` 是访问证据,不是权限列表。没有返回访问记录只能表述为“未获得最近访问证据”或“低活跃候选”,不能表述为“无人有权限”。
@@ -162,17 +164,17 @@ Drive folder 发现:
- `PolicyReview`:复制、创建副本、打印、下载、评论等依赖 policy 的设置;没有明确 policy 时不要称为高风险。
- `Unknown`读取失败、已删除、无权限、API 不支持、协作者名单 / 继承链 / DLP / AI 索引 / 审计日志未覆盖。
每个可审计目标都必须先归一化为 `per_target_permission_assessment`,再按 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) 的 `Semantic Rendering` 渲染。`public_exposure_check` 只是 `target_count=1` 的轻量渲染模式;它和多目标、容器诊断复用同一套语义字段与风险分类。该判断只覆盖当前文档公共访问和协作权限设置,不审计协作者名单、历史权限变更、完整继承链或审计日志。
每个可审计目标都必须先归一化为 `per_target_permission_assessment`,再按 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) 的 `Semantic Rendering` 渲染。`public_exposure_check` 只是 `target_count=1` 的轻量渲染模式;它和多目标、容器诊断复用同一套语义字段与风险分类。该判断只覆盖当前目标公共访问和协作权限设置,不审计协作者名单、历史权限变更、完整继承链或审计日志。
`AI 检索暴露候选风险` 只是基于权限和标签的代理标签。除非另有工具明确返回索引状态,否则不要声称某个文档已经被 Agent、Copilot 或 RAG 索引。
## 写入规则
- 文档公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。
- 目标公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。
- 如果 `manage_public_auth.auth_result=false`,禁止 patch。告诉用户需要具备 manage-public 权限的用户,或由 owner 操作。
- `drive permission.public get` 只用于 `drive +inspect``DISCOVER_TARGETS` 可解析且运行时 schema 支持的目标类型;类型集合不要硬编码,执行时以 `lark-cli schema drive.permission.public.get` 为准
- 权限设置读取使用 `drive +permission-get-setting`;裸 token 必须传 `--type`URL 可以自动推断。写入仍使用 `drive permission.public patch`,只 patch 已解析且 schema 明确支持的类型和字段,不要把读取支持的 `folder` 自动外推为可写入
- 不要 patch 已解析类型不支持的字段。对于 wiki 目标,必须省略 schema 明确标注为 wiki 不支持的字段。
- 不要在同一个写入确认中合并密级标签更新和文档公共访问与协作权限设置修改;必须分别确认。
- 不要在同一个写入确认中合并密级标签更新和目标公共访问与协作权限设置修改;必须分别确认。
- `drive +apply-permission` 默认不批量执行;每次调用都会向 owner 发送通知。
- `permission_request_candidates` 可以来自用户直接提供的目标、明确列表或容器发现目标;只要能构造 token、type、权限类型和申请理由就可以进入候选。不要因为目标不在 `discovered_targets` 中而拒绝单目标 / 小列表权限申请。
- 容器范围内的"统一申请权限"必须先产出 `permission_request_candidates`。未展示候选目标、数量、权限类型和 owner 通知影响前,禁止调用 `drive +apply-permission`
@@ -182,8 +184,8 @@ Drive folder 发现:
- 批量 owner 转移必须逐个顺序执行;失败项进入结果清单,不要重复执行已成功目标。`remove_old_owner=true``old_owner_perm` 降权必须单独在确认中高亮。
- 用户要求“生成整改方案 / dry-run / 先看看会改什么”时,只生成 `remediation_plan`不执行任何写命令。dry-run 必须包含 target count、field changes、跳过原因、验证方式和有限回滚范围。
- 用户基于完整风险清单选择对象时,必须先解析 `risk_id`、风险分组、URL 或 artifact 中 `selected=true` 的行,生成 `selected_risk_items`。无法匹配到当前 `risk_manifest` 的选择必须要求用户重新确认或重新读取清单。
- 针对 `selected_risk_items` 生成 dry-run 前,必须重新读取所选目标的 `drive permission.public get`;如果当前设置和清单快照不同,标记为 `changed_since_report` 并跳过或要求用户确认更新后的计划。
- 执行 `drive permission.public patch` 前,必须把当前 `public_permission_facts` 中会被改动的字段保存为 `public_permission_snapshots`。该快照只用于文档公共访问和协作权限设置字段的有限回滚说明不覆盖协作者、owner、继承权限或密级标签。
- 针对 `selected_risk_items` 生成 dry-run 前,必须重新读取所选目标的 `drive +permission-get-setting`;如果当前设置和清单快照不同,标记为 `changed_since_report` 并跳过或要求用户确认更新后的计划。
- 执行 `drive permission.public patch` 前,必须把当前 `public_permission_facts` 中会被改动的字段保存为 `public_permission_snapshots`。该快照只用于目标公共访问和协作权限设置字段的有限回滚说明不覆盖协作者、owner、继承权限或密级标签。
- 如果用户要求批量收紧权限,必须按风险分层和目标顺序逐个执行;失败项进入结果清单,不要因为单个失败而重复执行已成功目标。
- 遇到 secure-label downgrade error `1063013` 时,停止重试,并告诉用户需要在文档 UI 中完成审批。
@@ -194,7 +196,7 @@ Drive folder 发现:
- `drive permission.members create` 可创建协作者权限,但当前 workflow 不做协作者 grant / update / revoke未来需要单独定义授权对象解析、最小权限、确认模板和验证方式。
- backup owner、部门 / 项目负责人绑定没有当前 workflow 可执行写入面;如用户要落地为 owner 转移,必须先给出明确目标和新 owner并走本 workflow 的 owner-transfer 确认。
- `wiki +member-list` 可作为 Wiki space 成员治理的读侧事实来源;当前 workflow 只治理文档 / 节点 / 文件夹下可发现文档的权限,不做 space member governance。
- 当前 CLI 没有 `permission.members list`完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。
- `drive +member-list` 可读取单目标直接协作者/授权成员;当前 CLI 仍没有完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。
## 输出策略

View File

@@ -75,15 +75,17 @@ metadata:
## Quick Reference
**本表只定位「场景 → 用哪条命令、读哪份文档」。参数以「执行前必做」里对应的文档和 `lark-cli slides +<verb> --help` 为准,不要凭记忆或按别的命令类比补参数。**
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|----------|----------|-----------------|
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| 用户要求使用模板 | 将模板导入为 Slides 再编辑 | `lark-slides-pptx-template-workflows.md` |
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``lark-slides-create.md``slides +create` |
| 用户要求使用模板,或提供 PPTX 文件要求修改、美化 | 将模板导入为 Slides 再编辑 | `lark-slides-pptx-template-workflows.md` |
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide``lark-slides-replace-slide.md` |
| 读取或分析已有 PPT | 解析 slides/wiki token用 shortcut 回读全文 XML 或读取单页 XML保存 `xml_presentation_id``slide_id``revision_id` | `slides +xml-get``xml_presentation.slide.get``lark-slides-xml-presentations-get.md` |
| 查看或回滚历史版本 | 先用 `+history-list``history_version_id`,再 `+history-revert`,必要时 `+history-revert-status` 轮询 | [`lark-slides-history.md`](references/lark-slides-history.md) |
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面,一次不超过 10 页 | `slides +screenshot``lark-slides-screenshot.md` |
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload``lark-slides-media-upload.md`,或 `+create --slides``@./path` 占位符 |
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload``lark-slides-media-upload.md`,或 `+create --slides`XML 里写 `<img src="@./path">` 占位符 |
| 绘制图表 | 原生图表(柱状、条形、折线、面积、饼(环)、雷达、组合图)用 `<chart>`,其他(漏斗图、金字塔图、象限图、矩阵图等)用 `<shape>` + `<line>` 模拟 | `xml-schema-quick-ref.md``slides_chart_demo.xml` |
| 绘制表格 | 优先用 `rect``text` 模拟,其他用 `<table>` | `xml-schema-quick-ref.md` |
| 使用图标 | 禁止盲猜 iconType必须先检索 IconPark再写 `<icon iconType="...">`,图标必须填充颜色并和背景有足够对比,禁止使用 emoji 图标 | `iconpark_tool.py search → resolve``iconpark.md` |
@@ -141,9 +143,9 @@ lark-cli auth login --domain slides
- [asset-planning.md](references/asset-planning.md)(新建 / 大幅改写)
- [validation-checklist.md](references/validation-checklist.md)(创建 / 大幅改写后)
按需再读
调用相关命令前必须读取相关的文档以了解命令的使用方式
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)、[`lark-slides-xml-presentation-slide-create.md`](references/lark-slides-xml-presentation-slide-create.md)(逐页添加)
- 阅读:[`lark-slides-xml-presentations-get.md`](references/lark-slides-xml-presentations-get.md)
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
- 历史版本:[`lark-slides-history.md`](references/lark-slides-history.md)
@@ -189,20 +191,6 @@ lark-cli auth login --domain slides
- 不要在任何位置使用 emoji 图标。
### 创建方式选择
| 场景 | 推荐方式 |
|------|----------|
| 简单 XML1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
| 复杂 XML多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多 | **两步创建**:先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide create` 逐页添加 |
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
> [!WARNING]
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
> [!IMPORTANT]
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
### 生成流程
```text
@@ -220,17 +208,18 @@ Step 2: 生成大纲 → 写入 slide_plan.json
Step 3: 按 slide_plan.json 生成 XML → 创建
- 逐页消费 plankey_message 定主结论layout_type 定几何visual_focus 定主视觉text_density 定文本量
- 缺少真实素材时必须用 `fallback_if_missing` 生成替代图片,不要留空
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
- 读 lark-slides-create.md 定一步创建还是两步创建,并据此构造 `slides +create`;两步创建再读 lark-slides-xml-presentation-slide-create.md 逐页添加
- 图片按 lark-slides-media-upload.md 处理;复杂 XML、转义和 3350001 排查按 troubleshooting.md 执行
Step 4: 审查 & 交付
- 创建完成后,必须用 `slides +xml-get` 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
- 创建完成后,必须用 `slides +xml-get --presentation <xml_presentation_id>` 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
- 没问题 → 交付:使用 NotifyHuman 工具交付 PPT 链接
```
### jq 命令模板(编辑已有 PPT 时使用)
新建 PPT 推荐用 `+create --slides`以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
```bash
# 追加到末尾
@@ -313,8 +302,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
| [`+xml-get`](references/lark-slides-xml-presentations-get.md) | 读取全文 XML 并保存到本地文件,避免终端输出被截断 |
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选一步添加页面 |
| [`+xml-get`](references/lark-slides-xml-presentations-get.md) | 读取全文 XML,用 `--presentation` 指定演示文稿的 `xml_presentation_id`,用 `--output` 把 XML 存到本地文件(必须是 CWD 内的相对路径,如 `.lark-slides/plan/<deck>/readback.xml` |
| [`+screenshot`](references/lark-slides-screenshot.md) | 把幻灯片页面截图保存为本地图片,用 `--slide-number` 指定页号(从 1 开始,多页重复传入,一次最多 10 页),用 `--output-dir` 指定保存目录(必须是 CWD 内的相对路径,默认 `.lark-slides/screenshots`),失败时降级到 XML 回读等非截图检查 |
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
@@ -331,12 +321,12 @@ lark-cli slides <resource> <method> [flags] # 调用 API
## 核心规则
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
2. **创建流程**简单短 XML1-3 页、结构简单、特殊字符少)可`slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
2. **创建流程**新建演示文稿`slides +create`一步创建还是两步创建按 [`lark-slides-create.md`](references/lark-slides-create.md) 判断
3. **`<slide>` 直接子元素只有 `<style>``<data>``<note>`**:文本和图形必须放在 `<data>`
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
5. **保存关键 ID**:后续操作需要 `xml_presentation_id``slide_id``revision_id`
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide``block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete`
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传 `+create --slides``@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传,或在 `+create --slides`XML 里写 `<img src="@./path">` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。

View File

@@ -3,13 +3,22 @@
创建一个新的飞书幻灯片演示文稿,可选一步添加页面内容。
- 禁止从完整 <presentation> XML 解析/拆分/重序列化生成提交 payload
- 推荐:提交源直接就是单页 <slide> XML+create --slides 只接受已经人工/程序直接生成的 slide 数组,不接受由
presentation 动态拆出来的数组。
提交源必须是直接生成的单页 `<slide>` XML。禁止从完整 `<presentation>` XML 解析拆分重序列化出 slide 数组再提交
- 最稳:复杂 deck 默认空 deck + 单页 slide create每次只提交一个 <slide>
本命令只从零创建演示文稿,没有导入本地 PPT 文件的参数。要把已有 PPTX 变成 Slides`drive +import --file <x.pptx> --type slides`,再在导入结果上编辑,流程见 [lark-slides-pptx-template-workflows.md](lark-slides-pptx-template-workflows.md)
- 注意:复杂 XML 不适合直接塞命令行,中文、引号、特殊字符较多时,直接拼接 --slides 容易发生 shell 转义或截断。建议将每页 XML 保存为独立文件,使用 `jq --rawfile` 组装 JSON 数组,避免手动处理 XML 引号和换行。
## 创建方式选择
| 场景 | 推荐方式 |
|------|----------|
| 简单 XML1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
| 复杂 XML多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多 | **两步创建**:先 `slides +create` 创建空白 PPT再用 [`xml_presentation.slide create`](lark-slides-xml-presentation-slide-create.md) 逐页添加 |
| 已有 PPT 继续追加或插入页面 | 使用 [`xml_presentation.slide create`](lark-slides-xml-presentation-slide-create.md),必要时配合 `before_slide_id` |
> [!WARNING]
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
> [!IMPORTANT]
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
## 命令

View File

@@ -60,6 +60,13 @@ GHOST_TEXT_MIN_FONT_SIZE = 96
GHOST_TEXT_MAX_ALPHA = 0.5
GHOST_TEXT_FAINT_MIN_FONT_SIZE = 36
GHOST_TEXT_FAINT_MAX_ALPHA = 0.35
# A <line> crossing text glyphs is a legibility defect (see line_crosses_text_glyphs). We erode the
# glyph box by this margin before testing intersection so a line that only skims a glyph edge or the
# padding-only text frame -- but does not actually cut through the letterforms -- is not flagged.
LINE_TEXT_GRAZE_MIN_PX = 2.0
LINE_TEXT_GRAZE_FONT_RATIO = 0.12
# A line whose effective stroke alpha is below this is not visibly rendered, so it cannot occlude text.
LINE_MIN_VISIBLE_ALPHA = 0.08
# Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a
# visible defect; keep this well under 1px so real overflow is still always caught.
CANVAS_OVERFLOW_TOLERANCE = 0.5
@@ -1500,8 +1507,6 @@ def detect_elements_out_of_canvas(
if element["kind"] in {"table", "chart"}
or (element["kind"] == "shape" and element["type"] in {"rect", "text"})
):
if is_ghost_text(element):
continue
bbox = element_canvas_bbox(element)
overflow = {
"left": max(-bbox["x"], 0),
@@ -1611,6 +1616,93 @@ def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[
return issues
def segment_intersects_rect(
x1: float, y1: float, x2: float, y2: float, rect: dict[str, int | float]
) -> bool:
"""True when segment (x1,y1)-(x2,y2) enters the axis-aligned rect (Liang-Barsky clip)."""
left = rect["x"]
top = rect["y"]
right = rect["x"] + rect["width"]
bottom = rect["y"] + rect["height"]
if right <= left or bottom <= top:
return False
dx = x2 - x1
dy = y2 - y1
if dx == 0 and dy == 0:
return left <= x1 <= right and top <= y1 <= bottom
t_enter, t_exit = 0.0, 1.0
for delta, distance in ((-dx, x1 - left), (dx, right - x1), (-dy, y1 - top), (dy, bottom - y1)):
if delta == 0:
if distance < 0:
return False
continue
t = distance / delta
if delta < 0:
t_enter = max(t_enter, t)
else:
t_exit = min(t_exit, t)
if t_enter > t_exit:
return False
return True
def line_text_graze_margin(text_element: dict[str, Any]) -> float:
font_size = text_element["fontSize"] if isinstance(text_element.get("fontSize"), (int, float)) else 16
return max(font_size * LINE_TEXT_GRAZE_FONT_RATIO, LINE_TEXT_GRAZE_MIN_PX)
def erode_rect(rect: dict[str, int | float], margin: float) -> dict[str, int | float] | None:
width = rect["width"] - 2 * margin
height = rect["height"] - 2 * margin
if width <= 0 or height <= 0:
return None
return {"x": rect["x"] + margin, "y": rect["y"] + margin, "width": width, "height": height}
def line_crosses_text(line: dict[str, Any], text_element: dict[str, Any]) -> bool:
if not is_visually_rendered(line) or line.get("alpha", 1) < LINE_MIN_VISIBLE_ALPHA:
return False
if not is_text_element(text_element) or not has_text_content(text_element):
return False
if is_ghost_text(text_element) or is_decorative_text(text_element):
return False
glyph_bbox = estimate_text_visual_bbox(text_element)
if glyph_bbox is None:
return False
# Erode the glyph box so a line skimming the letter edge or only clipping the padding-only text
# frame is exempt; only a line that actually cuts through the letterforms is a crossing.
target = erode_rect(glyph_bbox, line_text_graze_margin(text_element))
if target is None:
return False
return segment_intersects_rect(
line["startX"], line["startY"], line["endX"], line["endY"], target
)
def detect_line_text_crossings(
slide_xml: str, elements: list[dict[str, Any]]
) -> list[dict[str, Any]]:
lines = extract_line_elements(slide_xml)
if not lines:
return []
text_elements = [element for element in elements if is_text_element(element)]
issues: list[dict[str, Any]] = []
for line in lines:
for text_element in text_elements:
if not line_crosses_text(line, text_element):
continue
issues.append(
{
"level": "error",
"code": "bbox_overlap",
"elements": [line["id"], text_element["id"]],
"message": f'line {line["id"]} crosses text {text_element["id"]}',
"hint": "Move the line off the text glyphs so it no longer cuts through the letterforms.",
}
)
return issues
def lint_slide(
slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
) -> dict[str, Any]:
@@ -1621,6 +1713,7 @@ def lint_slide(
*detect_table_layout_size_mismatches(elements),
*detect_text_may_overflow_shapes(elements),
*detect_image_text_occlusions(elements),
*detect_line_text_crossings(slide_xml, elements),
]
for index, left in enumerate(elements):
@@ -2226,7 +2319,7 @@ def related_object(element: dict[str, Any]) -> dict[str, Any]:
def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
elements: list[dict[str, Any]] = []
for match in re.finditer(r"<line\b([^>]*)>", slide_xml):
for match in re.finditer(r"<line\b([^>]*?)(/?)>", slide_xml):
attrs = match.group(1)
start_x = extract_numeric_attribute(attrs, "startX")
start_y = extract_numeric_attribute(attrs, "startY")
@@ -2235,6 +2328,15 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
if any(value is None for value in (start_x, start_y, end_x, end_y)):
continue
line_alpha = extract_numeric_attribute(attrs, "alpha")
base_alpha = line_alpha if line_alpha is not None else 1
border_alpha = 1
if match.group(2) != "/":
close_index = slide_xml.find("</line>", match.end())
body = slide_xml[match.end() : close_index] if close_index != -1 else ""
border_attrs = extract_tag_attributes(body, "border")
color_alpha = extract_color_alpha(extract_attribute(border_attrs, "color"))
if isinstance(color_alpha, (int, float)):
border_alpha = color_alpha
elements.append(
{
"id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}",
@@ -2244,8 +2346,12 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
"y": min(start_y, end_y),
"width": abs(end_x - start_x),
"height": abs(end_y - start_y),
"startX": start_x,
"startY": start_y,
"endX": end_x,
"endY": end_y,
"rotation": 0,
"alpha": line_alpha if line_alpha is not None else 1,
"alpha": base_alpha * border_alpha,
"order": len(elements),
}
)

View File

@@ -1026,7 +1026,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(result["slides"][0]["infos"], [issues["bg-deco"]])
self.assertIn("background decoration", issues["bg-deco"]["message"])
def test_lint_xml_allows_shape_alpha_ghost_text_out_of_canvas_and_overlap(self) -> None:
def test_lint_xml_reports_shape_alpha_ghost_text_out_of_canvas_but_allows_overlap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
@@ -1042,11 +1042,11 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("shape_out_of_canvas", codes)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertIn("shape_out_of_canvas", codes)
self.assertNotIn("bbox_overlap", codes)
def test_lint_xml_allows_content_color_alpha_ghost_text_out_of_canvas_and_overlap(self) -> None:
def test_lint_xml_reports_content_color_alpha_ghost_text_out_of_canvas_but_allows_overlap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
@@ -1062,11 +1062,11 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("shape_out_of_canvas", codes)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertIn("shape_out_of_canvas", codes)
self.assertNotIn("bbox_overlap", codes)
def test_lint_xml_allows_faint_medium_ghost_text_out_of_canvas_and_overlap(self) -> None:
def test_lint_xml_reports_faint_medium_ghost_text_out_of_canvas_but_allows_overlap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
@@ -1082,8 +1082,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("shape_out_of_canvas", codes)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertIn("shape_out_of_canvas", codes)
self.assertNotIn("bbox_overlap", codes)
def test_lint_xml_allows_ghost_text_image_overlap(self) -> None:
@@ -1126,7 +1126,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
codes = [issue["code"] for issue in result["issues"]]
self.assertNotIn("whiteboard_external_overlap", codes)
def test_lint_xml_allows_faint_ghost_text_without_area_threshold(self) -> None:
def test_lint_xml_reports_faint_ghost_text_out_of_canvas_without_area_threshold(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
@@ -1138,8 +1138,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["slides"][0]["issues"], [])
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
def test_lint_xml_keeps_out_of_canvas_error_for_medium_text_without_faint_alpha(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -1457,6 +1457,187 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_reports_horizontal_line_crossing_headline_glyphs(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="title" type="text" topLeftX="80" topLeftY="200" width="500" height="90">
<content fontSize="60"><p>测试文字 ABC</p></content>
</shape>
<line id="strike" startX="80" startY="245" endX="560" endY="245">
<border color="rgb(255, 0, 0)" width="4"/>
</line>
</data>
</slide>
"""
)
crossing = [
issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"strike", "title"}
]
self.assertEqual(len(crossing), 1)
self.assertEqual(crossing[0]["code"], "bbox_overlap")
def test_lint_xml_reports_vertical_line_crossing_multiline_text(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="col" type="text" topLeftX="700" topLeftY="180" width="240" height="180">
<content fontSize="20"><p>第一行文字内容</p><p>第二行文字内容</p><p>第三行文字内容</p></content>
</shape>
<line id="vbar" startX="740" startY="170" endX="740" endY="360">
<border color="rgb(0, 0, 255)" width="3"/>
</line>
</data>
</slide>
"""
)
crossing = [
issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"vbar", "col"}
]
self.assertEqual(len(crossing), 1)
def test_lint_xml_reports_diagonal_line_crossing_text_block(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="para" type="text" topLeftX="80" topLeftY="400" width="420" height="140">
<content fontSize="18"><p>这是一段测试文字用于验证线条穿过</p></content>
</shape>
<line id="diag" startX="80" startY="410" endX="500" endY="530">
<border color="rgb(255, 0, 0)" width="3"/>
</line>
</data>
</slide>
"""
)
crossing = [
issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"diag", "para"}
]
self.assertEqual(len(crossing), 1)
def test_lint_xml_ignores_diagonal_line_whose_bbox_but_not_segment_crosses_text(self) -> None:
# The diagonal's axis-aligned bounding box overlaps the text, but the segment itself passes
# through empty space in the opposite corner -- a naive bbox test would false-positive here.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="corner-text" type="text" topLeftX="80" topLeftY="80" width="120" height="40">
<content fontSize="18"><p>corner</p></content>
</shape>
<line id="far-diag" startX="700" startY="80" endX="90" endY="500">
<border color="rgb(255, 0, 0)" width="3"/>
</line>
</data>
</slide>
"""
)
crossing = [
issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"far-diag", "corner-text"}
]
self.assertEqual(crossing, [])
def test_lint_xml_ignores_line_touching_text_frame_but_not_glyphs(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="lbl" type="text" topLeftX="80" topLeftY="80" width="300" height="200">
<content fontSize="18" verticalAlign="top"><p>短标签</p></content>
</shape>
<line id="below" startX="80" startY="270" endX="380" endY="270">
<border color="rgb(255, 0, 0)" width="2"/>
</line>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_ignores_invisible_line_crossing_text(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="title" type="text" topLeftX="80" topLeftY="200" width="500" height="90">
<content fontSize="60"><p>测试文字 ABC</p></content>
</shape>
<line id="ghost-line" startX="80" startY="245" endX="560" endY="245">
<border color="rgba(255, 0, 0, 0.03)" width="4"/>
</line>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_ignores_vertical_line_grazing_text_left_edge(self) -> None:
# Verbatim from deck GpGusGCwplQyK8dFN9LczmBXnwQ slide 4: a vertical line sitting on the text
# frame's left edge renders before the first glyph, so it must not be flagged.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape width="240" height="60" topLeftX="120" topLeftY="100" type="text" id="bmm">
<content fontSize="20" fontFamily="Arial" color="rgba(31, 35, 41, 1)" lineSpacing="fixed:24">
<p>Vertical edge graze</p>
</content>
</shape>
<line id="bmX" startX="120.00000000000001" startY="90" endX="120.00000000000001" endY="150.00833275470998">
<border color="rgba(0, 0, 0, 1)"/>
</line>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_ignores_polyline_crossing_text(self) -> None:
# Verbatim from deck GpGusGCwplQyK8dFN9LczmBXnwQ slide 6: the crossing check is scoped to
# <line> only, so a <polyline> over text is not flagged.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape width="240" height="60" topLeftX="120" topLeftY="100" type="text" id="bmr">
<content fontSize="20" fontFamily="Arial" color="rgba(31, 35, 41, 1)" lineSpacing="fixed:24">
<p>Polyline target</p>
</content>
</shape>
<polyline id="bmH" width="270" height="55" topLeftX="110" topLeftY="95">
<border color="rgba(0, 0, 0, 1)"/>
</polyline>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_ignores_line_below_visual_glyph_height(self) -> None:
# Verbatim from deck GpGusGCwplQyK8dFN9LczmBXnwQ slide 7: the shape frame is 80px tall but the
# single 20px line of glyphs occupies only its top; a line at the frame's lower region grazes
# under the visual glyph box (underline look) and must not be flagged.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape width="240" height="80" topLeftX="120" topLeftY="100" type="text" id="bmB">
<content fontSize="20" fontFamily="Arial" color="rgba(31, 35, 41, 1)" lineSpacing="fixed:24">
<p>Visual height target</p>
</content>
</shape>
<line id="bmQ" startX="110" startY="150" endX="380.00185184550116" endY="150">
<border color="rgba(0, 0, 0, 1)"/>
</line>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""

View File

@@ -111,18 +111,29 @@ func TestBase_RoleWorkflow(t *testing.T) {
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
getResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
DefaultAs: "bot",
})
require.NoError(t, err)
getResult.AssertExitCode(t, 0)
getResult.AssertStdoutStatus(t, true)
pollTimeout := 30 * time.Second
pollCtx, pollCancel := context.WithTimeout(ctx, pollTimeout)
defer pollCancel()
rolePayload := gjson.Get(getResult.Stdout, "data.data").String()
require.NotEmpty(t, rolePayload, "stdout:\n%s", getResult.Stdout)
require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", getResult.Stdout)
assert.Equal(t, updatedRoleName, gjson.Get(rolePayload, "role_name").String())
err = clie2e.WaitForCondition(pollCtx, clie2e.WaitOptions{
Timeout: pollTimeout,
Interval: 3 * time.Second,
}, func() (bool, error) {
getResult, getErr := clie2e.RunCmd(pollCtx, clie2e.Request{
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
DefaultAs: "bot",
})
if getErr != nil {
return false, getErr
}
if getResult.ExitCode != 0 {
return false, getResult.RunErr
}
rolePayload := gjson.Get(getResult.Stdout, "data.data").String()
return gjson.Valid(rolePayload) && gjson.Get(rolePayload, "role_name").String() == updatedRoleName, nil
})
require.NoError(t, err, "role name should converge to %q", updatedRoleName)
})
}

View File

@@ -0,0 +1,165 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_MemberListDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
args []string
wantURL string
wantType string
wantFields string
wantPermType string
}{
{
name: "bare folder token",
args: []string{
"drive", "+member-list",
"--token", "fldE2E001",
"--type", "folder",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/fldE2E001/members",
wantType: "folder",
},
{
name: "folder URL infers folder type",
args: []string{
"drive", "+member-list",
"--token", "https://example.feishu.cn/drive/folder/fldE2E002?from=share",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/fldE2E002/members",
wantType: "folder",
},
{
name: "fields star is passed only when explicit",
args: []string{
"drive", "+member-list",
"--token", "doxE2E003",
"--type", "docx",
"--fields", "*",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxE2E003/members",
wantType: "docx",
wantFields: "*",
},
{
name: "wiki perm type",
args: []string{
"drive", "+member-list",
"--token", "wikE2E004",
"--type", "wiki",
"--fields", "name,type",
"--perm-type", "single_page",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/wikE2E004/members",
wantType: "wiki",
wantFields: "name,type",
wantPermType: "single_page",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "data.api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "data.api.0.url").String(); got != tt.wantURL {
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
}
if got := gjson.Get(out, "data.api.0.params.type").String(); got != tt.wantType {
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out)
}
if tt.wantFields == "" {
if gjson.Get(out, "data.api.0.params.fields").Exists() {
t.Fatalf("params.fields should be omitted\nstdout:\n%s", out)
}
} else if got := gjson.Get(out, "data.api.0.params.fields").String(); got != tt.wantFields {
t.Fatalf("params.fields = %q, want %q\nstdout:\n%s", got, tt.wantFields, out)
}
if tt.wantPermType == "" {
if gjson.Get(out, "data.api.0.params.perm_type").Exists() {
t.Fatalf("params.perm_type should be omitted\nstdout:\n%s", out)
}
} else if got := gjson.Get(out, "data.api.0.params.perm_type").String(); got != tt.wantPermType {
t.Fatalf("params.perm_type = %q, want %q\nstdout:\n%s", got, tt.wantPermType, out)
}
})
}
}
func TestDrive_MemberListWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
folderName := "lark-cli-e2e-drive-member-list-" + clie2e.GenerateSuffix()
folderToken := createDriveFolderOrSkipPermission(t, parentT, ctx, folderName)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+member-list",
"--token", folderToken,
"--type", "folder",
"--format", "json",
},
DefaultAs: "bot",
})
require.NoError(t, err)
if result.ExitCode != 0 {
combinedOutput := strings.ToLower(result.Stdout + "\n" + result.Stderr)
if strings.Contains(combinedOutput, "docs:permission.member:retrieve") ||
strings.Contains(combinedOutput, "app scope not enabled") ||
strings.Contains(combinedOutput, "missing required scope") ||
strings.Contains(combinedOutput, "missing_scope") ||
strings.Contains(combinedOutput, "99991672") ||
strings.Contains(combinedOutput, "1063002") ||
strings.Contains(combinedOutput, "1063004") ||
strings.Contains(combinedOutput, "permission denied") ||
strings.Contains(combinedOutput, "no share permission") {
t.Skipf("skip drive member list workflow due to missing bot scope or folder permission: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr))
}
if strings.Contains(combinedOutput, "99992402") &&
strings.Contains(combinedOutput, "field validation failed") {
t.Skipf("skip drive member list workflow because this environment does not yet accept type=folder on the member list API: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr))
}
t.Fatalf("drive member list workflow failed: exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
}
result.AssertStdoutStatus(t, true)
if items := gjson.Get(result.Stdout, "data.items"); !items.Exists() || !items.IsArray() {
t.Fatalf("data.items must be present as an array\nstdout:\n%s", result.Stdout)
}
}

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_PermissionGetSettingDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
args []string
wantURL string
wantType string
}{
{
name: "bare folder token",
args: []string{
"drive", "+permission-get-setting",
"--token", "fldE2E001",
"--type", "folder",
"--dry-run",
},
wantURL: "/open-apis/drive/v2/permissions/fldE2E001/public",
wantType: "folder",
},
{
name: "folder URL",
args: []string{
"drive", "+permission-get-setting",
"--token", "https://example.feishu.cn/drive/folder/fldE2E001?from=share",
"--dry-run",
},
wantURL: "/open-apis/drive/v2/permissions/fldE2E001/public",
wantType: "folder",
},
{
name: "docx URL",
args: []string{
"drive", "+permission-get-setting",
"--token", "https://example.feishu.cn/docx/doxE2E001",
"--dry-run",
},
wantURL: "/open-apis/drive/v2/permissions/doxE2E001/public",
wantType: "docx",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "data.api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "data.api.0.url").String(); got != tt.wantURL {
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
}
if got := gjson.Get(out, "data.api.0.params.type").String(); got != tt.wantType {
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out)
}
if gjson.Get(out, "data.folder_token").Exists() {
t.Fatalf("folder_token exists in dry-run output, want omitted\nstdout:\n%s", out)
}
})
}
}
func TestDrive_PermissionGetSettingWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
folderToken := CreateDriveFolder(
t,
t,
ctx,
"lark-cli-e2e-drive-permission-get-setting-"+clie2e.GenerateSuffix(),
"bot",
"",
)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+permission-get-setting",
"--token", folderToken,
"--type", "folder",
"--format", "json",
},
DefaultAs: "bot",
})
require.NoError(t, err)
if result.ExitCode != 0 {
combinedOutput := strings.ToLower(result.Stdout + "\n" + result.Stderr)
if strings.Contains(combinedOutput, "docs:permission.setting:read") ||
strings.Contains(combinedOutput, "app scope not enabled") ||
strings.Contains(combinedOutput, "missing required scope") ||
strings.Contains(combinedOutput, "99991672") {
t.Skipf("skip drive permission setting workflow due to missing bot scope docs:permission.setting:read: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr))
}
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
if !gjson.Get(result.Stdout, "data.permission_public").Exists() {
t.Fatalf("permission_public missing in output\nstdout:\n%s", result.Stdout)
}
}