Compare commits

..

19 Commits

Author SHA1 Message Date
leave330
78acceb910 fix: preserve cause chain when rewrapping name-collision error 2026-07-08 17:53:18 +08:00
leave330
cb8d09d802 test: use conventional placeholder credentials in app test config 2026-07-08 17:27:22 +08:00
leave330
42dc50f5fb test: build delete stub URL from shared base path constant 2026-07-08 17:16:02 +08:00
leave330
59ccffb09a docs: refine lark-application skill guide 2026-07-08 17:10:01 +08:00
leave330
d4f62490d0 fix: limit recommended tier to slash-command read scope 2026-07-08 16:10:18 +08:00
leave330
d8327726b0 feat: expose application domain in interactive auth login 2026-07-08 16:00:16 +08:00
leave330
3a297df5b0 docs: note interactive login picker does not yet list application domain 2026-07-08 15:38:05 +08:00
leave330
dce04f792a fix: declare shared scopes on application shortcuts for user preflight 2026-07-08 15:09:52 +08:00
leave330
fd00d43406 fix: preserve high-risk note in delete dry-run and document force i18n caveat 2026-07-08 13:28:36 +08:00
leave330
f2f49a68f4 test: add application slash command dry-run E2E coverage
Pin the dry-run request shapes for +slash-command-list/create/update/delete
(GET/POST/PATCH/DELETE paths, top-level icon key sibling to description,
description.i18n) and confirm the high-risk-write delete path exits 10 with
a confirmation_required envelope on stderr when --yes is omitted.
2026-07-08 12:38:59 +08:00
leave330
42c4b7d155 fix: classify slash command not-found as API error and preserve conflict code
- commandNotFoundError now returns errs.NewAPIError(SubtypeNotFound, ...)
  instead of a validation error: a resolution miss against the live list
  means the resource doesn't exist, not that the caller's argument shape
  was invalid.
- The no-force name-collision rewrap in +slash-command-create now carries
  over the original Code/LogID from the upstream conflict response instead
  of dropping them.
- +slash-command-delete now prints an extra stderr note that recreating the
  same command name yields a NEW command_id.
- Add a test guarding that --force only converts a name-collision response
  into an update; any other POST failure (e.g. invalid icon_key) must
  surface unchanged with no PATCH attempted.
2026-07-08 12:38:53 +08:00
leave330
96c8e638a1 docs: fix slash command trigger event reference in lark-application skill 2026-07-08 12:20:01 +08:00
leave330
ba8368504d docs: add lark-application skill for slash command management 2026-07-08 11:56:15 +08:00
leave330
c3adfc1789 feat: add slash-command-delete shortcut with high-risk confirmation 2026-07-08 11:49:00 +08:00
leave330
a290aa628f feat: add slash-command-update shortcut with by-name addressing 2026-07-08 11:43:36 +08:00
leave330
edeccaa419 feat: add slash-command-create shortcut with --force idempotent re-run 2026-07-08 11:38:39 +08:00
leave330
84962a1927 feat: add slash command name-to-id resolution helper 2026-07-08 11:29:38 +08:00
leave330
a72a562c7d feat: add application domain with slash-command-list shortcut 2026-07-08 11:25:20 +08:00
leave330
deb866f981 feat: add application domain description and slash command helpers 2026-07-08 11:17:15 +08:00
72 changed files with 1772 additions and 8911 deletions

View File

@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
}

View File

@@ -1,10 +0,0 @@
# Lark Slides Development Plans
本目录只保存开发阶段计划、评审记录和执行拆解,不属于 `lark-slides` skill 的运行时提示词或工具使用参考。
约束:
- 不要从 `skills/lark-slides/SKILL.md` 路由到本目录。
- 不要把本目录内容作为 agent 调用 `slides` 工具时的稳定协议或操作规范。
- 当计划中的结论沉淀为长期有效规则时,先拆成原子化、可验证的运行时说明,再放入 `skills/lark-slides/references/` 或其下的专题目录。
- 当计划仅用于阶段性开发判断时,继续留在本目录,避免污染工具提示词边界。

File diff suppressed because it is too large Load Diff

View File

@@ -248,10 +248,18 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
allowSet := LoadOverrideAutoApproveAllow()
// recommend.allow in scope_overrides.json is intentionally empty:
// no scopes are special-cased into the auto-approve set anymore.
if len(allowSet) != 0 {
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
// recommend.allow special-cases scopes absent from scope_priorities.json
// (application v7 is not in the platform catalog yet) so interactive
// login's "common scopes" tier still offers them. Only the read scope is
// admitted: write stays out of the recommended tier by design.
if !allowSet["application:app_slash_command:read"] {
t.Error("expected application:app_slash_command:read in override allow set")
}
if allowSet["application:app_slash_command:write"] {
t.Error("write scope must NOT be in the recommended tier")
}
if len(allowSet) != 1 {
t.Errorf("expected exactly 1 override allow entry, got %d", len(allowSet))
}
}

View File

@@ -12,7 +12,9 @@
"vc:meeting.meetingevent:read": 75
},
"recommend": {
"allow": [],
"allow": [
"application:app_slash_command:read"
],
"deny": [
"im:chat",
"im:message.send_as_user"

View File

@@ -3,6 +3,10 @@
"en": { "title": "Approval", "description": "Approval instance, and task management" },
"zh": { "title": "审批", "description": "审批实例、审批任务管理" }
},
"application": {
"en": { "title": "Application", "description": "Open Platform app self-management: slash commands of the current bound app (NOT Miaoda low-code apps)" },
"zh": { "title": "应用管理", "description": "开放平台应用自管理:当前绑定应用的斜杠指令管理(非妙搭低代码应用)" }
},
"apps": {
"en": { "title": "Apps", "description": "Develop, deploy HTML, web pages and applications" },
"zh": { "title": "应用", "description": "开发、部署 HTML、Web 页面和应用" }

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package application provides shortcuts for Open Platform app
// self-management (slash commands of the current bound app).
package application
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all shortcuts of the application domain.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
SlashCommandList,
SlashCommandCreate,
SlashCommandUpdate,
SlashCommandDelete,
}
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"strings"
"github.com/larksuite/cli/errs"
)
// slashCommandBasePath is the raw v7 endpoint (not in meta_data.json / SDK).
const slashCommandBasePath = "/open-apis/application/v7/app_slash_commands"
// clientCacheHint is printed to stderr after every successful write.
const clientCacheHint = "note: changes take ~5 minutes to appear in Feishu clients (client-side cache); the server state is already updated - list reflects it immediately."
// parseDescriptionI18n parses repeated --description-i18n values ("<lang>=<text>",
// split on the FIRST '='). Returns nil for empty input. Duplicate langs rejected.
func parseDescriptionI18n(values []string) (map[string]string, error) {
if len(values) == 0 {
return nil, nil
}
m := make(map[string]string, len(values))
for _, v := range values {
idx := strings.Index(v, "=")
if idx <= 0 || idx == len(v)-1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid --description-i18n value %q: expected <lang>=<text> (e.g. zh_cn=你好)", v).
WithParam("--description-i18n")
}
lang := strings.TrimSpace(v[:idx])
text := v[idx+1:]
if lang == "" || strings.TrimSpace(text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid --description-i18n value %q: language and text must be non-empty", v).
WithParam("--description-i18n")
}
if _, dup := m[lang]; dup {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"duplicate language %q in --description-i18n", lang).
WithParam("--description-i18n")
}
m[lang] = text
}
return m, nil
}
// validateCommandName rejects empty and slash-prefixed command names.
func validateCommandName(name, flagName string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s must not be empty", flagName).WithParam(flagName)
}
if strings.HasPrefix(trimmed, "/") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s must not start with \"/\" - the slash is implied (use %q)",
flagName, strings.TrimPrefix(trimmed, "/")).WithParam(flagName)
}
return nil
}
// buildSlashCommandBody assembles a create/update request body. Only provided
// fields are included: PATCH is field-level partial (absent top-level fields
// are preserved server-side; a provided i18n map REPLACES the whole map).
// icon sits at the top level, sibling of description (verified live; the
// official create sample nesting icon inside description is a doc bug).
func buildSlashCommandBody(command, description string, i18n map[string]string, iconKey string) map[string]interface{} {
body := map[string]interface{}{}
if command != "" {
body["command"] = command
}
if description != "" || len(i18n) > 0 {
desc := map[string]interface{}{}
if description != "" {
desc["default_value"] = description
}
if len(i18n) > 0 {
desc["i18n"] = i18n
}
body["description"] = desc
}
if iconKey != "" {
body["icon"] = map[string]interface{}{"icon_key": iconKey}
}
return body
}
// isCommandExists reports whether err is the server-side name-collision error
// (code=40000000, message contains "command already exists"; verified live).
func isCommandExists(err error) bool {
p, ok := errs.ProblemOf(err)
if !ok {
return false
}
return strings.Contains(p.Message, "command already exists")
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
func TestParseDescriptionI18n_OK(t *testing.T) {
m, err := parseDescriptionI18n([]string{"zh_cn=你好", "en_us=Hello=World"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m["zh_cn"] != "你好" {
t.Errorf("zh_cn = %q", m["zh_cn"])
}
// 只按首个 = 分割:值内可含 =
if m["en_us"] != "Hello=World" {
t.Errorf("en_us = %q", m["en_us"])
}
}
func TestParseDescriptionI18n_Empty(t *testing.T) {
m, err := parseDescriptionI18n(nil)
if err != nil || m != nil {
t.Fatalf("nil input: m=%v err=%v", m, err)
}
}
func TestParseDescriptionI18n_BadFormat(t *testing.T) {
for _, bad := range []string{"zh_cn", "=text", "zh_cn=", " =x"} {
_, err := parseDescriptionI18n([]string{bad})
if err == nil {
t.Errorf("%q: expected error", bad)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("%q: expected validation problem, got %v", bad, err)
}
}
}
func TestParseDescriptionI18n_DuplicateLang(t *testing.T) {
_, err := parseDescriptionI18n([]string{"zh_cn=a", "zh_cn=b"})
if err == nil || !strings.Contains(err.Error(), "duplicate language") {
t.Fatalf("expected duplicate language error, got %v", err)
}
}
func TestValidateCommandName(t *testing.T) {
if err := validateCommandName("greet", "--command"); err != nil {
t.Fatalf("greet: %v", err)
}
for _, bad := range []string{"", " ", "/greet"} {
if err := validateCommandName(bad, "--command"); err == nil {
t.Errorf("%q: expected error", bad)
}
}
}
func TestBuildSlashCommandBody(t *testing.T) {
body := buildSlashCommandBody("greet", "hi", map[string]string{"zh_cn": "你好"}, "skill_outlined")
if body["command"] != "greet" {
t.Errorf("command = %v", body["command"])
}
desc := body["description"].(map[string]interface{})
if desc["default_value"] != "hi" {
t.Errorf("default_value = %v", desc["default_value"])
}
if desc["i18n"].(map[string]string)["zh_cn"] != "你好" {
t.Errorf("i18n = %v", desc["i18n"])
}
// icon 与 description 顶层平级(实测钉死,文档 create 示例是笔误)
if body["icon"].(map[string]interface{})["icon_key"] != "skill_outlined" {
t.Errorf("icon = %v", body["icon"])
}
// partial不提供的字段不出现PATCH 语义依赖)
partial := buildSlashCommandBody("", "", nil, "skill_outlined")
if _, has := partial["command"]; has {
t.Error("empty command must be omitted")
}
if _, has := partial["description"]; has {
t.Error("empty description must be omitted")
}
}
// TestSlashCommandShortcuts_SharedScopesAcrossIdentities locks in the
// reversal of the OAuth-isolation design: all four slash-command shortcuts
// declare identical scopes for the bot and user identities (plain Scopes /
// ConditionalScopes, no per-identity overrides), so a user-identity
// pre-flight sees the same scope set a bot identity would.
func TestSlashCommandShortcuts_SharedScopesAcrossIdentities(t *testing.T) {
cases := []struct {
name string
shortcut common.Shortcut
wantScope string
wantConditional string
hasConditional bool
}{
{
name: "list",
shortcut: SlashCommandList,
wantScope: "application:app_slash_command:read",
},
{
name: "create",
shortcut: SlashCommandCreate,
wantScope: "application:app_slash_command:write",
wantConditional: "application:app_slash_command:read",
hasConditional: true,
},
{
name: "update",
shortcut: SlashCommandUpdate,
wantScope: "application:app_slash_command:write",
wantConditional: "application:app_slash_command:read",
hasConditional: true,
},
{
name: "delete",
shortcut: SlashCommandDelete,
wantScope: "application:app_slash_command:write",
wantConditional: "application:app_slash_command:read",
hasConditional: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
for _, identity := range []string{"user", "bot"} {
declared := tc.shortcut.DeclaredScopesForIdentity(identity)
if !containsStr(declared, tc.wantScope) {
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain %q", tc.name, identity, declared, tc.wantScope)
}
if tc.hasConditional && !containsStr(declared, tc.wantConditional) {
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain conditional %q", tc.name, identity, declared, tc.wantConditional)
}
}
})
}
}
func containsStr(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlashCommandCreate registers a new slash command on the current bound app.
var SlashCommandCreate = common.Shortcut{
Service: "application",
Command: "+slash-command-create",
Description: "Register a slash command (/ command) on the current bound Open Platform app; --force converts a name collision into an update (idempotent re-run)",
Risk: "write",
Scopes: []string{"application:app_slash_command:write"},
ConditionalScopes: []string{
"application:app_slash_command:read", // only the --force collision path lists to resolve the id
},
AuthTypes: []string{"bot", "user"},
Flags: []common.Flag{
{Name: "command", Desc: "command name WITHOUT the leading slash (server enforces uniqueness per app; max 100 commands)", Required: true},
{Name: "description", Desc: "default description shown in the client command panel (description.default_value)", Required: true},
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable, format <lang>=<text> (e.g. zh_cn=发送问候); language codes are passed through to the server"},
{Name: "icon-key", Desc: "icon key (server default: skill_outlined; invalid keys are rejected server-side with code 40000031)"},
{Name: "force", Type: "bool", Desc: "on name collision, resolve the existing command by name and PATCH it instead (like `gh label create --force`)"},
},
Tips: []string{
`lark-cli application +slash-command-create --command greet --description "say hi" --description-i18n zh_cn=问候 --as bot`,
"changes take ~5 minutes to appear in clients (client-side cache); the server updates immediately",
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:write",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateCommandName(runtime.Str("command"), "--command"); err != nil {
return err
}
if len(strings.TrimSpace(runtime.Str("description"))) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description must not be blank").WithParam("--description")
}
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
body := buildSlashCommandBody(runtime.Str("command"), runtime.Str("description"), i18n, runtime.Str("icon-key"))
d := common.NewDryRunAPI().
Desc("Create a slash command on the current bound app").
POST(slashCommandBasePath).
Body(body)
if runtime.Bool("force") {
d.Desc("--force: on 'command already exists' (code 40000000), GET list to resolve command_id then PATCH the same body")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
name := runtime.Str("command")
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
return err
}
body := buildSlashCommandBody(name, runtime.Str("description"), i18n, runtime.Str("icon-key"))
data, err := runtime.CallAPITyped("POST", slashCommandBasePath, nil, body)
action := "created"
if err != nil {
if !isCommandExists(err) {
return err
}
if !runtime.Bool("force") {
p, _ := errs.ProblemOf(err)
rewrapped := errs.NewAPIError(p.Subtype, "slash command %q already exists", name).
WithHint("rerun with --force to update it, or use `lark-cli application +slash-command-update --command %q`", name).
WithCause(err)
if p.Code != 0 {
rewrapped = rewrapped.WithCode(p.Code)
}
if p.LogID != "" {
rewrapped = rewrapped.WithLogID(p.LogID)
}
return rewrapped
}
// --force: name collision -> resolve id -> PATCH (idempotent re-run).
id, _, rerr := resolveCommandID(runtime, name)
if rerr != nil {
return rerr
}
patchBody := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
data, err = runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+validate.EncodePathSegment(id), nil, patchBody)
if err != nil {
return err
}
action = "updated"
}
if data == nil {
data = map[string]interface{}{}
}
data["action"] = action
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
runtime.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s /%v (command_id: %v)\n", action, data["command"], data["command_id"])
})
return nil
},
}

View File

@@ -0,0 +1,182 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func createOKStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": sampleItem("greet", "id-new"),
},
}
}
func createConflictStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 40000000, "msg": "Invalid Param 'command'. command already exists.",
},
}
}
func patchOKStub(id string) *httpmock.Stub {
return &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/application/v7/app_slash_commands/" + id,
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": sampleItem("greet", id),
},
}
}
func TestSlashCommandCreate_OK(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createOKStub())
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi",
"--description-i18n", "zh_cn=你好", "--description-i18n", "en_us=Hello",
"--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\n%s", err, stdout.String())
}
data := got["data"].(map[string]interface{})
if data["action"] != "created" {
t.Fatalf("action = %v", data["action"])
}
if data["command_id"] != "id-new" {
t.Fatalf("command_id = %v", data["command_id"])
}
}
func TestSlashCommandCreate_ValidateRejects(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
cases := [][]string{
{"+slash-command-create", "--command", "/greet", "--description", "hi", "--as", "bot"},
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "bad", "--as", "bot"},
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "zh_cn=a", "--description-i18n", "zh_cn=b", "--as", "bot"},
{"+slash-command-create", "--command", "greet", "--description", " ", "--as", "bot"},
}
for i, args := range cases {
err := mountAndRun(t, SlashCommandCreate, args, f, stdout)
if err == nil {
t.Errorf("case %d: expected validation error", i)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("case %d: expected validation problem, got %v", i, err)
}
}
}
func TestSlashCommandCreate_ConflictNoForce(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createConflictStub())
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected conflict error")
}
p, _ := errs.ProblemOf(err)
if !strings.Contains(p.Hint, "--force") || !strings.Contains(p.Hint, "+slash-command-update") {
t.Fatalf("hint must offer --force and update, got %q", p.Hint)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("rewrapped error must be *errs.APIError, got %T", err)
}
if errors.Unwrap(apiErr) == nil {
t.Fatal("rewrapped conflict error must preserve the original cause via WithCause")
}
}
func TestSlashCommandCreate_ForceConvertsToUpdate(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createConflictStub())
reg.Register(listStub([]interface{}{sampleItem("greet", "id-exist")}))
reg.Register(patchOKStub("id-exist"))
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi2", "--force", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
if data["action"] != "updated" {
t.Fatalf("action = %v (force must convert to update)", data["action"])
}
}
func createIconInvalidStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 40000031, "msg": "Invalid Param 'icon_key'. icon_key is invalid.",
},
}
}
// TestSlashCommandCreate_ForceDoesNotConvertNonConflict guards against --force
// blindly treating ANY POST failure as a name collision: only the
// "command already exists" (40000000) shape may fall through to the
// GET+PATCH idempotent-update path. No PATCH stub is registered here, so if
// the code mistakenly attempted a PATCH, the httpmock registry would fail
// the unexpected request and surface a different (registry) error instead
// of the original icon_key failure asserted below.
func TestSlashCommandCreate_ForceDoesNotConvertNonConflict(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createIconInvalidStub())
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi", "--icon-key", "bogus", "--force", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected the original icon_key error, got nil")
}
if !strings.Contains(err.Error(), "icon_key") {
t.Fatalf("expected original icon_key failure to surface unchanged, got %v", err)
}
}
func TestSlashCommandCreate_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
if err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi", "--icon-key", "skill_outlined", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "POST") || !strings.Contains(out, slashCommandBasePath) {
t.Fatalf("dry-run must show POST path: %s", out)
}
// icon 顶层dry-run body 里 icon 不嵌套在 description 内
if !strings.Contains(out, "icon_key") {
t.Fatalf("dry-run must include body: %s", out)
}
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlashCommandDelete removes a slash command (irreversible; command_id is not
// reused - recreating the same name yields a NEW id).
var SlashCommandDelete = common.Shortcut{
Service: "application",
Command: "+slash-command-delete",
Description: "Delete a slash command from the current bound app (high-risk: irreversible; recreating the same name yields a new command_id)",
Risk: "high-risk-write",
Scopes: []string{"application:app_slash_command:write"},
ConditionalScopes: []string{
"application:app_slash_command:read", // only the --command by-name path
},
AuthTypes: []string{"bot", "user"},
Flags: []common.Flag{
{Name: "command-id", Desc: "target command_id; mutually exclusive with --command"},
{Name: "command", Desc: "target command name WITHOUT leading slash (resolved via live list, needs read scope); mutually exclusive with --command-id"},
},
Tips: []string{
"lark-cli application +slash-command-delete --command greet --yes --as bot",
"deleted commands may linger in clients for ~5 minutes (client cache)",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
name := strings.TrimSpace(runtime.Str("command"))
if (id == "") == (name == "") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"provide exactly one of --command-id or --command").WithParam("--command-id")
}
if name != "" {
return validateCommandName(name, "--command")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI().Desc("HIGH-RISK: delete a slash command (irreversible; same-name recreate gets a NEW command_id)")
target := runtime.Str("command-id")
if target == "" {
d.GET(slashCommandBasePath).
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", runtime.Str("command")))
target = "<resolved_command_id>"
}
return d.DELETE(slashCommandBasePath + "/" + target)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
name := strings.TrimSpace(runtime.Str("command"))
if id == "" {
resolved, _, err := resolveCommandID(runtime, name)
if err != nil {
return err
}
id = resolved
}
if _, err := runtime.CallAPITyped("DELETE", slashCommandBasePath+"/"+validate.EncodePathSegment(id), nil, nil); err != nil {
return err
}
out := map[string]interface{}{"action": "deleted", "command_id": id}
if name != "" {
out["command"] = name
}
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
fmt.Fprintln(runtime.IO().ErrOut, "note: recreating the same command name will yield a NEW command_id.")
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "deleted command_id %s\n", id)
})
return nil
},
}

View File

@@ -0,0 +1,109 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func deleteOKStub(id string) *httpmock.Stub {
return &httpmock.Stub{
Method: "DELETE",
URL: slashCommandBasePath + "/" + id,
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
}
func TestSlashCommandDelete_RequiresYes(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command-id", "id1", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected confirmation_required without --yes")
}
if errs.CategoryOf(err) != errs.CategoryConfirmation {
t.Fatalf("expected confirmation category, got %v (%v)", errs.CategoryOf(err), err)
}
}
func TestSlashCommandDelete_ByIDWithYes(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(deleteOKStub("id1"))
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command-id", "id1", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
// 上游 DELETE 返回空对象CLI 必须补 action/command_id写操作返回资源 ID
if data["action"] != "deleted" || data["command_id"] != "id1" {
t.Fatalf("data = %v", data)
}
}
func TestSlashCommandDelete_ByNameWithYes(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub([]interface{}{sampleItem("greet", "id7")}))
reg.Register(deleteOKStub("id7"))
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command", "greet", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
_ = json.Unmarshal(stdout.Bytes(), &got)
data := got["data"].(map[string]interface{})
if data["command"] != "greet" || data["command_id"] != "id7" {
t.Fatalf("data = %v", data)
}
}
func TestSlashCommandDelete_ByNameDryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command", "greet", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
// 两条 Desc 都必须保留top-level HIGH-RISK 说明和 GET 调用的 resolve 说明
// 不能被覆盖DryRunAPI.Desc 在没有 call 时设置 top-levelappend 后设置 per-call
if !strings.Contains(out, "HIGH-RISK") {
t.Fatalf("dry-run must keep top-level HIGH-RISK desc: %s", out)
}
if !strings.Contains(out, "resolve command_id") {
t.Fatalf("dry-run must keep per-call resolve desc: %s", out)
}
}
func TestSlashCommandDelete_Validate(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
for _, args := range [][]string{
{"+slash-command-delete", "--yes", "--as", "bot"},
{"+slash-command-delete", "--command-id", "id1", "--command", "greet", "--yes", "--as", "bot"},
} {
err := mountAndRun(t, SlashCommandDelete, args, f, stdout)
if err == nil {
t.Errorf("%v: expected validation error", args)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("%v: expected validation problem, got %v", args, err)
}
}
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// SlashCommandList lists all slash commands of the current bound app.
var SlashCommandList = common.Shortcut{
Service: "application",
Command: "+slash-command-list",
Description: "List all slash commands (/ commands) registered on the current bound Open Platform app; source of command_id for update/delete (NOT for Miaoda apps - use the apps domain for those)",
Risk: "read",
Scopes: []string{"application:app_slash_command:read"},
AuthTypes: []string{"bot", "user"},
Tips: []string{
"lark-cli application +slash-command-list --as bot",
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:read",
"the upstream API returns all commands at once (max 100 per app, no pagination)",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("List all slash commands of the current bound app (read-only)").
GET(slashCommandBasePath)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
if items == nil {
items = []interface{}{}
}
out := map[string]interface{}{"items": items, "count": len(items)}
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "%d slash command(s)\n", len(items))
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
desc := ""
if d, ok := m["description"].(map[string]interface{}); ok {
desc, _ = d["default_value"].(string)
}
fmt.Fprintf(w, " /%v\t%v\t%s\n", m["command"], m["command_id"], desc)
}
})
return nil
},
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func appTestConfig() *core.CliConfig {
return &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
}
// mountAndRun mounts the shortcut under a parent cobra command and runs it.
// Mirrors shortcuts/contact tests.
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "application"}
s.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func listStub(items []interface{}) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"items": items},
},
}
}
func sampleItem(name, id string) map[string]interface{} {
return map[string]interface{}{
"command": name, "command_id": id,
"create_time": "1783318553", "update_time": "1783318553",
"description": map[string]interface{}{"default_value": "desc of " + name},
"icon": map[string]interface{}{"icon_key": "skill_outlined"},
}
}
func TestSlashCommandList_JSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub([]interface{}{sampleItem("greet", "id1"), sampleItem("weather", "id2")}))
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\n%s", err, stdout.String())
}
data := got["data"].(map[string]interface{})
items := data["items"].([]interface{})
if len(items) != 2 {
t.Fatalf("items = %d", len(items))
}
if data["count"] != float64(2) {
t.Fatalf("count = %v", data["count"])
}
first := items[0].(map[string]interface{})
for _, k := range []string{"command", "command_id", "description", "icon", "create_time", "update_time"} {
if _, ok := first[k]; !ok {
t.Errorf("missing item key %q", k)
}
}
}
func TestSlashCommandList_Empty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub(nil))
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
items, ok := data["items"].([]interface{})
if !ok || len(items) != 0 {
t.Fatalf("empty list must be [] not %v", data["items"])
}
if data["count"] != float64(0) {
t.Fatalf("count = %v", data["count"])
}
}
func TestSlashCommandList_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/application/v7/app_slash_commands") || !strings.Contains(out, "GET") {
t.Fatalf("dry-run must show GET path, got %s", out)
}
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// matchCommandItem finds the item whose "command" equals name (exact match -
// the server enforces name uniqueness, so first hit is the only hit).
func matchCommandItem(items []interface{}, name string) (string, map[string]interface{}) {
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
if m["command"] == name {
id, _ := m["command_id"].(string)
if id != "" {
return id, m
}
}
}
return "", nil
}
// commandNotFoundError reports a resolution miss against the live list as an
// API-category not-found error (the name is a valid argument shape; the
// resource simply does not exist server-side - this is not a validation
// failure of caller input).
func commandNotFoundError(name string) error {
return errs.NewAPIError(errs.SubtypeNotFound,
"slash command %q not found in the current bound app", name).
WithHint("run `lark-cli application +slash-command-list` to see registered commands")
}
// resolveCommandID resolves a command name to its command_id via the live
// list endpoint (in-memory only; never touches local files). Requires the
// read scope on the current identity.
func resolveCommandID(runtime *common.RuntimeContext, name string) (string, map[string]interface{}, error) {
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
if err != nil {
return "", nil, err
}
items, _ := data["items"].([]interface{})
id, item := matchCommandItem(items, name)
if id == "" {
return "", nil, commandNotFoundError(name)
}
return id, item, nil
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"strings"
"testing"
)
func TestMatchCommandItem(t *testing.T) {
items := []interface{}{
sampleItem("greet", "id1"),
sampleItem("weather", "id2"),
}
id, item := matchCommandItem(items, "weather")
if id != "id2" || item == nil {
t.Fatalf("got id=%q item=%v", id, item)
}
id, item = matchCommandItem(items, "nope")
if id != "" || item != nil {
t.Fatalf("miss should return empty, got id=%q", id)
}
// 精确匹配:大小写与空白不做宽容
id, _ = matchCommandItem(items, "Greet")
if id != "" {
t.Fatalf("match must be exact, got %q", id)
}
}
func TestResolveNotFoundErrorShape(t *testing.T) {
err := commandNotFoundError("nope")
if err == nil || !strings.Contains(err.Error(), `"nope"`) {
t.Fatalf("err = %v", err)
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("err should say not found: %v", err)
}
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// validateUpdateTarget enforces: exactly one of --command-id/--command, and at
// least one editable field; --description-i18n requires --description (PATCH
// replaces the whole description object - sending i18n alone would drop
// default_value; conservative rule, see spec amendment #3).
func validateUpdateTarget(runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
name := strings.TrimSpace(runtime.Str("command"))
if (id == "") == (name == "") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"provide exactly one of --command-id or --command").WithParam("--command-id")
}
if name != "" {
if err := validateCommandName(name, "--command"); err != nil {
return err
}
}
hasDesc := strings.TrimSpace(runtime.Str("description")) != ""
hasI18n := len(runtime.StrArray("description-i18n")) > 0
hasIcon := strings.TrimSpace(runtime.Str("icon-key")) != ""
if !hasDesc && !hasI18n && !hasIcon {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"provide at least one of --description / --description-i18n / --icon-key").WithParam("--description")
}
if hasI18n && !hasDesc {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description-i18n requires --description: PATCH replaces the whole description object, so default_value must be provided together").WithParam("--description-i18n")
}
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
return err
}
return nil
}
// SlashCommandUpdate updates description/i18n/icon of an existing slash command.
var SlashCommandUpdate = common.Shortcut{
Service: "application",
Command: "+slash-command-update",
Description: "Update description / localized descriptions / icon of a slash command on the current bound app, addressed by --command-id or by name via --command",
Risk: "write",
Scopes: []string{"application:app_slash_command:write"},
ConditionalScopes: []string{
"application:app_slash_command:read", // only the --command by-name path lists to resolve the id
},
AuthTypes: []string{"bot", "user"},
Flags: []common.Flag{
{Name: "command-id", Desc: "target command_id (from +slash-command-list or create output); mutually exclusive with --command"},
{Name: "command", Desc: "target command name WITHOUT leading slash; resolved via live list (needs read scope); mutually exclusive with --command-id"},
{Name: "description", Desc: "new default description (description.default_value)"},
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable <lang>=<text>; REPLACES the whole i18n map (missing languages are dropped); requires --description"},
{Name: "icon-key", Desc: "new icon key (invalid keys rejected server-side with code 40000031)"},
},
Tips: []string{
`lark-cli application +slash-command-update --command greet --description "new text" --as bot`,
"PATCH is field-level partial: fields you do not pass are preserved server-side",
"the command NAME itself cannot be changed (API limitation): rename = delete + create (new command_id)",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateUpdateTarget(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
d := common.NewDryRunAPI()
target := runtime.Str("command-id")
if target == "" {
d.Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", runtime.Str("command"))).
GET(slashCommandBasePath)
target = "<resolved_command_id>"
}
return d.PATCH(slashCommandBasePath + "/" + target).Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
if id == "" {
resolved, _, err := resolveCommandID(runtime, strings.TrimSpace(runtime.Str("command")))
if err != nil {
return err
}
id = resolved
}
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
return err
}
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
data, err := runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+validate.EncodePathSegment(id), nil, body)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
data["action"] = "updated"
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
runtime.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "updated /%v (command_id: %v)\n", data["command"], data["command_id"])
})
return nil
},
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
)
func TestSlashCommandUpdate_ByID(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(patchOKStub("id1"))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command-id", "id1", "--description", "new", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
if data["action"] != "updated" {
t.Fatalf("action = %v", data["action"])
}
}
func TestSlashCommandUpdate_ByName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub([]interface{}{sampleItem("greet", "id9")}))
reg.Register(patchOKStub("id9"))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command", "greet", "--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
}
func TestSlashCommandUpdate_ByNameNotFound(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub(nil))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command", "nope", "--description", "x", "--as", "bot"}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected not found, got %v", err)
}
}
func TestSlashCommandUpdate_Validate(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
cases := []struct {
name string
args []string
}{
{"both id and name", []string{"+slash-command-update", "--command-id", "id1", "--command", "greet", "--description", "x", "--as", "bot"}},
{"neither id nor name", []string{"+slash-command-update", "--description", "x", "--as", "bot"}},
{"no editable field", []string{"+slash-command-update", "--command-id", "id1", "--as", "bot"}},
{"i18n without description", []string{"+slash-command-update", "--command-id", "id1", "--description-i18n", "zh_cn=x", "--as", "bot"}},
}
for _, c := range cases {
err := mountAndRun(t, SlashCommandUpdate, c.args, f, stdout)
if err == nil {
t.Errorf("%s: expected validation error", c.name)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("%s: expected validation problem, got %v", c.name, err)
}
}
}

View File

@@ -27,17 +27,12 @@ const (
html5BlockDataAttr = "data"
html5BlockReferenceRoot = "doc-fetch-resources"
html5BlockReferenceMaxRaw = 1024
whiteboardTag = "whiteboard"
whiteboardTypeAttr = "type"
whiteboardPathAttr = "path"
)
var (
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
whiteboardElementPattern = regexp.MustCompile(`(?is)<whiteboard\b[^>]*(?:/>|>.*?</whiteboard>)`)
)
type html5BlockReferenceEntry struct {
@@ -63,11 +58,6 @@ type html5BlockStartTag struct {
SelfClosing bool
}
type whiteboardStartTag struct {
Attrs []html5BlockAttr
SelfClosing bool
}
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := buildCreateBody(runtime)
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
@@ -125,11 +115,7 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
return docsV2WriteInput{}, err
}
content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content)
if err != nil {
return docsV2WriteInput{}, err
}
content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), content, html5RefMap)
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
if err != nil {
return docsV2WriteInput{}, err
}
@@ -246,248 +232,6 @@ func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string
return out, compactReferenceMap(refMap), nil
}
func prepareWhiteboardWriteContent(runtime *common.RuntimeContext, format string, content string) (string, error) {
if !strings.Contains(content, "<whiteboard") {
return content, nil
}
rewrite := func(segment string) (string, error) {
return rewriteWhiteboardFileRefs(runtime, segment)
}
if strings.TrimSpace(format) != "markdown" {
return rewrite(content)
}
var rewriteErrs []error
out := applyOutsideCodeFences(content, func(segment string) string {
outSegment, rewriteErr := rewrite(segment)
if rewriteErr != nil {
rewriteErrs = append(rewriteErrs, rewriteErr)
return segment
}
return outSegment
})
if len(rewriteErrs) > 0 {
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
}
return out, nil
}
func rewriteWhiteboardFileRefs(runtime *common.RuntimeContext, content string) (string, error) {
var rewriteErrs []error
out := whiteboardElementPattern.ReplaceAllStringFunc(content, func(raw string) string {
rewritten, err := rewriteWhiteboardFileRef(runtime, raw)
if err != nil {
rewriteErrs = append(rewriteErrs, err)
return raw
}
return rewritten
})
if len(rewriteErrs) > 0 {
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
}
return out, nil
}
func rewriteWhiteboardFileRef(runtime *common.RuntimeContext, raw string) (string, error) {
startRaw, body, _, ok := splitWhiteboardElement(raw)
if !ok {
return raw, nil
}
tag, err := parseWhiteboardStartTag(startRaw)
if err != nil {
return "", common.ValidationErrorf("invalid whiteboard tag: %v", err).WithParam("whiteboard")
}
pathValue, hasPath := tag.attr(whiteboardPathAttr)
bodyPath, hasBodyPath := whiteboardBodyPathRef(body)
if !hasPath && !hasBodyPath {
return raw, nil
}
if hasPath && strings.TrimSpace(body) != "" {
return "", common.ValidationErrorf("whiteboard cannot contain both path and inline content").WithParam("whiteboard")
}
if hasPath && hasBodyPath {
return "", common.ValidationErrorf("whiteboard cannot contain both path and @file body").WithParam("whiteboard")
}
typRaw, ok := tag.attr(whiteboardTypeAttr)
if !ok || strings.TrimSpace(typRaw) == "" {
return "", common.ValidationErrorf("whiteboard file input requires type=\"svg\", type=\"mermaid\", or type=\"plantuml\"").WithParam("type")
}
typ, ok := canonicalWhiteboardFileType(typRaw)
if !ok {
return "", common.ValidationErrorf("whiteboard file input only supports type=\"svg\", type=\"mermaid\", or type=\"plantuml\", got %q", typRaw).WithParam("type")
}
if hasBodyPath {
pathValue = bodyPath
}
data, err := readWhiteboardPath(runtime, pathValue, typ)
if err != nil {
return "", err
}
tag.setAttr(whiteboardTypeAttr, typ)
tag.removeAttrs(whiteboardPathAttr)
return tag.render(false) + whiteboardContentForType(typ, data) + "</" + whiteboardTag + ">", nil
}
func splitWhiteboardElement(raw string) (startTag string, body string, selfClosing bool, ok bool) {
trimmed := strings.TrimSpace(raw)
selfClosing = strings.HasSuffix(trimmed, "/>")
if selfClosing {
return raw, "", true, true
}
startEnd := strings.Index(raw, ">")
if startEnd < 0 {
return "", "", false, false
}
endStart := strings.LastIndex(strings.ToLower(raw), "</whiteboard>")
if endStart < 0 || endStart < startEnd {
return "", "", false, false
}
return raw[:startEnd+1], raw[startEnd+1 : endStart], false, true
}
func whiteboardBodyPathRef(body string) (string, bool) {
trimmed := strings.TrimSpace(body)
if !strings.HasPrefix(trimmed, "@") || strings.HasPrefix(trimmed, "@@") {
return "", false
}
if strings.ContainsAny(trimmed, "\r\n") {
return "", false
}
return trimmed, true
}
func canonicalWhiteboardFileType(raw string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "svg":
return "svg", true
case "mermaid":
return "mermaid", true
case "plantuml":
return "plantuml", true
default:
return "", false
}
}
func readWhiteboardPath(runtime *common.RuntimeContext, pathValue string, typ string) (string, error) {
pathRaw := strings.TrimSpace(pathValue)
if !strings.HasPrefix(pathRaw, "@") {
return "", common.ValidationErrorf("whiteboard %s path %q must start with @, for example @diagram.%s", typ, pathValue, exampleWhiteboardExt(typ)).WithParam("path")
}
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
if relPath == "" {
return "", common.ValidationErrorf("whiteboard %s path cannot be empty after @", typ).WithParam("path")
}
clean := filepath.Clean(relPath)
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", common.ValidationErrorf("whiteboard %s path %q must be a relative path within the current working directory", typ, pathValue).WithParam("path")
}
if !whiteboardExtAllowed(typ, strings.ToLower(filepath.Ext(clean))) {
return "", common.ValidationErrorf("whiteboard %s path %q must point to a %s file", typ, pathValue, whiteboardExtList(typ)).WithParam("path")
}
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
if err != nil {
return "", common.ValidationErrorf("whiteboard %s path %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", typ, clean, err).
WithParam("path").
WithParams(errs.InvalidParam{Name: clean, Reason: fmt.Sprintf("whiteboard %s path cannot be read", typ)}).
WithCause(err)
}
return string(data), nil
}
func whiteboardExtAllowed(typ string, ext string) bool {
for _, allowed := range whiteboardAllowedExts(typ) {
if ext == allowed {
return true
}
}
return false
}
func whiteboardAllowedExts(typ string) []string {
switch typ {
case "svg":
return []string{".svg"}
case "mermaid":
return []string{".mermaid", ".mmd"}
case "plantuml":
return []string{".plantuml", ".puml", ".pu", ".uml"}
default:
return nil
}
}
func whiteboardExtList(typ string) string {
return strings.Join(whiteboardAllowedExts(typ), ", ")
}
func exampleWhiteboardExt(typ string) string {
exts := whiteboardAllowedExts(typ)
if len(exts) == 0 {
return "txt"
}
return strings.TrimPrefix(exts[0], ".")
}
func whiteboardContentForType(typ string, data string) string {
if typ == "svg" {
return data
}
return escapeXMLText(data)
}
func aggregateWhiteboardRewriteErrors(rewriteErrs []error) error {
flatErrs := flattenWhiteboardRewriteErrors(rewriteErrs)
messages := make([]string, 0, len(flatErrs))
params := make([]errs.InvalidParam, 0, len(flatErrs))
for _, err := range flatErrs {
messages = append(messages, err.Error())
params = append(params, whiteboardInvalidParamsFromError(err)...)
}
validationErr := common.ValidationErrorf("whiteboard file input failed: %s", strings.Join(messages, "; ")).
WithParam("whiteboard").
WithCause(errors.Join(flatErrs...))
if len(params) > 0 {
validationErr.WithParams(params...)
}
return validationErr
}
func flattenWhiteboardRewriteErrors(rewriteErrs []error) []error {
flatErrs := make([]error, 0, len(rewriteErrs))
for _, err := range rewriteErrs {
var validationErr *errs.ValidationError
if errors.As(err, &validationErr) && validationErr.Param == "whiteboard" && validationErr.Cause != nil {
if joined, ok := validationErr.Cause.(interface{ Unwrap() []error }); ok {
flatErrs = append(flatErrs, flattenWhiteboardRewriteErrors(joined.Unwrap())...)
continue
}
}
flatErrs = append(flatErrs, err)
}
return flatErrs
}
func whiteboardInvalidParamsFromError(err error) []errs.InvalidParam {
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
return nil
}
if len(validationErr.Params) > 0 {
return validationErr.Params
}
if validationErr.Param != "" {
return []errs.InvalidParam{{Name: validationErr.Param, Reason: validationErr.Message}}
}
return nil
}
func validateHTML5BlockWriteElementBodies(format string, content string) error {
validateSegment := func(segment string) error {
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
@@ -877,34 +621,6 @@ func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
func parseWhiteboardStartTag(raw string) (whiteboardStartTag, error) {
trimmed := strings.TrimSpace(raw)
selfClosing := strings.HasSuffix(trimmed, "/>")
decoder := xml.NewDecoder(strings.NewReader(raw))
for {
tok, err := decoder.Token()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return whiteboardStartTag{}, err
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
if start.Name.Local != whiteboardTag {
return whiteboardStartTag{}, fmt.Errorf("expected <%s>, got <%s>", whiteboardTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
attrs := make([]html5BlockAttr, 0, len(start.Attr))
for _, attr := range start.Attr {
attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
}
return whiteboardStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
}
return whiteboardStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
func (t html5BlockStartTag) attr(name string) (string, bool) {
for _, attr := range t.Attrs {
if attr.Name == name {
@@ -914,15 +630,6 @@ func (t html5BlockStartTag) attr(name string) (string, bool) {
return "", false
}
func (t whiteboardStartTag) attr(name string) (string, bool) {
for _, attr := range t.Attrs {
if attr.Name == name {
return attr.Value, true
}
}
return "", false
}
func (t html5BlockStartTag) hasAttr(name string) bool {
_, ok := t.attr(name)
return ok
@@ -943,31 +650,6 @@ func (t *html5BlockStartTag) removeAttrs(names ...string) {
t.Attrs = attrs
}
func (t *whiteboardStartTag) removeAttrs(names ...string) {
remove := make(map[string]struct{}, len(names))
for _, name := range names {
remove[name] = struct{}{}
}
attrs := t.Attrs[:0]
for _, attr := range t.Attrs {
if _, ok := remove[attr.Name]; ok {
continue
}
attrs = append(attrs, attr)
}
t.Attrs = attrs
}
func (t *whiteboardStartTag) setAttr(name string, value string) {
for i, attr := range t.Attrs {
if attr.Name == name {
t.Attrs[i].Value = value
return
}
}
t.Attrs = append(t.Attrs, html5BlockAttr{Name: name, Value: value})
}
func (t html5BlockStartTag) render(selfClosing bool) string {
var b strings.Builder
b.WriteByte('<')
@@ -992,25 +674,6 @@ func (t html5BlockStartTag) render(selfClosing bool) string {
return b.String()
}
func (t whiteboardStartTag) render(selfClosing bool) string {
var b strings.Builder
b.WriteByte('<')
b.WriteString(whiteboardTag)
for _, attr := range t.Attrs {
b.WriteByte(' ')
b.WriteString(attr.Name)
b.WriteString(`="`)
b.WriteString(escapeXMLAttr(attr.Value))
b.WriteByte('"')
}
if selfClosing {
b.WriteString("/>")
} else {
b.WriteByte('>')
}
return b.String()
}
func escapeXMLAttr(value string) string {
var b strings.Builder
for _, r := range value {
@@ -1031,18 +694,3 @@ func escapeXMLAttr(value string) string {
}
return b.String()
}
func escapeXMLText(value string) string {
var b strings.Builder
for _, r := range value {
switch r {
case '&':
b.WriteString("&amp;")
case '<':
b.WriteString("&lt;")
default:
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -6,13 +6,11 @@ package doc
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
@@ -118,61 +116,6 @@ func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
}
}
func TestDocsCreateV2WhiteboardFileInputs(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
files := map[string]string{
"diagram.svg": `<svg viewBox="0 0 10 10"><text>A</text></svg>`,
"flow.mmd": "flowchart TD\nA --> B",
"sequence.puml": "@startuml\nAlice -> Bob: hi\n@enduml",
}
for name, content := range files {
if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile(%s) error: %v", name, err)
}
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", strings.Join([]string{
`<whiteboard type="svg" path="@diagram.svg"></whiteboard>`,
`<whiteboard type="mermaid">@flow.mmd</whiteboard>`,
`<whiteboard type="plantUML" path="@sequence.puml"/>`,
}, "\n"),
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
got := body["content"].(string)
for _, want := range []string{
`<whiteboard type="svg"><svg viewBox="0 0 10 10"><text>A</text></svg></whiteboard>`,
"<whiteboard type=\"mermaid\">flowchart TD\nA --> B</whiteboard>",
"<whiteboard type=\"plantuml\">@startuml\nAlice -> Bob: hi\n@enduml</whiteboard>",
} {
if !strings.Contains(got, want) {
t.Fatalf("content missing %q:\n%s", want, got)
}
}
if strings.Contains(got, `path="@`) {
t.Fatalf("content still contains whiteboard path attr: %s", got)
}
if _, ok := body["reference_map"]; ok {
t.Fatalf("whiteboard file input must not create reference_map: %#v", body)
}
}
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
for _, flag := range flags {
if flag.Name == name {
@@ -464,119 +407,6 @@ func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
}
}
func TestDocsCreateV2WhiteboardFileInputReportsAllMissingPaths(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", strings.Join([]string{
`<whiteboard type="svg" path="@missing.svg"></whiteboard>`,
`<whiteboard type="mermaid">@missing.mmd</whiteboard>`,
`<whiteboard type="plantuml" path="@missing.puml"></whiteboard>`,
}, "\n"),
"--as", "user",
})
if err == nil {
t.Fatal("expected aggregated whiteboard path error")
}
assertWhiteboardFileInputValidation(t, err, []string{
"missing.svg",
"missing.mmd",
"missing.puml",
}, []string{
`whiteboard svg path "missing.svg" cannot be read`,
`whiteboard mermaid path "missing.mmd" cannot be read`,
`whiteboard plantuml path "missing.puml" cannot be read`,
})
}
func TestDocsCreateV2WhiteboardFileInputMarkdownReportsMissingPathsAcrossFences(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--doc-format", "markdown",
"--content", strings.Join([]string{
`<whiteboard type="svg" path="@before.svg"></whiteboard>`,
"```",
`<whiteboard type="svg" path="@inside.svg"></whiteboard>`,
"```",
`<whiteboard type="plantuml" path="@after.puml"></whiteboard>`,
}, "\n"),
"--as", "user",
})
if err == nil {
t.Fatal("expected aggregated whiteboard path error")
}
assertWhiteboardFileInputValidation(t, err, []string{
"before.svg",
"after.puml",
}, []string{
`whiteboard svg path "before.svg" cannot be read`,
`whiteboard plantuml path "after.puml" cannot be read`,
})
if strings.Contains(err.Error(), "inside.svg") {
t.Fatalf("error should ignore fenced whiteboard path, got: %v", err)
}
}
func assertWhiteboardFileInputValidation(t *testing.T, err error, wantParams []string, wantMessages []string) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("category/subtype = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if validationErr.Param != "whiteboard" {
t.Fatalf("param = %q, want whiteboard", validationErr.Param)
}
if validationErr.Cause == nil {
t.Fatal("expected aggregated error to preserve cause")
}
var childValidationErr *errs.ValidationError
if !errors.As(validationErr.Cause, &childValidationErr) || childValidationErr.Cause == nil {
t.Fatalf("expected child validation cause to preserve file read cause, got %#v", validationErr.Cause)
}
gotParams := make(map[string]string, len(validationErr.Params))
for _, param := range validationErr.Params {
gotParams[param.Name] = param.Reason
}
if len(gotParams) != len(wantParams) {
t.Fatalf("params = %#v, want names %v", validationErr.Params, wantParams)
}
for _, param := range wantParams {
reason, ok := gotParams[param]
if !ok {
t.Fatalf("params = %#v, want name %q", validationErr.Params, param)
}
if reason == "" {
t.Fatalf("param %q missing reason: %#v", param, validationErr.Params)
}
}
for _, want := range wantMessages {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error missing %q:\n%v", want, err)
}
if !strings.Contains(validationErr.Cause.Error(), want) {
t.Fatalf("cause missing %q:\n%v", want, validationErr.Cause)
}
}
}
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)

View File

@@ -51,8 +51,9 @@ func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) {
// original message as read after a reply/reply-all/forward operation.
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
fmt.Fprintf(runtime.IO().ErrOut,
"tip: mark original as read? lark-cli mail +message-modify --mailbox '%s' --message-ids '%s' --remove-label-ids UNREAD\n",
shellQuoteForHint(mailboxID), shellQuoteForHint(originalMessageID))
"tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+
` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n",
sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID))
}
// hintReadReceiptRequest prints a stderr tip when a message that the caller

View File

@@ -465,19 +465,14 @@ func TestPrintWatchOutputSchema(t *testing.T) {
// TestHintMarkAsRead verifies hint mark as read.
func TestHintMarkAsRead(t *testing.T) {
rt, _, stderr := newOutputRuntime(t)
hintMarkAsRead(rt, "mail box;$(whoami)", "msg-\x1b[31m123 'quoted'\nnext")
// Inject ANSI escape + message ID to verify sanitization
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
out := stderr.String()
if strings.Contains(out, "\x1b[") {
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
}
if strings.Contains(out, "\nnext") {
t.Errorf("hintMarkAsRead should strip embedded newlines, got: %q", out)
}
if !strings.Contains(out, "--mailbox 'mail box;$(whoami)'") {
t.Errorf("hintMarkAsRead should quote mailbox for shell copy/paste, got: %q", out)
}
if !strings.Contains(out, "--message-ids 'msg-123 '\\''quoted'\\''next'") {
t.Errorf("hintMarkAsRead should quote message ID for shell copy/paste, got: %q", out)
if !strings.Contains(out, "msg-123") {
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out)
}
}

View File

@@ -1,482 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
func messageManageID(suffix string) string {
return "msg_abcdefghijklmnop_" + suffix
}
func stubMessageManagePost(reg *httpmock.Registry, endpoint string, body map[string]interface{}) *httpmock.Stub {
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/" + endpoint,
Body: body,
}
reg.Register(stub)
return stub
}
func decodeMessageManageSummary(t *testing.T, data map[string]interface{}) ([]interface{}, []interface{}) {
t.Helper()
success, ok := data["success_message_ids"].([]interface{})
if !ok {
t.Fatalf("success_message_ids = %#v, want array", data["success_message_ids"])
}
failed, ok := data["failed_message_ids"].([]interface{})
if !ok {
t.Fatalf("failed_message_ids = %#v, want array", data["failed_message_ids"])
}
return success, failed
}
func requireMessageManageValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatalf("expected validation error for %s, got nil", param)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError for %s, got %T", param, err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed Problem for %s, got %T", param, 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.Param != param {
t.Fatalf("param = %q, want %q", validationErr.Param, param)
}
return validationErr
}
func requireMessageManageFailedPrecondition(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected failed precondition error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed Problem, got %T", err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %s/%s, want validation/failed_precondition", problem.Category, problem.Subtype)
}
}
func TestMessageManage_NormalizeMessageIDs(t *testing.T) {
id1 := messageManageID("1")
id2 := messageManageID("2")
got, err := normalizeMessageManageIDs([]string{id1, id2, id1})
if err != nil {
t.Fatalf("normalizeMessageManageIDs returned error: %v", err)
}
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
t.Fatalf("ids = %v, want [%s %s]", got, id1, id2)
}
got, err = normalizeMessageManageIDs([]string{id1 + "," + id2, id1})
if err != nil {
t.Fatalf("normalizeMessageManageIDs CSV/repeated returned error: %v", err)
}
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
t.Fatalf("CSV/repeated ids = %v, want [%s %s]", got, id1, id2)
}
cases := [][]string{
{""},
{" id_with_leading_space_12345"},
{"msg_abcdefghijklmnop_1,msg_abcdefghijklmnop_2 "},
{"1234567890123456"},
{"short"},
{"msg_abcdefghijklmnop!"},
{"msg_abcdefghijklmnop\t"},
{"msg_abcdefghijklmnop_1\nmsg_abcdefghijklmnop_2"},
{"msg_abcdefghijklmnop_1", "msg_abcdefghijklmnop_2 "},
}
for _, tc := range cases {
_, err := normalizeMessageManageIDs(tc)
requireMessageManageValidationParam(t, err, "--message-ids")
}
}
func TestMessageModify_Metadata(t *testing.T) {
if MailMessageModify.Command != "+message-modify" {
t.Fatalf("Command = %q", MailMessageModify.Command)
}
if MailMessageModify.Risk != "write" {
t.Errorf("Risk = %q, want write", MailMessageModify.Risk)
}
if len(MailMessageModify.AuthTypes) != 1 || MailMessageModify.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v, want [user]", MailMessageModify.AuthTypes)
}
requiredScopes := map[string]bool{
"mail:user_mailbox.message:modify": true,
}
for _, scope := range MailMessageModify.Scopes {
delete(requiredScopes, scope)
}
if len(requiredScopes) != 0 {
t.Errorf("Scopes missing %v", requiredScopes)
}
if len(MailMessageModify.ConditionalScopes) != 1 || MailMessageModify.ConditionalScopes[0] != "mail:user_mailbox.folder:read" {
t.Errorf("ConditionalScopes = %v, want [mail:user_mailbox.folder:read]", MailMessageModify.ConditionalScopes)
}
flags := map[string]common.Flag{}
for _, fl := range MailMessageModify.Flags {
flags[fl.Name] = fl
}
for _, name := range []string{"mailbox", "message-ids", "add-label-ids", "remove-label-ids", "add-folder"} {
if _, ok := flags[name]; !ok {
t.Fatalf("missing --%s flag", name)
}
}
if flags["message-ids"].Type != "string_array" || !flags["message-ids"].Required {
t.Errorf("--message-ids = %#v, want required string_array", flags["message-ids"])
}
}
func TestMessageTrash_Metadata(t *testing.T) {
if MailMessageTrash.Command != "+message-trash" {
t.Fatalf("Command = %q", MailMessageTrash.Command)
}
if MailMessageTrash.Risk != "high-risk-write" {
t.Errorf("Risk = %q, want high-risk-write", MailMessageTrash.Risk)
}
if len(MailMessageTrash.AuthTypes) != 1 || MailMessageTrash.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v, want [user]", MailMessageTrash.AuthTypes)
}
if len(MailMessageTrash.Scopes) != 1 || MailMessageTrash.Scopes[0] != "mail:user_mailbox.message:modify" {
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:modify]", MailMessageTrash.Scopes)
}
}
func TestMessageModify_LabelOnlyDoesNotRequireFolderReadScope(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
token := auth.GetStoredToken("test-app", "ou_testuser")
if token == nil {
t.Fatal("expected test token")
}
token.Scope = strings.ReplaceAll(token.Scope, " mail:user_mailbox.folder:read", "")
if err := auth.SetStoredToken(token); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
id := messageManageID("1")
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--remove-label-ids", "UNREAD",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
removeLabels := body["remove_label_ids"].([]interface{})
if len(removeLabels) != 1 || removeLabels[0] != "UNREAD" {
t.Fatalf("remove_label_ids = %#v, want [UNREAD]", removeLabels)
}
}
func TestMessageModify_ReadReceiptRequestLabelIsSystemLabel(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--remove-label-ids", "read_receipt_request",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
removeLabels := body["remove_label_ids"].([]interface{})
if len(removeLabels) != 1 || removeLabels[0] != "READ_RECEIPT_REQUEST" {
t.Fatalf("remove_label_ids = %#v, want [READ_RECEIPT_REQUEST]", removeLabels)
}
}
func TestMessageModify_LabelFolderNormalizationAndValidationAPIs(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/labels/customA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"label_id": "customA"}}})
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/folders/folderA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"folder_id": "folderA"}}})
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-label-ids", "unread,customA",
"--remove-label-ids", "FLAGGED",
"--add-folder", "folderA",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if got := body["add_folder"]; got != "folderA" {
t.Errorf("add_folder = %v, want folderA", got)
}
addLabels := body["add_label_ids"].([]interface{})
if addLabels[0] != "UNREAD" || addLabels[1] != "customA" {
t.Errorf("add_label_ids = %#v, want [UNREAD customA]", addLabels)
}
removeLabels := body["remove_label_ids"].([]interface{})
if removeLabels[0] != "FLAGGED" {
t.Errorf("remove_label_ids = %#v, want [FLAGGED]", removeLabels)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 1 || success[0] != id || len(failed) != 0 {
t.Errorf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageModify_RejectsLabelIntersectionAndTrashFolder(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id := messageManageID("1")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-label-ids", "unread",
"--remove-label-ids", "UNREAD",
}, f, stdout)
requireMessageManageValidationParam(t, err, "--add-label-ids")
if !strings.Contains(err.Error(), "label cannot be both added and removed") {
t.Fatalf("error = %v, want label intersection validation", err)
}
err = runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-folder", "trash",
}, f, stdout)
requireMessageManageValidationParam(t, err, "--add-folder")
if !strings.Contains(err.Error(), "use +message-trash") {
t.Fatalf("error = %v, want TRASH validation", err)
}
}
func TestMessageModify_EmptyOperationDoesNotCallPost(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id1 + "," + id2 + "," + id1,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 2 || success[0] != id1 || success[1] != id2 || len(failed) != 0 {
t.Fatalf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageModify_BatchesAndAggregatesPartialFailure(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
ids := make([]string, 41)
for i := range ids {
ids[i] = messageManageID(fmt.Sprintf("%02d", i))
}
first := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
second := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
third := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", strings.Join(ids, ","),
"--add-folder", "archive",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
for idx, stub := range []*httpmock.Stub{first, second, third} {
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("batch %d body unmarshal: %v", idx+1, err)
}
messageIDs := body["message_ids"].([]interface{})
want := []int{20, 20, 1}[idx]
if len(messageIDs) != want {
t.Fatalf("batch %d size = %d, want %d", idx+1, len(messageIDs), want)
}
if body["add_folder"] != "ARCHIVED" {
t.Fatalf("batch %d add_folder = %v, want ARCHIVED", idx+1, body["add_folder"])
}
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 21 || len(failed) != 20 {
t.Fatalf("success=%d failed=%d, want 21/20", len(success), len(failed))
}
}
func TestMessageModify_AllBatchesFailReturnsError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-folder", "archive",
}, f, stdout)
requireMessageManageFailedPrecondition(t, err)
}
func TestMessageModify_DryRunShowsPlanWithoutValidationGET(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id1 + "," + id2,
"--add-label-ids", "customA",
"--add-folder", "folderA",
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run failed: %v", err)
}
out := stdout.String()
for _, want := range []string{
`/user_mailboxes/me/messages/batch_modify`,
`validation_api_plan`,
`/user_mailboxes/me/labels/customA`,
`/user_mailboxes/me/folders/folderA`,
`will_validate`,
`batch_size`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q; got %s", want, out)
}
}
}
func TestMessageTrash_RequiresYesAndBatches(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id1 + "," + id2,
}, f, stdout)
if err == nil {
t.Fatal("expected confirmation error, got nil")
}
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
t.Fatalf("exit code = %d, want %d", code, output.ExitConfirmationRequired)
}
post := stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err = runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id1 + "," + id2,
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err with --yes: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if got := len(body["message_ids"].([]interface{})); got != 2 {
t.Fatalf("message_ids len = %d, want 2", got)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 2 || len(failed) != 0 {
t.Fatalf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageTrash_AllBatchesFailReturnsError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 1230001, "msg": "bad request"})
err := runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id,
"--yes",
}, f, stdout)
requireMessageManageFailedPrecondition(t, err)
}
func TestMessageManage_RejectsWhitespaceBeforeAPI(t *testing.T) {
id1 := messageManageID("1")
id2 := messageManageID("2")
cases := []struct {
name string
shortcut common.Shortcut
args []string
}{
{
name: "trash newline in repeated flag",
shortcut: MailMessageTrash,
args: []string{"+message-trash", "--message-ids", id1 + "\n" + id2, "--yes"},
},
{
name: "trash tab in csv flag",
shortcut: MailMessageTrash,
args: []string{"+message-trash", "--message-ids", id1 + ",\t" + id2, "--yes"},
},
{
name: "modify space in repeated flag",
shortcut: MailMessageModify,
args: []string{"+message-modify", "--message-ids", id1, "--message-ids", id2 + " ", "--add-folder", "archive"},
},
{
name: "modify space in csv flag",
shortcut: MailMessageModify,
args: []string{"+message-modify", "--message-ids", id1 + ", " + id2, "--add-folder", "archive"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d; err=%v", code, output.ExitValidation, err)
}
if !strings.Contains(err.Error(), "must not contain whitespace or control characters") {
t.Fatalf("error = %v, want whitespace/control validation", err)
}
})
}
}

View File

@@ -1,141 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
type messageModifyInput struct {
MessageIDs []string
AddLabelIDs []string
RemoveLabelIDs []string
AddFolder string
CustomLabelIDs []string
CustomFolderID string
ValidationAPIPlans []validationAPIPlan
}
// MailMessageModify is the `+message-modify` shortcut: apply labels, unread
// state labels, or a folder move to existing messages in batches of 20.
var MailMessageModify = common.Shortcut{
Service: "mail",
Command: "+message-modify",
Description: "Modify existing mail messages by adding/removing label IDs or moving them to a folder. Batches message IDs in groups of 20 and keeps output compact.",
Risk: "write",
Scopes: []string{"mail:user_mailbox.message:modify"},
ConditionalScopes: []string{
"mail:user_mailbox.folder:read",
},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to modify; comma-separated or repeat the flag."},
{Name: "add-label-ids", Type: "string_slice", Desc: "Label IDs to add. System labels unread/important/other/flagged are normalized to upper case."},
{Name: "remove-label-ids", Type: "string_slice", Desc: "Label IDs to remove. System labels unread/important/other/flagged are normalized to upper case."},
{Name: "add-folder", Desc: "Folder ID to move messages to. System folders inbox/sent/spam/archive/archived are normalized; TRASH is rejected, use +message-trash."},
},
Validate: validateMessageModify,
DryRun: dryRunMessageModify,
Execute: executeMessageModify,
}
func validateMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
_, err := buildMessageModifyInput(rt)
return err
}
func dryRunMessageModify(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(rt)
input, _ := buildMessageModifyInput(rt)
api := common.NewDryRunAPI().
Desc("Modify messages sequentially in batches of 20; dry-run does not call label/folder validation APIs").
Set("batch_size", mailMessageManageBatchSize).
Set("batches", chunkMessageManageIDs(input.MessageIDs)).
Set("validation_api_plan", input.ValidationAPIPlans)
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
api = api.POST(mailboxPath(mailboxID, "messages", "batch_modify")).
Body(messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
}
return api
}
func executeMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveMailboxID(rt)
input, err := buildMessageModifyInput(rt)
if err != nil {
return err
}
if err := validateCustomMessageManageLabels(rt, mailboxID, input.CustomLabelIDs); err != nil {
return err
}
if err := validateCustomMessageManageFolder(rt, mailboxID, input.CustomFolderID); err != nil {
return err
}
if len(input.AddLabelIDs) == 0 && len(input.RemoveLabelIDs) == 0 && input.AddFolder == "" {
emitMessageManageSummary(rt, messageManageSummary{
SuccessMessageIDs: input.MessageIDs,
FailedMessageIDs: []messageManageFailure{},
}, true)
return nil
}
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_modify"), nil,
messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
if err != nil {
for _, id := range batch {
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
}
continue
}
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
}
emitMessageManageSummary(rt, summary, false)
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
return mailFailedPreconditionError("all message modify batches failed")
}
return nil
}
func buildMessageModifyInput(rt *common.RuntimeContext) (messageModifyInput, error) {
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
if err != nil {
return messageModifyInput{}, err
}
addLabels, customAddLabels, err := normalizeMessageManageLabels(rt.StrSlice("add-label-ids"), "--add-label-ids")
if err != nil {
return messageModifyInput{}, err
}
removeLabels, customRemoveLabels, err := normalizeMessageManageLabels(rt.StrSlice("remove-label-ids"), "--remove-label-ids")
if err != nil {
return messageModifyInput{}, err
}
if err := validateLabelIntersection(addLabels, removeLabels); err != nil {
return messageModifyInput{}, err
}
folder, customFolder, err := normalizeMessageManageFolder(rt.Str("add-folder"))
if err != nil {
return messageModifyInput{}, err
}
customLabels := append(customAddLabels, customRemoveLabels...)
customFolderID := ""
if customFolder {
customFolderID = folder
}
return messageModifyInput{
MessageIDs: messageIDs,
AddLabelIDs: addLabels,
RemoveLabelIDs: removeLabels,
AddFolder: folder,
CustomLabelIDs: customLabels,
CustomFolderID: customFolderID,
ValidationAPIPlans: messageManageValidationPlan(resolveMailboxID(rt), customLabels, customFolderID),
}, nil
}

View File

@@ -1,75 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// MailMessageTrash is the `+message-trash` shortcut: soft-delete existing
// messages in batches of 20 via batch_trash. Risk is high-risk-write, so the
// runner requires --yes before Execute.
var MailMessageTrash = common.Shortcut{
Service: "mail",
Command: "+message-trash",
Description: "Soft-delete existing mail messages. Batches message IDs in groups of 20 and calls batch_trash sequentially. Requires --yes.",
Risk: "high-risk-write",
Scopes: []string{"mail:user_mailbox.message:modify"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to soft-delete; comma-separated or repeat the flag."},
},
Validate: validateMessageTrash,
DryRun: dryRunMessageTrash,
Execute: executeMessageTrash,
}
func validateMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
_, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
return err
}
func dryRunMessageTrash(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(rt)
messageIDs, _ := normalizeMessageManageIDs(rt.StrArray("message-ids"))
api := common.NewDryRunAPI().
Desc("Soft-delete messages sequentially in batches of 20").
Set("batch_size", mailMessageManageBatchSize).
Set("batches", chunkMessageManageIDs(messageIDs))
for _, batch := range chunkMessageManageIDs(messageIDs) {
api = api.POST(mailboxPath(mailboxID, "messages", "batch_trash")).
Body(map[string]interface{}{"message_ids": batch})
}
return api
}
func executeMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveMailboxID(rt)
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
if err != nil {
return err
}
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
for _, batch := range chunkMessageManageIDs(messageIDs) {
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_trash"), nil,
map[string]interface{}{"message_ids": batch})
if err != nil {
for _, id := range batch {
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
}
continue
}
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
}
emitMessageManageSummary(rt, summary, false)
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
return mailFailedPreconditionError("all message trash batches failed")
}
return nil
}

View File

@@ -44,7 +44,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by
RefreshToken: "test-refresh-token",
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly mail:user_mailbox.folder:read",
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
}
if err := auth.SetStoredToken(token); err != nil {

View File

@@ -1,283 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"fmt"
"io"
"strings"
"unicode"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const mailMessageManageBatchSize = 20
var messageManageSystemLabels = map[string]string{
"UNREAD": "UNREAD",
"IMPORTANT": "IMPORTANT",
"OTHER": "OTHER",
"FLAGGED": "FLAGGED",
"READ_RECEIPT_REQUEST": "READ_RECEIPT_REQUEST",
}
var messageManageSystemFolders = map[string]string{
"INBOX": "INBOX",
"SENT": "SENT",
"SPAM": "SPAM",
"ARCHIVE": "ARCHIVED",
"ARCHIVED": "ARCHIVED",
}
type messageManageSummary struct {
SuccessMessageIDs []string `json:"success_message_ids"`
FailedMessageIDs []messageManageFailure `json:"failed_message_ids"`
}
type messageManageFailure struct {
MessageID string `json:"message_id"`
Reason string `json:"reason"`
}
type validationAPIPlan struct {
Method string `json:"method"`
Path string `json:"path"`
WillValidate bool `json:"will_validate"`
}
func normalizeMessageManageIDs(raw []string) ([]string, error) {
if len(raw) == 0 {
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
}
parts, err := splitMessageManageIDTokens(raw)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for i, part := range parts {
if part == "" {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
id := strings.TrimSpace(part)
if id == "" {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
if id != part {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
}
if err := validateMessageManageID(id, i); err != nil {
return nil, err
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
}
return ids, nil
}
func splitMessageManageIDTokens(raw []string) ([]string, error) {
parts := make([]string, 0, len(raw))
for i, token := range raw {
for _, r := range token {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", i+1, token)
}
}
parts = append(parts, strings.Split(token, ",")...)
}
return parts, nil
}
func validateMessageManageID(id string, index int) error {
if len(id) < 16 {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): length must be at least 16 characters", index+1, id)
}
if strings.Trim(id, "0123456789") == "" {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported; pass the Open API message_id from mail output", index+1, id)
}
for _, r := range id {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", index+1, id)
}
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
switch r {
case '+', '/', '=', '_', '-':
continue
default:
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): contains characters outside the Open API message_id character set", index+1, id)
}
}
return nil
}
func normalizeMessageManageLabels(raw []string, flagName string) ([]string, []string, error) {
labels := make([]string, 0, len(raw))
custom := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for i, part := range raw {
id := strings.TrimSpace(part)
if id == "" {
return nil, nil, mailValidationParamError(flagName, "%s entry %d is empty; remove extra commas or provide valid label IDs", flagName, i+1)
}
if id != part {
return nil, nil, mailValidationParamError(flagName, "%s entry %d (%q): must not contain leading or trailing whitespace", flagName, i+1, part)
}
normalized := id
if system, ok := messageManageSystemLabels[strings.ToUpper(id)]; ok {
normalized = system
} else {
custom = append(custom, id)
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
labels = append(labels, normalized)
}
if len(labels) > 20 {
return nil, nil, mailValidationParamError(flagName, "%s accepts at most 20 label IDs (got %d)", flagName, len(labels))
}
return labels, custom, nil
}
func validateLabelIntersection(add, remove []string) error {
removeSet := make(map[string]struct{}, len(remove))
for _, id := range remove {
removeSet[id] = struct{}{}
}
for _, id := range add {
if _, ok := removeSet[id]; ok {
return mailValidationParamError("--add-label-ids", "label cannot be both added and removed: %s", id)
}
}
return nil
}
func normalizeMessageManageFolder(raw string) (string, bool, error) {
if raw == "" {
return "", false, nil
}
folder := strings.TrimSpace(raw)
if folder == "" {
return "", false, mailValidationParamError("--add-folder", "--add-folder must not be empty")
}
if folder != raw {
return "", false, mailValidationParamError("--add-folder", "--add-folder %q must not contain leading or trailing whitespace", raw)
}
if strings.EqualFold(folder, "TRASH") {
return "", false, mailValidationParamError("--add-folder", "TRASH is not supported by +message-modify; use +message-trash")
}
if system, ok := messageManageSystemFolders[strings.ToUpper(folder)]; ok {
return system, false, nil
}
return folder, true, nil
}
func chunkMessageManageIDs(ids []string) [][]string {
if len(ids) == 0 {
return nil
}
chunks := make([][]string, 0, (len(ids)+mailMessageManageBatchSize-1)/mailMessageManageBatchSize)
for start := 0; start < len(ids); start += mailMessageManageBatchSize {
end := start + mailMessageManageBatchSize
if end > len(ids) {
end = len(ids)
}
chunks = append(chunks, ids[start:end])
}
return chunks
}
func validateCustomMessageManageLabels(rt *common.RuntimeContext, mailboxID string, ids []string) error {
if len(ids) == 0 {
return nil
}
if err := validateLabelReadScope(rt); err != nil {
return err
}
seen := map[string]struct{}{}
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "labels", id), nil, nil); err != nil {
return mailDecorateProblemMessage(err, "label not found: %s", id)
}
}
return nil
}
func validateCustomMessageManageFolder(rt *common.RuntimeContext, mailboxID, id string) error {
if id == "" {
return nil
}
if err := validateFolderReadScope(rt); err != nil {
return err
}
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "folders", id), nil, nil); err != nil {
return mailDecorateProblemMessage(err, "folder not found: %s", id)
}
return nil
}
func messageManageBody(ids, addLabels, removeLabels []string, addFolder string) map[string]interface{} {
body := map[string]interface{}{"message_ids": ids}
if len(addLabels) > 0 {
body["add_label_ids"] = addLabels
}
if len(removeLabels) > 0 {
body["remove_label_ids"] = removeLabels
}
if addFolder != "" {
body["add_folder"] = addFolder
}
return body
}
func messageManageValidationPlan(mailboxID string, customLabels []string, customFolder string) []validationAPIPlan {
plans := make([]validationAPIPlan, 0, len(customLabels)+1)
seenLabels := map[string]struct{}{}
for _, id := range customLabels {
if _, ok := seenLabels[id]; ok {
continue
}
seenLabels[id] = struct{}{}
plans = append(plans, validationAPIPlan{
Method: "GET",
Path: mailboxPath(mailboxID, "labels", id),
WillValidate: true,
})
}
if customFolder != "" {
plans = append(plans, validationAPIPlan{
Method: "GET",
Path: mailboxPath(mailboxID, "folders", customFolder),
WillValidate: true,
})
}
return plans
}
func emitMessageManageSummary(rt *common.RuntimeContext, summary messageManageSummary, noAPICalls bool) {
rt.OutFormat(summary, &output.Meta{Count: len(summary.SuccessMessageIDs)}, func(w io.Writer) {
fmt.Fprintf(w, "success_message_ids: %d\n", len(summary.SuccessMessageIDs))
fmt.Fprintf(w, "failed_message_ids: %d\n", len(summary.FailedMessageIDs))
if noAPICalls {
fmt.Fprintln(w, "No changes requested; no API calls were made.")
}
for _, item := range summary.FailedMessageIDs {
fmt.Fprintf(w, "- %s: %s\n", item.MessageID, item.Reason)
}
})
}

View File

@@ -10,8 +10,6 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MailMessage,
MailMessages,
MailMessageModify,
MailMessageTrash,
MailThread,
MailTriage,
MailWatch,

View File

@@ -17,6 +17,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/application"
"github.com/larksuite/cli/shortcuts/apps"
"github.com/larksuite/cli/shortcuts/base"
"github.com/larksuite/cli/shortcuts/calendar"
@@ -61,6 +62,7 @@ var allShortcuts []common.Shortcut
func init() {
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
allShortcuts = append(allShortcuts, application.Shortcuts()...)
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
allShortcuts = append(allShortcuts, drive.Shortcuts()...)

View File

@@ -0,0 +1,20 @@
## 开放平台应用自管理application域介绍
本域面向飞书开放平台的**应用自身**做自管理操作——即应用对着自己的配置做增删改查(而不是操作某个用户/群组的业务资源)。当前唯一承载的能力是**斜杠指令Slash Command**:应用注册在客户端指令面板里可见的 `/` 命令(也叫机器人命令),用户在会话输入框敲出后会触发对应的机器人交互。
- **App应用**:当前 lark-cli 绑定的开放平台应用本身,通过 appId/appSecret 识别,没有独立的 `app_id` 参数——所有操作都作用于"当前绑定应用"
- **Slash Command斜杠指令**:应用注册的 `/` 命令对象,含 `command_id`(服务端生成、不可复用)、`command`(指令名,不带前导 `/`)、`description`(默认描述 + 多语言 i18n`icon`
### 与 apps妙搭域的区别
两者经常被用户口语混淆,务必区分:
| | application本域 | apps妙搭 |
|---|---|---|
| 对象 | 开放平台应用自身的配置(自管理) | 低代码/无代码平台上创建的妙搭应用 |
| 典型能力 | 斜杠指令管理 | 应用创建、HTML 发布、可用范围管理 |
| 触发词 | 斜杠指令、指令面板、机器人命令、应用自管理 | 妙搭、Miaoda、低代码应用 |
### 当前能力与未来规划
当前仅实现斜杠指令的列出/创建/更新/删除。**bot 菜单管理、应用版本发布、应用使用统计**等其他"应用自管理"类能力规划中、尚未实现——遇到相关用户诉求应如实告知暂不支持,不要臆造对应命令。

View File

@@ -65,7 +65,7 @@
1. `+triage --from spam@x.com` → 列出 N 条结果
2. 展示:"将删除 N 封邮件(发件人 spam@x.com主题确认"
3. 用户确认后 → `+message-trash --message-ids ... --yes`
3. 用户确认后 → `*.batch_trash`
## 身份选择:优先使用 user 身份
@@ -82,13 +82,12 @@
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
2. **浏览**`+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
3. **阅读**`+message` 读单封邮件,`+thread` 读整个会话
4. **整理**标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
5. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送
7. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
8. **确认投递**立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
9. **编辑草稿** `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
10. **已读回执**
4. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
5. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
7. **确认投递** 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
8. **编辑草稿**`+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
9. **已读回执**
- **请求回执(写信侧)**`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
- **响应回执(拉信侧)**:拉信看到 `label_ids``READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
@@ -418,7 +417,7 @@ lark-cli mail +message --message-id <id>
## 原生 API 调用规则
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`。调用步骤以本节为准API Resources 章节的 resource/method 列表可辅助查阅)。
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准API Resources 章节的 resource/method 列表可辅助查阅)。
### Step 1 — 用 `-h` 确定要调用的 API必须不可跳过

View File

@@ -0,0 +1,38 @@
---
name: lark-application
version: 1.0.0
description: "飞书开放平台应用自管理目前仅实现管理当前绑定应用的斜杠指令Slash Command 是一种允许用户在飞书聊天框中通过输入 / 快速触发机器人服务的交互方式——列出、创建、更新、删除。当用户提到Slash Command、发消息斜杠指令面板、斜杠指令时使用。注意区分这不是妙搭应用lark-apps也不负责指令触发后的回调消费lark-event。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli application --help"
---
# application
## 身份权限和风险处理
开始前先读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)(认证、权限处理、`--dry-run`、exit 10 高风险确认协议)。
`--as bot``--as user`均支持。
两个权限:`application:app_slash_command:read`(列出)/ `application:app_slash_command:write`(创建/更新/删除)
## Shortcuts
| Shortcut | 说明 |
|----------|------|
| [`+slash-command-list`](references/lark-application-slash-command-list.md) | 列出当前绑定应用的全部斜杠指令;是 update/delete 定位 `command_id` 的数据来源 |
| [`+slash-command-create`](references/lark-application-slash-command-create.md) | 注册新斜杠指令;`--force` 可把撞名冲突转成幂等的 update |
| [`+slash-command-update`](references/lark-application-slash-command-update.md) | 更新已有指令的描述 / 多语言描述 / 图标,按 `--command-id``--command` 定位 |
| [`+slash-command-delete`](references/lark-application-slash-command-delete.md) | 删除斜杠指令(高危写操作,走 exit 10 确认协议) |
## 关键语义
- **`--command` 不带前导 `/`**:传 `/greet` 会被拒绝,正确写法是 `--command greet`(斜杠是隐含的,客户端展示时自动加上)。
- **客户端缓存 ~5 分钟,服务端立即生效**:写操作成功后 `+slash-command-list` 立刻能看到最新状态,但 Feishu 客户端指令面板可能仍显示旧值,这是客户端缓存导致,不代表写入失败,不要因为客户端没刷新就重试写操作。
- **改名 = 删除 + 创建,`command_id` 会变**API 不支持直接修改 `command` 字段本身(`+slash-command-update` 只能改描述/图标)。要重命名指令,必须先 `+slash-command-delete` 旧指令,再 `+slash-command-create` 新名字,产生的是全新 `command_id`,不是原地重命名。
## 不在本 skill 范围
- 妙搭应用的创建、发布、可见范围管理 → [lark-apps](../lark-apps/SKILL.md)
- 指令触发后的消息以普通 IM 消息送达(`im.message.receive_v1` 事件),消费走 lark-event`lark-cli event consume` → [lark-event](../lark-event/SKILL.md)
- bot 菜单、应用版本管理、应用统计(未实现)

View File

@@ -0,0 +1,67 @@
# application +slash-command-create
在当前绑定应用上注册一个新的斜杠指令。
## 命令格式
```bash
lark-cli application +slash-command-create --command <name> --description <text> [flags]
```
## 参数表
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `--command` | string | 是 | 指令名,**不带前导 `/`**(服务端按应用维度校验唯一,最多 100 条指令) |
| `--description` | string | 是 | 客户端指令面板默认展示的描述(写入 `description.default_value` |
| `--description-i18n` | string_array可重复 | 否 | 多语言描述,格式 `<lang>=<text>`(如 `zh_cn=发送问候`),可重复多次传不同语言;语言码原样透传给服务端,不做校验 |
| `--icon-key` | string | 否 | 图标 key不传时服务端默认 `skill_outlined`;非法 key 会被服务端拒绝code `40000031` |
| `--force` | bool | 否 | 撞名时不报错,改为解析出已存在指令的 `command_id` 并对其执行 PATCH等价于幂等重跑类似 `gh label create --force` |
> **CAUTION**`--force` 撞名转 PATCH 时,`description`(含 `i18n` map按整体替换处理不是增量合并。若重跑命令时不带上完整的 `--description-i18n`,指令已有的多语言翻译会被静默清空。要做真正幂等的重跑,必须把之前设置过的所有语言连同新值一起重新传入 `--description-i18n`。
## 返回值
```json
{
"ok": true,
"identity": "bot",
"data": {
"command_id": "7123456789012345678",
"command": "greet",
"description": { "default_value": "say hi", "i18n": { "zh_cn": "问候" } },
"icon": { "icon_key": "skill_outlined" },
"action": "created"
}
}
```
`action``created``updated`(后者只在 `--force` 命中撞名分支时出现)。命令执行成功后 stderr 会打印一行客户端缓存提示,不是错误。
## 典型用法
```bash
# 创建一个新指令,附带中文本地化描述
lark-cli application +slash-command-create \
--command greet --description "say hi" --description-i18n zh_cn=问候 --as bot
# 幂等重跑:如果 greet 已存在则直接更新它,而不是报错退出
# 注意:若 greet 已有 --description-i18n如 zh_cn=问候),这里不带上就会被清空
lark-cli application +slash-command-create \
--command greet --description "say hi v2" --description-i18n zh_cn=问候 --force --as bot
```
## 错误与恢复
| 场景 | 识别 | 处理 |
|---|---|---|
| 指令名已存在 | 错误 `code=40000000`message 含 `command already exists` | 未加 `--force` 时直接报错并提示改用 `+slash-command-update` 或加 `--force` 重跑;已加 `--force` 时 CLI 会自动列出全部指令解析 `command_id` 后转为 PATCH无需人工介入 |
| `--icon-key` 非法 | 错误 `code=40000031`,原样透传服务端 message | 换成合法 icon key或省略该参数使用默认值 |
| bot 缺少 `application:app_slash_command:write``--force` 撞名分支还需 `:read` | 错误信封携带 `console_url` | 把 `console_url` 给用户,引导去开发者后台开通对应 scope 并**发布新版本**;不要对 bot 执行 `auth login` |
| user 缺少对应 scope | 错误提示缺失 scope | `lark-cli auth login --scope application:app_slash_command:write``--force` 场景还需 `:read` |
## 参考
- [lark-application](../SKILL.md) — skill 入口与路由,含"改名=删除+创建"等关键语义
- [+slash-command-list](lark-application-slash-command-list.md)
- [+slash-command-update](lark-application-slash-command-update.md)

View File

@@ -0,0 +1,65 @@
# application +slash-command-delete
删除当前绑定应用上的一个斜杠指令。**`Risk: high-risk-write`,不可逆。**
## 命令格式
```bash
lark-cli application +slash-command-delete (--command-id <id> | --command <name>) --yes
```
## 参数表
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `--command-id` | string | 与 `--command` 二选一 | 目标指令的 `command_id` |
| `--command` | string | 与 `--command-id` 二选一 | 按名字定位(不带前导 `/`CLI 会先调用 list 解析出 `command_id`,需要 read scope |
| `--yes` | bool | 高风险操作确认必需 | 见下方"高风险确认协议" |
## 高风险确认协议CRITICAL
本命令 `Risk: high-risk-write`。不带 `--yes` 调用会以 exit code `10` 失败stderr 返回 `error.type == "confirmation_required"` 的结构化 envelope——完整协议见 [lark-shared 高风险操作的审批协议exit 10](../../lark-shared/SKILL.md),本节只补充本命令的领域特有部分。
**Agent 必须在追加 `--yes` 重试前,先把要删除的指令名 / `command_id` 明确展示给用户并取得同意**——不要因为 exit 10 是"标准协议"就默认自动重试。判断"是否已获得授权"时,用户必须在当前对话中明确点名了具体指令;仅泛泛说"清理一下指令"不构成授权。
## 返回值
```json
{
"ok": true,
"identity": "bot",
"data": { "action": "deleted", "command_id": "7123456789012345678", "command": "greet" }
}
```
`command` 字段只在通过 `--command` 定位时才会出现(`--command-id` 直接删除时无法反查名字,字段缺省)。
## 典型用法
```bash
# 先不加 --yes 试探,看到 confirmation_required 后向用户确认
lark-cli application +slash-command-delete --command greet
# 用户明确同意后,原样追加 --yes 重试
lark-cli application +slash-command-delete --command greet --yes --as bot
```
## 错误与恢复
| 场景 | 识别 | 处理 |
|---|---|---|
| 未加 `--yes` | exit code `10``error.type == "confirmation_required"` | 展示待删除指令给用户确认,同意后在原始命令末尾追加 `--yes` 重试;不要静默重试 |
| 按 `--command` 找不到指令 | 报错"未找到"hint 指向 list | 先跑 `+slash-command-list` 确认名字 |
| bot 缺少 `application:app_slash_command:write`(按名定位还需 `:read` | 错误信封携带 `console_url` | 引导用户去开发者后台开通并发版;不要对 bot 执行 `auth login` |
| user 缺少对应 scope | 提示缺失 scope | `lark-cli auth login --scope application:app_slash_command:write`(按名定位还需 `--scope application:app_slash_command:read` |
| 删除后客户端指令面板仍显示旧指令 | 正常现象,约 5 分钟客户端缓存 | 服务端已立即生效(`+slash-command-list` 会立刻反映删除),不要因客户端未刷新而重复删除 |
## 恢复注意
删除是不可逆操作,且**没有"撤销删除"**。如果需要恢复同名指令,只能重新 `+slash-command-create`,得到的是**全新的 `command_id`**(与原指令无关联)。
## 参考
- [lark-application](../SKILL.md) — skill 入口与路由
- [+slash-command-list](lark-application-slash-command-list.md)
- [+slash-command-create](lark-application-slash-command-create.md)

View File

@@ -0,0 +1,62 @@
# application +slash-command-list
列出当前绑定应用的全部斜杠指令。只读操作,也是 `+slash-command-update` / `+slash-command-delete` 按名字定位 `command_id` 的数据来源。
## 命令格式
```bash
lark-cli application +slash-command-list --as bot
```
无参数、无分页——上游 API 一次性返回全部指令(每个应用最多 100 条)。
## 参数表
本命令不接受除全局 `--as` / `--format` 之外的业务参数。
## 返回值
```json
{
"ok": true,
"identity": "bot",
"data": {
"items": [
{
"command_id": "7123456789012345678",
"command": "greet",
"description": { "default_value": "say hi", "i18n": { "zh_cn": "问候" } },
"icon": { "icon_key": "skill_outlined" }
}
],
"count": 1
}
}
```
`count``items` 的长度,供快速判断是否为空列表;不要用它替代实际遍历 `items`
## 典型用法
```bash
# 查看当前应用注册了哪些指令
lark-cli application +slash-command-list --as bot
# 按名字找出某条指令的 command_id供 update/delete 使用
lark-cli application +slash-command-list --as bot --format json | jq '.data.items[] | select(.command=="greet")'
```
## 错误与恢复
| 场景 | 处理 |
|---|---|
| bot 缺少 `application:app_slash_command:read` scope | 错误信封携带 `console_url`;把它原样给用户,引导去开发者后台开通并**发布新版本**——scope 变更需要应用发版才能对线上 bot 生效,不要对 bot 执行 `auth login` |
| user 缺少该 scope | 提示执行 `lark-cli auth login --scope application:app_slash_command:read` |
| 返回空列表 | 正常状态(应用还没注册任何指令),不是错误 |
## 参考
- [lark-application](../SKILL.md) — skill 入口与路由
- [+slash-command-create](lark-application-slash-command-create.md)
- [+slash-command-update](lark-application-slash-command-update.md)
- [+slash-command-delete](lark-application-slash-command-delete.md)

View File

@@ -0,0 +1,74 @@
# application +slash-command-update
更新一个已有斜杠指令的描述 / 多语言描述 / 图标。
## 命令格式
```bash
lark-cli application +slash-command-update (--command-id <id> | --command <name>) [flags]
```
## 参数表
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `--command-id` | string | 与 `--command` 二选一 | 目标指令的 `command_id`(来自 `+slash-command-list` 或 create 的输出) |
| `--command` | string | 与 `--command-id` 二选一 | 按名字定位(不带前导 `/`CLI 会先调用 list 解析出 `command_id`,需要 read scope |
| `--description` | string | 否 | 新的默认描述(`description.default_value` |
| `--description-i18n` | string_array可重复 | 否 | 多语言描述,格式 `<lang>=<text>`**整体替换**已有 i18n map未传入的语言会被服务端丢弃不是增量合并**必须伴随 `--description` 一起传**,否则报错 |
| `--icon-key` | string | 否 | 新图标 key非法 key 服务端拒绝code `40000031` |
`--description` / `--description-i18n` / `--icon-key` 三者至少提供一个,否则报错"没有可更新字段"。
## 关键语义
- **`--command-id``--command` 互斥,且必须提供其中之一**——两个都不传或都传都会报错。
- **PATCH 是字段级局部更新**:未显式传入的字段(如只传了 `--icon-key`)在服务端保持原值不变。
- **`--description-i18n` 是整体替换,不是增量合并**:如果指令原本有 `zh_cn` + `ja_jp` 两个语言的描述,本次只传 `--description-i18n en_us=hi`,结果是只剩 `en_us` 一个语言,`zh_cn`/`ja_jp` 会丢失。要保留原有语言,需要把它们连同新值一起重新传入。
- **指令名本身不可改**API 限制):`+slash-command-update` 不支持修改 `command` 字段。要"重命名",必须走 [+slash-command-delete](lark-application-slash-command-delete.md) + [+slash-command-create](lark-application-slash-command-create.md)(详见 [SKILL.md 关键语义](../SKILL.md#关键语义)),会产生一个全新的 `command_id`
## 返回值
```json
{
"ok": true,
"identity": "bot",
"data": {
"command_id": "7123456789012345678",
"command": "greet",
"description": { "default_value": "new text", "i18n": { "en_us": "hi" } },
"icon": { "icon_key": "skill_outlined" },
"action": "updated"
}
}
```
## 典型用法
```bash
# 按名字更新描述CLI 内部先 list 解析 command_id 再 PATCH
lark-cli application +slash-command-update --command greet --description "new text" --as bot
# 只改图标,不动描述
lark-cli application +slash-command-update --command-id 7123456789012345678 --icon-key chat_outlined --as bot
# 同时替换默认描述和多语言描述(记得带上所有要保留的语言)
lark-cli application +slash-command-update --command greet \
--description "say hi" --description-i18n zh_cn=你好 --description-i18n en_us=hi --as bot
```
## 错误与恢复
| 场景 | 识别 | 处理 |
|---|---|---|
| `--description-i18n` 未伴随 `--description` | 校验期报错,提示两者必须同传 | 补上 `--description`(哪怕值和原来一样),因为 PATCH 会整体替换 description 对象,缺 `default_value` 会导致其被清空 |
| 按 `--command` 找不到指令 | 报错"未找到"hint 指向 list | 先跑 `+slash-command-list` 确认指令名拼写和是否存在 |
| `--icon-key` 非法 | 错误 `code=40000031` | 换合法 icon key |
| bot 缺少 `application:app_slash_command:write`(按名定位还需 `:read` | 错误信封携带 `console_url` | 引导用户去开发者后台开通并发版;不要对 bot 执行 `auth login` |
| user 缺少对应 scope | 提示缺失 scope | `lark-cli auth login --scope application:app_slash_command:write`(按名定位还需 `--scope application:app_slash_command:read` |
## 参考
- [lark-application](../SKILL.md) — skill 入口与路由
- [+slash-command-list](lark-application-slash-command-list.md)
- [+slash-command-delete](lark-application-slash-command-delete.md)

View File

@@ -44,8 +44,6 @@ SubAgent 插入 SVG。
</whiteboard>
```
如果 Mermaid 已在本地文件中,可写成 `<whiteboard type="mermaid" path="@diagram.mmd"></whiteboard>`CLI 会在写入前读取文件并展开为内联内容。
### 步骤 2B: SubAgent 使用 SVG 插入图表
主 Agent 启动 SubAgent让它用 `docs +create` / `docs +update` 插入:
@@ -58,8 +56,6 @@ SubAgent 插入 SVG。
</whiteboard>
```
如果 SVG 已在本地文件中,可写成 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>`PlantUML 文件同理使用 `<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`
Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南:
- doc token、插入位置标题 / block_id / command

View File

@@ -41,7 +41,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
- `<img>``<img href="https://..."/>` 上传网络图片
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`也可用本地文件简写 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>``<whiteboard type="mermaid" path="@flow.mmd"></whiteboard>``<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`CLI 会写入前展开为内联内容;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
- `<sheet>``<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
- `<task>``<task task-id="GUID"></task>`,必传 task-id任务 guid
- `<chat_card>``<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id

View File

@@ -79,7 +79,7 @@ metadata:
1. `+triage --from spam@x.com` → 列出 N 条结果
2. 展示:"将删除 N 封邮件(发件人 spam@x.com主题确认"
3. 用户确认后 → `+message-trash --message-ids ... --yes`
3. 用户确认后 → `*.batch_trash`
## 身份选择:优先使用 user 身份
@@ -96,14 +96,13 @@ metadata:
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
2. **浏览**`+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
3. **阅读**`+message` 只读单封邮件;已有多个 `message_id` 时用 `+messages` 批量读取,不要循环调用 `+message``+thread` 读整个会话
4. **整理**标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
5. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送
7. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
8. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op已内置 autofix普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
9. **确认投递**立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
10. **编辑草稿** `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
11. **已读回执**
4. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
5. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
7. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op已内置 autofix普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
8. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
9. **编辑草稿**`+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
10. **已读回执**
- **请求回执(写信侧)**`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
- **响应回执(拉信侧)**:拉信看到 `label_ids``READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
@@ -120,8 +119,6 @@ metadata:
- 查看发送邮件后的投递状态发送成功后查看邮件投递状态也覆盖发送拦截。ref: [lark-mail-send-status](references/lark-mail-send-status.md)
- 使用邮件模板:区分个人模板和静态 HTML 模板,发信类 shortcut 用 `--template-id` 套用模板。ref: [lark-mail-template](references/lark-mail-template.md)
- 撤回已发送邮件撤回邮件并查询异步撤回状态。ref: [lark-mail-recall](references/lark-mail-recall.md)
- 修改邮件标签/已读状态/文件夹:优先使用 `+message-modify`。ref: [`+message-modify`](references/lark-mail-message-modify.md)
- 软删除邮件:优先使用 `+message-trash`。ref: [`+message-trash`](references/lark-mail-message-trash.md)
- 收信规则创建、验证、删除自动处理收到邮件的规则。ref: [lark-mail-rules](references/lark-mail-rules.md)
- 分享邮件到 IM分享邮件或会话到群聊、个人会话。ref: [lark-mail-share-to-chat](references/lark-mail-share-to-chat.md)
- 发送日程邀请邮件:在邮件中嵌入 `text/calendar` 日程邀请。ref: [lark-mail-calendar-invite](references/lark-mail-calendar-invite.md)
@@ -195,7 +192,7 @@ lark-cli mail +messages --message-ids <id1>,<id2>,<id3> --html=false
## 原生 API 调用规则
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`调用步骤以本节为准;资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
### Step 1 — 用 `-h` 确定要调用的 API必须不可跳过

View File

@@ -215,7 +215,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
```bash
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
```
## 编辑转发草稿

View File

@@ -1,48 +0,0 @@
# mail +message-modify
`mail +message-modify` is the preferred shortcut for changing labels, read-state labels, or folder placement on existing messages.
Use it instead of raw `user_mailbox.messages batch_modify` when the operation targets concrete `message_id` values from `+triage`, `+message`, or `+messages`.
## Common Commands
```bash
lark-cli mail +message-modify --message-ids <id1>,<id2> --add-label-ids unread
lark-cli mail +message-modify --message-ids <id> --remove-label-ids FLAGGED
lark-cli mail +message-modify --message-ids <id> --add-folder archive
lark-cli mail +message-modify --mailbox shared@example.com --message-ids <id> --add-folder folder_xxx
lark-cli mail +message-modify --message-ids <id> --add-label-ids custom_label_id --dry-run
```
## Flags
| Flag | Required | Notes |
| --- | --- | --- |
| `--mailbox` | No | Mailbox that owns the messages. Defaults to `me`. |
| `--message-ids` | Yes | `string_array`; supports comma-separated values and repeated flags. |
| `--add-label-ids` | No | Adds labels. System labels `unread`, `important`, `other`, `flagged` normalize to upper case. |
| `--remove-label-ids` | No | Removes labels. Cannot overlap with `--add-label-ids`. |
| `--add-folder` | No | Moves to one folder. `inbox`, `sent`, `spam`, `archive`, `archived` normalize to system folder IDs. |
`TRASH` is intentionally rejected by this shortcut. Use `mail +message-trash --message-ids <id> --yes` for soft deletion.
## Behavior
- Message IDs are locally validated, de-duplicated in first-seen order, and sent in batches of 20.
- Custom label IDs are checked with `labels.get`; custom folder IDs are checked with `folders.get`.
- If no label or folder operation is requested, the command succeeds locally, emits all message IDs as `success_message_ids`, and makes no POST request.
- Single batch POST failures mark every message in that batch with the same failure reason; later batches still run.
- JSON output is intentionally compact:
```json
{
"success_message_ids": ["id1"],
"failed_message_ids": [
{"message_id": "id2", "reason": "api error"}
]
}
```
## When Raw API Is Still Appropriate
Use raw `mail user_mailbox.messages batch_modify` only when you need a request shape that the shortcut intentionally does not expose, or when reproducing backend/API behavior exactly for diagnostics.

View File

@@ -1,41 +0,0 @@
# mail +message-trash
`mail +message-trash` is the preferred shortcut for soft-deleting existing messages.
Use it after obtaining real `message_id` values from `+triage`, `+message`, or `+messages`, and after the user has confirmed the deletion preview.
## Common Commands
```bash
lark-cli mail +message-trash --message-ids <id1>,<id2> --yes
lark-cli mail +message-trash --mailbox shared@example.com --message-ids <id> --yes
lark-cli mail +message-trash --message-ids <id1> --message-ids <id2> --dry-run
```
## Flags
| Flag | Required | Notes |
| --- | --- | --- |
| `--mailbox` | No | Mailbox that owns the messages. Defaults to `me`. |
| `--message-ids` | Yes | `string_array`; supports comma-separated values and repeated flags. |
| `--yes` | Yes for execution | Required by the high-risk write confirmation framework. |
## Behavior
- Message IDs are locally validated, de-duplicated in first-seen order, and sent in batches of 20.
- The shortcut calls `POST /open-apis/mail/v1/user_mailboxes/<mailbox>/messages/batch_trash` sequentially.
- Single batch POST failures mark every message in that batch with the same failure reason; later batches still run.
- JSON output is intentionally compact:
```json
{
"success_message_ids": ["id1"],
"failed_message_ids": [
{"message_id": "id2", "reason": "api error"}
]
}
```
## When Raw API Is Still Appropriate
Use raw `mail user_mailbox.messages batch_trash` only when reproducing backend/API behavior exactly for diagnostics. For normal soft deletion, prefer this shortcut because it handles validation, batching, compact output, and `--yes` confirmation consistently.

View File

@@ -203,7 +203,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
```bash
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
```
## 相关命令

View File

@@ -218,7 +218,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
```bash
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
```
## 编辑回复草稿

View File

@@ -15,7 +15,6 @@ metadata:
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|----------|----------|-----------------|
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| 本地生成或校验 SVG Slides / SVGlide 产物 | 先读 `references/svg-slides/README.md`,生成 local publish-ready bundle发布层另走 `+create-svglide` 后续计划 | `references/svg-slides/README.md``scripts/validate_svg_deck.mjs``scripts/svg_slides_bundle.mjs` |
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get``lark-slides-replace-pages.md``lark-slides-edit-workflows.md` |
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide``lark-slides-replace-slide.md` |
| 读取或分析已有 PPT | 解析 slides/wiki token回读全文或单页 XML保存 `xml_presentation_id``slide_id``revision_id` | `xml_presentations.get``xml_presentation.slide.get` |
@@ -30,8 +29,6 @@ metadata:
**CRITICAL — 生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
**CRITICAL — SVG Slides / SVGlide 与当前 XML/SXSD 工作流是不同协议。两者都使用 960x540 画布,但 SVG Slides 使用 `viewBox="0 0 960 540"` 和 `slide:*` SVG 语义XML/SXSD 使用 SML XML。处理 SVG Slides 生成或校验时,先读 [`references/svg-slides/README.md`](references/svg-slides/README.md),不要把 SVG 规则写进 `xml-schema-quick-ref.md`。**
**CRITICAL — PPT 生成与模板编辑硬约束PPT 的尺寸是 960x540确保主体内容在页面边界内。多用生图辅助搜图必须要图文并茂。不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。生成背景图时必须在 prompt 中明确要求不要出现任何文字。用户指定 PPT 模板时,用 lark-drive 技能导入成 lark slides回读理解每页版式后直接在该 slides 上编辑,可以填改文字和图片、按需增删模板页,必须严格沿用原版式和字体,只改内容不做设计,完成后回读并微调,凝练文字或缩减字号消除文字溢出,调整 shape 顺序或位置避免文字遮挡。**
**CRITICAL — 新建演示文稿或大幅改写页面时MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**

View File

@@ -1,160 +0,0 @@
# create-svglide boundary study
## Goal
Use `slides +create` as the design constraint sample for `slides +create-svglide`.
The central rule is:
```text
slides +create is a thin publisher for already-authored slide XML.
slides +create-svglide should be a thin publisher for already-authored SVGlide artifacts.
```
This document is evidence-first. It separates what the existing shortcut actually does from the broader generation and validation work described by the `lark-slides` skill.
## Source Surface
| Area | Files | Why it matters |
| --- | --- | --- |
| Go shortcut implementation | `shortcuts/slides/slides_create.go`, `shortcuts/slides/helpers.go`, `shortcuts/slides/slides_media_upload.go`, `shortcuts/slides/shortcuts.go` | Shows the real runtime boundary of `slides +create`. |
| Unit tests | `shortcuts/slides/slides_create_test.go` | Shows behavior that must not drift silently. |
| E2E proof | `tests/cli_e2e/slides/slides_create_workflow_test.go`, `tests/cli_e2e/slides/coverage.md` | Shows what is proven outside the shortcut body. |
| Skill and references | `skills/lark-slides/SKILL.md`, `skills/lark-slides/references/lark-slides-create.md`, `xml-schema-quick-ref.md`, `validation-checklist.md`, `troubleshooting.md` | Shows which work belongs to agent guidance or scripts instead of Go shortcut code. |
## `slides +create` Responsibility Matrix
| Responsibility | Evidence | Boundary meaning |
| --- | --- | --- |
| Register a write shortcut named `slides +create` for user and bot auth | `shortcuts/slides/slides_create.go:24-43`, `shortcuts/slides/shortcuts.go:8-17` | The command is a shortcut wrapper, not a general slide-generation subsystem. |
| Build a minimal presentation XML shell | `shortcuts/slides/slides_create.go:224-241` | The shortcut creates only the deck container: title plus 960x540 presentation metadata. |
| Create the online XML presentation | `shortcuts/slides/slides_create.go:125-148` | The first real API call is presentation creation. |
| Accept optional `--slides` as a JSON array of `<slide>` XML strings | `shortcuts/slides/slides_create.go:40-52` | Page content is supplied by the caller as final XML strings. |
| Enforce a maximum of 10 inline slide XML strings | `shortcuts/slides/slides_create.go:50-52` | Larger decks must use the lower-level page-create API after container creation. |
| Detect local image placeholders in submitted XML | `shortcuts/slides/helpers.go:113-153` | The shortcut only interprets one small XML convention: `<img src=\"@path\">`. |
| Validate placeholder files before creating the presentation | `shortcuts/slides/slides_create.go:53-67` | Avoids creating an orphan deck for missing/oversized local images. |
| Upload placeholder images and replace them with file tokens | `shortcuts/slides/slides_create.go:163-177`, `shortcuts/slides/slides_media_upload.go:119-138`, `shortcuts/slides/helpers.go:283-309` | Image upload is helper orchestration, not content generation. |
| Submit each supplied slide XML string to the page-create API | `shortcuts/slides/slides_create.go:179-200` | The shortcut forwards caller-authored XML to the backend. |
| Report partial progress when page creation fails | `shortcuts/slides/slides_create.go:194-196`, `shortcuts/slides/slides_create_test.go:354-420` | It does not roll back; it tells the caller where to resume. |
| Output machine-readable creation results | `shortcuts/slides/slides_create.go:150-219` | The output is an API orchestration receipt. |
| Optionally attempt bot-created deck permission grant | `shortcuts/slides/slides_create.go:215-217`, `shortcuts/slides/slides_create_test.go:66-198` | Bot grant is post-create convenience, not part of content semantics. |
## Behavior Locks From Tests
| Behavior | Evidence | Boundary meaning |
| --- | --- | --- |
| User-mode create returns `xml_presentation_id`, `title`, and `url`, without `permission_grant` | `shortcuts/slides/slides_create_test.go:23-63` | User-mode output is a creation receipt, not a validation report. |
| Missing `--title` becomes `Untitled` in dry-run and execution | `shortcuts/slides/slides_create_test.go:200-253` | Title normalization is a small deterministic convenience that belongs in the shortcut. |
| `--slides` creates the deck first, then adds pages, then returns `slide_ids` and `slides_added` | `shortcuts/slides/slides_create_test.go:285-352` | Page creation is orchestration after container creation. |
| `--slides []` behaves like no slides | `shortcuts/slides/slides_create_test.go:532-570` | Empty artifact lists should be explicit no-op additions, not special generators. |
| Invalid JSON and more than 10 inline slides fail validation with `Param == "--slides"` | `shortcuts/slides/slides_create_test.go:422-505` | Input-contract errors should be structured and routeable. |
| Missing `xml_presentation_id` from the backend fails | `shortcuts/slides/slides_create_test.go:255-283` | Creation success requires a usable resource id. |
| URL fallback is local and does not call Drive metas or batch query | `shortcuts/slides/slides_create_test.go:649-688` | Avoid adding extra API dependencies when a local receipt can be built. |
| Image placeholders are uploaded once per unique path and rewritten before page creation | `shortcuts/slides/slides_create_test.go:751-854` | Asset handling is publish-boundary plumbing, not design work. |
| Missing local placeholder files fail before any API call | `shortcuts/slides/slides_create_test.go:856-877` | Local artifact existence is a publish-blocking precondition. |
| Dry-run exposes the API plan shape and placeholder ids | `shortcuts/slides/slides_create_test.go:572-602`, `shortcuts/slides/slides_create_test.go:879-900` | Dry-run should describe orchestration, not execute validation-heavy side effects. |
| Readback is proven by E2E as a separate follow-up call | `tests/cli_e2e/slides/slides_create_workflow_test.go:32-85`, `tests/cli_e2e/slides/coverage.md:9-16` | Readback is evidence for tests and delivery, not default `Execute` behavior. |
| Bot permission grant is non-fatal and tri-state: granted, skipped, or failed | `shortcuts/slides/slides_create_test.go:66-198` | Convenience post-actions must not turn creation success into failure. |
## `slides +create` Does Not Do
| Non-responsibility | Evidence | Design implication for `+create-svglide` |
| --- | --- | --- |
| Does not generate slide XML from a prompt | `shortcuts/slides/slides_create.go:40-43`, `shortcuts/slides/slides_create.go:158-205` | `+create-svglide` must not become `--topic -> deck`. |
| Does not deeply validate slide XML semantics | `shortcuts/slides/slides_create.go:44-69` | Only minimal publish-blocking validation belongs in the shortcut. |
| Does not preview or repair layout | `shortcuts/slides/slides_create.go:125-221` | Preview and repair belong in skill/scripts or a runner before publish. |
| Does not run readback inside `Execute` | `shortcuts/slides/slides_create.go:125-221`, `tests/cli_e2e/slides/slides_create_workflow_test.go:68-85` | Readback is a test/proof step, not default shortcut behavior. |
| Does not guarantee atomic creation | `shortcuts/slides/slides_create.go:194-196`, `shortcuts/slides/slides_create_test.go:354-420` | New publish shortcuts should provide recovery context, not hide partial success. |
| Does not handle more than 10 inline pages | `shortcuts/slides/slides_create.go:18-21`, `shortcuts/slides/slides_create_test.go:441-465` | Bound the first version instead of building a complex batch manager. |
| Does not own visual quality | `skills/lark-slides/SKILL.md:91-127`, `skills/lark-slides/SKILL.md:153-160` | Visual quality gates belong before the shortcut consumes artifacts. |
## Counterexamples
| Tempting requirement | Why it looks tempting | What `slides +create` teaches |
| --- | --- | --- |
| Add readback by default | E2E uses readback to prove persistence. | E2E calls the get API after creation; `Execute` itself stops after outputting the create result. Keep readback optional or outside MVP. |
| Validate every page semantically before calling the backend | Better local errors sound useful. | `+create` only validates JSON shape, count, and local placeholder files; backend owns XML parsing. For SVGlide, only validate fields required to route and publish. |
| Run preview lint and auto-repair | SVGlide has preview tooling. | `+create` does not make layout judgments. Preview lint and repair must remain pre-publish tooling. |
| Accept a prompt and generate the deck | Higher-level UX is attractive. | `+create` consumes final submission artifacts. A prompt-to-deck runner would be a different command or script layer. |
| Hide partial failures by retrying/rebuilding automatically | It feels friendlier. | `+create` surfaces partial progress instead. Recovery should be explicit and resumable. |
## `slides +create-svglide` Allowed Extra Responsibilities
`+create-svglide` can be slightly heavier than `+create` only where SVGlide's input contract requires it. The extra work must still be publish-boundary work, not generation work.
| Extra responsibility | Allowed because | Limit |
| --- | --- | --- |
| Read a SVGlide manifest or run directory | Unlike `--slides`, SVGlide artifacts are file-based. | Normalize to one manifest model immediately; do not infer design intent. |
| Validate manifest schema and page order | Needed to know what to publish. | Validate shape and required fields only. |
| Validate page file existence and path safety | Equivalent to `+create` validating `@path` placeholders. | Do not inspect aesthetics or text quality. |
| Validate publish-required SVGlide fields | The target publish API or parser may require namespace, contract/version, dimensions, or roles before it can accept a page. | Check only required markers; do not rewrite ordinary SVG into protocol SVG in the shortcut. |
| Upload declared local assets | Equivalent to `+create` uploading `@path` images. | Upload and token replacement only; no asset search or generation. |
| Submit SVGlide pages to the target publish API | Equivalent to `+create` submitting each slide XML string. | Keep output and partial-progress behavior explicit; do not assume the CLI must convert to XML if the backend can consume SVGlide directly. |
## `slides +create-svglide` Must Not Own
| Responsibility | Owner |
| --- | --- |
| Research, outline, design brief, slide content planning | `skills/lark-slides` guidance and external runner/scripts |
| SVG authoring | Agent or runner before publish |
| Preview rendering, preview lint, and repair loop | Skill scripts or runner before publish |
| Visual quality scoring | Skill/scripts/quality gate, not shortcut `Execute` |
| Readback as default success criterion | E2E or optional verification flag |
| PPE/Whistle routing as core naming | Environment/profile layer only |
## MVP Scope
Recommended first implementation:
```bash
lark-cli slides +create-svglide --manifest ./svglide-run/manifest.json --as user
```
MVP behavior:
1. Parse manifest.
2. Validate required fields, page order, file existence, path safety, dimensions, and minimal SVGlide contract markers.
3. Create presentation shell.
4. Upload local assets declared in the manifest.
5. Submit pages to the backend.
6. Output `xml_presentation_id`, `url`, `page_ids` or `slide_ids`, uploaded asset count, and partial-progress context on failure.
MVP exclusions:
1. No prompt input.
2. No generation stages.
3. No preview repair.
4. No default readback.
5. No PPE-specific command name, directory name, or type name.
## Test Boundary For `+create-svglide`
The first test suite should mirror the shape of `slides +create` tests instead of proving the whole SVGlide generation pipeline.
| Test area | Required proof |
| --- | --- |
| Input contract | Invalid manifest, missing page file, unsafe path, and unsupported page count fail with structured params. |
| Dry-run | Shows create, asset upload, and page publish steps with placeholder presentation id and deterministic step labels. |
| Asset handling | Duplicate local assets upload once; page payloads reference uploaded tokens before publish. |
| Partial failure | If the deck exists and page N fails, error includes presentation id, failed page index, and successfully published page count. |
| Bot grant | Inherit user/bot output behavior from `slides +create`; grant failure is reported but not promoted to create failure. |
| E2E | Create/publish result is asserted first; optional readback is a separate proof step unless the command explicitly adds a `--readback` contract. |
## Team Finding
The effective research team for this boundary is:
| Role | Scope |
| --- | --- |
| Code Reader | Extract runtime responsibilities from Go implementation. |
| Test Reader | Extract behavior locks and prove what is outside `Execute`. |
| Skill Boundary Reader | Separate agent/script responsibilities from shortcut responsibilities. |
| Architect/Skeptic | Reject over-broad scope and map only proven `+create` patterns into `+create-svglide`. |
The team's proof standard is not "we read the files"; it is:
```text
Every proposed +create-svglide responsibility must map to either:
1. an existing +create responsibility, or
2. a minimal extra responsibility forced by SVGlide's artifact input shape.
```

View File

@@ -1,160 +0,0 @@
# create-svglide 边界研究
## 目标
`slides +create` 作为 `slides +create-svglide` 的设计约束样本。
核心规则是:
```text
slides +create 是已经写好的 slide XML 的薄发布器。
slides +create-svglide 也应该是已经生成好的 SVGlide 产物的薄发布器。
```
本文档以证据为先,区分现有 shortcut 真实承担的职责,以及 `lark-slides` skill 中描述的更宽泛的生成与验证工作。
## 研究范围
| 范围 | 文件 | 作用 |
| --- | --- | --- |
| Go shortcut 实现 | `shortcuts/slides/slides_create.go``shortcuts/slides/helpers.go``shortcuts/slides/slides_media_upload.go``shortcuts/slides/shortcuts.go` | 确认 `slides +create` 的真实运行时边界。 |
| 单元测试 | `shortcuts/slides/slides_create_test.go` | 确认可被测试锁定、不能随意漂移的行为。 |
| E2E 证明 | `tests/cli_e2e/slides/slides_create_workflow_test.go``tests/cli_e2e/slides/coverage.md` | 确认哪些证明发生在 shortcut 外部。 |
| Skill 与 references | `skills/lark-slides/SKILL.md``skills/lark-slides/references/lark-slides-create.md``xml-schema-quick-ref.md``validation-checklist.md``troubleshooting.md` | 确认哪些工作属于 agent 指导或脚本,而不是 Go shortcut。 |
## `slides +create` 职责矩阵
| 职责 | 证据 | 边界含义 |
| --- | --- | --- |
| 注册一个名为 `slides +create` 的写操作 shortcut支持 user 和 bot 身份 | `shortcuts/slides/slides_create.go:24-43``shortcuts/slides/shortcuts.go:8-17` | 这是 shortcut 封装,不是通用幻灯片生成系统。 |
| 构造最小 presentation XML 外壳 | `shortcuts/slides/slides_create.go:224-241` | shortcut 只创建 deck 容器:标题和 960x540 presentation 元数据。 |
| 创建在线 XML presentation | `shortcuts/slides/slides_create.go:125-148` | 第一个真实 API 调用是创建 presentation。 |
| 接收可选 `--slides`,格式为 `<slide>` XML 字符串 JSON 数组 | `shortcuts/slides/slides_create.go:40-52` | 页面内容由调用方以最终 XML 字符串形式提供。 |
| 限制一次内联提交最多 10 页 slide XML | `shortcuts/slides/slides_create.go:50-52` | 更大的 deck 应先创建容器,再用底层 page-create API 追加页面。 |
| 检测已提交 XML 里的本地图片占位符 | `shortcuts/slides/helpers.go:113-153` | shortcut 只理解一个很窄的 XML 约定:`<img src="@path">`。 |
| 创建 presentation 前校验占位符文件 | `shortcuts/slides/slides_create.go:53-67` | 避免因为本地图片缺失或超限而创建孤儿 deck。 |
| 上传占位符图片并替换为 file token | `shortcuts/slides/slides_create.go:163-177``shortcuts/slides/slides_media_upload.go:119-138``shortcuts/slides/helpers.go:283-309` | 图片上传是发布边界上的 helper 编排,不是内容生成。 |
| 把每个调用方提供的 slide XML 字符串提交给 page-create API | `shortcuts/slides/slides_create.go:179-200` | shortcut 把调用方写好的 XML 转交给后端。 |
| 页面创建失败时报告部分进度 | `shortcuts/slides/slides_create.go:194-196``shortcuts/slides/slides_create_test.go:354-420` | 不回滚;告诉调用方从哪里恢复。 |
| 输出机器可读的创建结果 | `shortcuts/slides/slides_create.go:150-219` | 输出是 API 编排回执。 |
| bot 创建 deck 后可选尝试给当前用户授权 | `shortcuts/slides/slides_create.go:215-217``shortcuts/slides/slides_create_test.go:66-198` | bot grant 是创建后的便利动作,不属于内容语义。 |
## 测试锁定的行为
| 行为 | 证据 | 边界含义 |
| --- | --- | --- |
| user 模式创建返回 `xml_presentation_id``title``url`,不返回 `permission_grant` | `shortcuts/slides/slides_create_test.go:23-63` | user 模式输出是创建回执,不是验证报告。 |
| 省略 `--title`dry-run 和真实执行都归一为 `Untitled` | `shortcuts/slides/slides_create_test.go:200-253` | 标题归一是适合放在 shortcut 内的小型确定性便利。 |
| `--slides` 会先创建 deck再添加页面最后返回 `slide_ids``slides_added` | `shortcuts/slides/slides_create_test.go:285-352` | 页面创建是容器创建后的编排。 |
| `--slides []` 等价于不传 slides | `shortcuts/slides/slides_create_test.go:532-570` | 空产物列表应是明确的无追加操作,不应触发特殊生成逻辑。 |
| 非法 JSON 和超过 10 个内联 slides 会以 `Param == "--slides"` 的校验错误失败 | `shortcuts/slides/slides_create_test.go:422-505` | 输入契约错误必须结构化,便于调用方路由处理。 |
| 后端缺少 `xml_presentation_id` 时失败 | `shortcuts/slides/slides_create_test.go:255-283` | 创建成功必须拿到可用资源 id。 |
| URL fallback 在本地构造,不调用 Drive metas 或 batch query | `shortcuts/slides/slides_create_test.go:649-688` | 能用本地回执构造的内容,不应增加额外 API 依赖。 |
| 图片占位符按唯一路径上传一次,并在页面创建前完成替换 | `shortcuts/slides/slides_create_test.go:751-854` | 素材处理是发布边界的管道能力,不是设计工作。 |
| 本地占位符文件缺失时,在任何 API 调用前失败 | `shortcuts/slides/slides_create_test.go:856-877` | 本地产物存在性是发布前置条件。 |
| Dry-run 暴露 API 计划形状和占位 id | `shortcuts/slides/slides_create_test.go:572-602``shortcuts/slides/slides_create_test.go:879-900` | Dry-run 应描述编排计划,而不是执行重型校验副作用。 |
| Readback 在 E2E 中作为单独 follow-up 调用证明 | `tests/cli_e2e/slides/slides_create_workflow_test.go:32-85``tests/cli_e2e/slides/coverage.md:9-16` | Readback 是测试和交付证据,不是默认 `Execute` 行为。 |
| Bot 授权是非致命三态granted、skipped、failed | `shortcuts/slides/slides_create_test.go:66-198` | 便利性的后置动作不应把创建成功升级成失败。 |
## `slides +create` 不负责的事情
| 非职责 | 证据 | 对 `+create-svglide` 的设计含义 |
| --- | --- | --- |
| 不从 prompt 生成 slide XML | `shortcuts/slides/slides_create.go:40-43``shortcuts/slides/slides_create.go:158-205` | `+create-svglide` 不能变成 `--topic -> deck`。 |
| 不深度校验 slide XML 语义 | `shortcuts/slides/slides_create.go:44-69` | shortcut 内只应放发布阻塞级的最小校验。 |
| 不预览或修复布局 | `shortcuts/slides/slides_create.go:125-221` | preview 和 repair 属于发布前的 skill/scripts 或 runner。 |
| 不在 `Execute` 内做 readback | `shortcuts/slides/slides_create.go:125-221``tests/cli_e2e/slides/slides_create_workflow_test.go:68-85` | Readback 是测试/证明步骤,不是默认 shortcut 行为。 |
| 不保证原子创建 | `shortcuts/slides/slides_create.go:194-196``shortcuts/slides/slides_create_test.go:354-420` | 新发布类 shortcut 应提供恢复上下文,而不是隐藏部分成功。 |
| 不处理超过 10 个内联页面 | `shortcuts/slides/slides_create.go:18-21``shortcuts/slides/slides_create_test.go:441-465` | 第一版应设边界,而不是一开始实现复杂批处理器。 |
| 不负责视觉质量 | `skills/lark-slides/SKILL.md:91-127``skills/lark-slides/SKILL.md:153-160` | 视觉质量门禁应发生在 shortcut 消费产物之前。 |
## 反例
| 诱人的需求 | 为什么看起来合理 | `slides +create` 给出的约束 |
| --- | --- | --- |
| 默认加入 readback | E2E 用 readback 证明持久化。 | E2E 是创建后另调 get API`Execute` 输出创建结果后即结束。Readback 应保持可选或放在 MVP 外。 |
| 调后端前语义校验每一页 | 本地错误更友好。 | `+create` 只校验 JSON 形状、页数、本地占位符文件XML 解析由后端负责。SVGlide 也只校验发布路由必需字段。 |
| 运行 preview lint 并自动 repair | SVGlide 有 preview 工具链。 | `+create` 不做布局判断。Preview lint 和 repair 应留在发布前工具链。 |
| 接受 prompt 并生成 deck | 高层 UX 很吸引人。 | `+create` 消费最终提交物。Prompt-to-deck runner 应是另一层命令或脚本。 |
| 通过自动重试/重建隐藏部分失败 | 看起来更友好。 | `+create` 暴露部分进度。恢复应该显式、可续跑。 |
## `slides +create-svglide` 允许新增的职责
`+create-svglide` 只能在 SVGlide 输入契约强制要求的地方比 `+create` 稍重。新增工作仍必须属于发布边界,而不是生成边界。
| 额外职责 | 允许原因 | 限制 |
| --- | --- | --- |
| 读取 SVGlide manifest 或 run directory | 与 `--slides` 不同SVGlide 产物是文件型产物。 | 立即归一化为一个 manifest 模型;不要推断设计意图。 |
| 校验 manifest schema 和页序 | 需要知道要发布什么。 | 只校验形状和必填字段。 |
| 校验页面文件存在性和路径安全 | 等价于 `+create` 校验 `@path` 占位符。 | 不检查美观度或文本质量。 |
| 校验发布必需的 SVGlide 字段 | 目标发布 API 或 parser 可能需要 namespace、contract/version、尺寸或 role 才能接收页面。 | 只检查必需标记;不要在 shortcut 中把普通 SVG 重写成协议 SVG。 |
| 上传声明的本地素材 | 等价于 `+create` 上传 `@path` 图片。 | 只做上传和 token 替换;不做素材搜索或生成。 |
| 把 SVGlide 页面提交给目标发布 API | 等价于 `+create` 提交每个 slide XML 字符串。 | 保持输出和部分进度语义明确;如果后端能直接消费 SVGlide不要假设 CLI 必须转 XML。 |
## `slides +create-svglide` 必须不拥有的职责
| 职责 | 所属边界 |
| --- | --- |
| research、outline、design brief、slide content planning | `skills/lark-slides` 指导和外部 runner/scripts |
| SVG authoring | agent 或 runner在发布前完成 |
| preview rendering、preview lint、repair loop | skill scripts 或 runner在发布前完成 |
| 视觉质量评分 | skill/scripts/quality gate不属于 shortcut `Execute` |
| readback 作为默认成功标准 | E2E 或可选验证 flag |
| PPE/Whistle 路由进入核心命名 | 只能属于环境/profile 层 |
## MVP 范围
推荐第一版实现:
```bash
lark-cli slides +create-svglide --manifest ./svglide-run/manifest.json --as user
```
MVP 行为:
1. 解析 manifest。
2. 校验必填字段、页序、文件存在性、路径安全、尺寸、最小 SVGlide contract 标记。
3. 创建 presentation 外壳。
4. 上传 manifest 声明的本地素材。
5. 把页面提交给后端。
6. 输出 `xml_presentation_id``url``page_ids``slide_ids`、上传素材数量,以及失败时的部分进度上下文。
MVP 排除项:
1. 不接受 prompt 输入。
2. 不包含生成阶段。
3. 不做 preview repair。
4. 不默认 readback。
5. 不在命令名、目录名或类型名中包含 PPE。
## `+create-svglide` 测试边界
第一版测试应镜像 `slides +create` 的测试形状,而不是证明完整 SVGlide 生成流水线。
| 测试范围 | 必须证明 |
| --- | --- |
| 输入契约 | 非法 manifest、缺失页面文件、不安全路径、不支持的页数以结构化 param 失败。 |
| Dry-run | 展示 create、asset upload、page publish 步骤,包含占位 presentation id 和确定性的 step label。 |
| 素材处理 | 重复本地素材只上传一次;页面 payload 在发布前引用已上传 token。 |
| 部分失败 | deck 已存在但第 N 页失败时,错误包含 presentation id、失败页序号、已成功发布页数。 |
| Bot grant | 继承 `slides +create` 的 user/bot 输出行为grant 失败不升级成 create 失败。 |
| E2E | 先断言 create/publish 结果;可选 readback 作为单独证明步骤,除非命令显式加入 `--readback` 契约。 |
## Team 结论
适合研究这个边界的 team 是:
| 角色 | 范围 |
| --- | --- |
| Code Reader | 从 Go 实现中抽取运行时职责。 |
| Test Reader | 抽取行为锁定点,并证明哪些行为不在 `Execute` 内。 |
| Skill Boundary Reader | 区分 agent/script 职责和 shortcut 职责。 |
| Architect/Skeptic | 拒绝过宽 scope只把已被 `+create` 证明的模式映射到 `+create-svglide`。 |
这个 team 的证明标准不是“读过文件”,而是:
```text
每一个 proposed +create-svglide 职责都必须映射到:
1. 一个已有 +create 职责;或
2. 一个由 SVGlide 产物输入形态强制产生的最小额外职责。
```

View File

@@ -1,41 +0,0 @@
# SVG Slides Local Generation
This reference family is for local SVG Slides generation and validation.
It is not the Lark Slides XML/SXSD workflow and it is not the publish shortcut. Use it to produce a local publish-ready bundle that a future `slides +create-svglide` publisher can consume.
## Read Routes
| Task | Read first | Then read |
|---|---|---|
| Generate a new SVG deck | `workflow.md` | `design-brief.md`, `protocol.md`, `authoring-rules.md`, `visual-design.md`, `validation.md` |
| Repair protocol failures | `validation.md` | `protocol.md`, `authoring-rules.md` |
| Improve visual quality | `visual-design.md` | `design-brief.md`, `workflow.md` |
| Use charts | `chart-workflow.md` | `protocol.md`, `validation.md` |
| Continue an existing deck | `editing-existing-decks.md` | `workflow.md`, `protocol.md` |
| Audit provenance | `source/split-manifest.json` | `source/full.debranded.md` |
## Boundary
Generation and validation produce a local publish-ready bundle.
A future SVG Slides publisher consumes this bundle. That publishing path is intentionally outside this reference family.
A local bundle may set `publish_ready=true`; it must not claim it is published.
## Canvas Decision
This CLI adaptation uses a 960x540 SVG canvas: `viewBox="0 0 960 540"`.
The source snapshot is preserved for provenance and coverage audit. Where the source describes a different default canvas, the CLI adaptation layer intentionally normalizes generated SVG Slides to 960x540.
## Required Local Gates
1. `node skills/lark-slides/scripts/validate_svg_deck.mjs <deck-dir> --json`
2. `node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title "<title>"`
3. Browser text-boundary check when Playwright is available.
## Source Coverage
- Covers manifest sections: title
- Coverage mode: routing entry; source text is preserved in `source/full.debranded.md`, while this file points workers to the coverage-preserving split docs.

View File

@@ -1,79 +0,0 @@
# SVG Slides Authoring Rules
## Required Authoring Pattern
Write complete slide files. A slide edit is not a fragment, patch, or HTML page.
Use this order:
1. Optional `<defs>`.
2. One background as the first rendered child.
3. Top-level shapes, images, charts, groups, and optional notes.
Every rendered element that the slide engine must understand needs the appropriate `slide:role`. Do not depend on generic browser rendering when the protocol has an explicit semantic role.
## Forbidden Constructs
Do not use:
- `<style>` blocks;
- `class=`;
- `<div>` or `<section>` wrappers in text `foreignObject`;
- bare text under `foreignObject`;
- SVG `<text>`;
- SVG `<marker>`;
- hex colors;
- named colors;
- `none` for `fill` or `stroke`;
- role-less primitives in the rendered slide body.
## Text Boxes
Use plain text boxes for text-only content:
```xml
<foreignObject slide:role="shape" slide:shape-type="text" x="96" y="96" width="640" height="120" style="font-size:32px;color:rgba(15,23,42,1);line-height:1.2">
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:32px;color:rgba(15,23,42,1)">Main argument</p>
</foreignObject>
```
Use shape-with-text only when the object is truly one styled box with one text block. If the card has multiple parts, use `<g slide:role="group">`.
## Image Elements
Use images when there is a real image asset or generated visual. The SVG file references the local asset path.
Informational images such as charts, diagrams, screenshots, and infographics must preserve their original ratio. Decorative images may be composed more freely, but should still fit the resolved design brief.
Unless the user explicitly requests no images, cover, section divider, and closing pages should use a large hero image or generated visual. Full-bleed image backgrounds use `<image slide:role="background">`; large non-background images use `<image slide:role="image" slide:shape-type="image">`.
When text sits on an image, place a semi-transparent `<rect slide:role="shape" slide:shape-type="shape">` scrim or a solid text zone after the image and before the text. Do not use SVG `<mask>` for this readability layer.
Generated cover, section divider, or closing images must not contain baked-in text. Render text as slide text on top of the image.
## Chart Embeds
A chart is an external SVG sidecar referenced by:
```xml
<rect slide:role="chart" href="resources/charts/example.svg" x="120" y="180" width="800" height="500"/>
```
Do not hand-draw a chart from primitives when the slide's point depends on a real quantitative data series. Use the chart workflow to generate the sidecar first.
## Custom Paths
Custom paths require accurate bounds. `slide:width` and `slide:height` describe the real extent of the path data, not the full canvas.
If a path has not been normalized, measure its bounding box before writing the final slide. Oversized path boxes make selection, hit testing, and layout misleading.
## Grouped Cards
Use `<g slide:role="group">` for a multi-element cluster: card background, badge, icon, title, body, chart, image, or connector. Each child still carries its own role.
Do not use `<g slide:role="shape">` as a generic container. It is only for the shape-with-text form.
## Source Coverage
- Covers manifest sections: slides_edit_tool, image_usage, compute_custom_shape_bbox_tool
- Coverage mode: preserve authoring constraints and tool semantics that affect generated SVG structure.

View File

@@ -1,64 +0,0 @@
# SVG Slides Chart Workflow
## When To Use A Chart
Use a chart when the slide's point depends on a real quantitative series:
- trend;
- multi-category comparison;
- part-to-whole split;
- distribution;
- ranking;
- two-dimensional positioning.
For single numbers or trivial two-bucket comparisons, prefer a large text callout unless the comparison needs a chart to be understood.
## When Not To Use A Chart
Do not generate a chart for vague, unsourced, decorative, or invented data. Do not choose a chart type because the raw data happens to look compatible; choose it because the takeaway requires that representation.
When in doubt, a sorted bar chart is safer than a pie or doughnut.
## Chart Sidecar Contract
A chart is generated as an SVG sidecar before slide authoring and embedded by reference.
The generation request must decide the takeaway first. The takeaway must be faithful to the data and short enough to guide chart design.
The request must include:
- chart type;
- JSON data matching that type;
- style matching the destination slide;
- actual on-slide width and height;
- output path under `resources/charts/`.
The declared chart width should match the embed width. Chart internals derive text size from width. Do not declare a wide chart and embed it in a narrow slot.
## Embed Contract
Embed a generated chart with:
```xml
<rect slide:role="chart" href="resources/charts/name.svg" x="120" y="180" width="800" height="500"/>
```
The embed width and height must match the chart sidecar's intended display size. Keep a 16:10-ish chart area when possible to avoid letterboxing.
One chart should carry one distinct insight. Pair charts with short callouts or labels, and vary chart composition across the deck.
## Validation Notes
Static deck validation confirms the chart placeholder shape, not the correctness of the chart sidecar data. Review chart sidecars for:
- source-backed data;
- truthful takeaway;
- readable labels;
- width at or above the practical floor;
- matching palette;
- intact `href`.
## Source Coverage
- Covers manifest sections: generate_svg_chart_tool
- Coverage mode: preserve chart generation, data contract, rendering constraints, and validation expectations.

View File

@@ -1,80 +0,0 @@
# SVG Slides Design Brief
## Inputs
Resolve the design brief after these inputs are known:
- topic and goal;
- audience;
- delivery mode;
- language;
- page count when known;
- source material;
- user-fixed brand, color, or content constraints;
- one to three short visual-direction phrases.
Do not ask the user to choose tone, density, palette, or typography unless they volunteered hard constraints.
## Output Contract
The brief must produce:
- `narrative_spine`;
- `depth`;
- `tone`;
- `visual_system`.
These outputs govern outline, content density, wording, asset choices, typography, color, layout, and decoration.
## narrative_spine
`narrative_spine` defines the slide sequence discipline. It is the default source of order, sectioning, and narrative movement.
The user can override it by giving or editing an outline. After that point, the user outline wins.
## depth
`depth` decides altitude and density:
- how much context each slide carries;
- what to include and exclude;
- how many main points per slide;
- how source evidence should appear;
- whether a page should split instead of cram.
## tone
`tone` controls writing style and evidence posture. It should reflect the audience and delivery mode.
Presented decks can use shorter on-slide wording because the speaker carries context. Self-read decks need more complete explanatory text but still must avoid walls of text.
## visual_system
`visual_system` is the authority for look and feel. It should include:
- color logic;
- typography category and treatment;
- layout grammar;
- imagery or material direction;
- page-role imagery defaults for cover, section divider, and closing pages;
- decoration and motif rules;
- constraints to avoid.
Unless the user explicitly requests no images, `visual_system` must specify how cover, section divider, and closing pages use a high-impact hero image or generated visual. The brief should describe the imagery subject, treatment, crop attitude, and how foreground text stays readable.
Font mapping must preserve the same category and treatment. Do not swap serif and sans, ignore uppercase treatment, or pick generic fonts when the brief calls for a distinctive style.
## How It Drives Generation
Use the brief in this order:
1. Shape the outline from `narrative_spine`.
2. Size the content from `depth`.
3. Write titles and evidence from `tone`.
4. Build the deck-level style from `visual_system`.
5. Author each slide's layout from the content logic plus the visual system.
## Source Coverage
- Covers manifest sections: resolve_design_brief
- Coverage mode: preserve design brief inputs, output contract, and downstream influence on outline and page authoring.

View File

@@ -1,39 +0,0 @@
# SVG Slides Editing Existing Decks
## Continue Existing Deck
When the user asks to continue, edit, extend, or repair an existing uploaded deck, operate on the existing converted project instead of recreating from scratch.
Preserve every existing page unless the user asks to change it. A page with minimal content should remain minimal if that is what the source deck contained.
## Preserve Existing Pages
For text or layout changes, edit only the target slide files. Preserve styling by default. Restyle only when the user explicitly asks.
Preserve media, chart, video, and audio blocks verbatim when the request does not touch them.
## Add Or Delete Pages
Add pages through the organize workflow, then author the new standalone SVG pages.
Delete only pages the user asked to remove. Do not rerun the new-deck outline workflow over an existing deck; it can overwrite existing slide files and lose original pages.
## Template Reference Boundary
An uploaded reference can mean two different things:
- Continue or edit this deck: preserve and modify that deck.
- Create a new deck inspired by this reference: author fresh SVG using the normal create workflow.
Clarify when the user's wording does not identify which behavior they want.
## PPTX Conversion Boundary
Converted decks may contain imported chart placeholders or media. Preserve legacy chart references unless the user asks to update chart data, type, theme, or emphasis.
If a chart is resized materially, regenerate the chart sidecar with the new dimensions rather than only squeezing the existing placeholder.
## Source Coverage
- Covers manifest sections: slide_organize_tool, slides_convert_tool, slides_parse_template_tool
- Coverage mode: preserve existing-deck continuation, conversion, and template parsing boundaries without turning them into publish behavior.

View File

@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="chart_embed" viewBox="0 0 960 540">
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="56" width="680" height="72" style="font-size:36px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(17,24,39,1);font-weight:800;line-height:1.15;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
<h2 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:36px;line-height:1.15;color:rgba(17,24,39,1);letter-spacing:0px">Chart is a referenced sidecar</h2>
</foreignObject>
<rect slide:role="chart" href="resources/charts/example_bar.svg" x="80" y="160" width="560" height="350"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="690" y="190" width="190" height="118" style="font-size:19px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(55,65,81,1);font-weight:500;line-height:1.38;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:19px;line-height:1.38;color:rgba(55,65,81,1);letter-spacing:0px">The chart payload lives outside the slide and is referenced by href.</p>
</foreignObject>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="group_card" viewBox="0 0 960 540">
<defs>
<linearGradient id="card_grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="rgba(255,255,255,1)"/>
<stop offset="100%" stop-color="rgba(226,232,240,1)"/>
</linearGradient>
</defs>
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(241,245,249,1)"/>
<g slide:role="group" id="card_primary">
<rect slide:role="shape" slide:shape-type="round-rect" x="120" y="140" width="520" height="300" rx="24" ry="24" fill="url(#card_grad)" stroke="rgba(148,163,184,1)" stroke-width="1"/>
<circle slide:role="shape" slide:shape-type="circle" cx="180" cy="206" r="26" fill="rgba(37,99,235,1)"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="224" y="178" width="340" height="50" style="font-size:28px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(15,23,42,1);font-weight:800;line-height:1.2;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
<h2 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:28px;line-height:1.2;color:rgba(15,23,42,1);letter-spacing:0px">Grouped card</h2>
</foreignObject>
<foreignObject slide:role="shape" slide:shape-type="text" x="154" y="264" width="420" height="86" style="font-size:20px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(51,65,85,1);font-weight:500;line-height:1.38;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:20px;line-height:1.38;color:rgba(51,65,85,1);letter-spacing:0px">A card is a group; every visual child still carries its own slide role.</p>
</foreignObject>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="minimal_slide" viewBox="0 0 960 540">
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(248,250,252,1)"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="760" height="92" style="font-size:42px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(15,23,42,1);font-weight:800;line-height:1.12;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
<h1 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:42px;line-height:1.12;color:rgba(15,23,42,1);letter-spacing:0px">One protocol-compliant SVG slide</h1>
</foreignObject>
</svg>

Before

Width:  |  Height:  |  Size: 753 B

View File

@@ -1,94 +0,0 @@
# SVG Slides Protocol
## Canvas
- Each page is one standalone SVG file.
- Root must contain `xmlns="http://www.w3.org/2000/svg"`.
- Root must contain `xmlns:slide="https://slides.bytedance.com/ns"`.
- Root must contain `slide:role="slide"`.
- Root must contain an `id`.
- Root must contain `viewBox="0 0 960 540"`.
- Child coordinates are in viewBox units.
- Do not rely on HTML document behavior. SVG nodes use SVG semantics; XHTML appears only inside approved `foreignObject` children.
- This 960x540 canvas is the CLI adaptation target. The preserved source snapshot may mention other defaults, but generated local bundles must use 960x540.
## Background
- Exactly one rendered background is required.
- Optional `<defs>` may appear first.
- The first rendered child after optional `<defs>` must be a `<rect>` or `<image>` with `slide:role="background"`.
- Background must cover the full canvas.
- Gradient backgrounds must reference gradients declared in the same slide's `<defs>`.
- A full-bleed image background should be an `<image slide:role="background">`.
- Text scrims over image backgrounds are normal shape overlays after the background, not additional backgrounds.
## Text
- Plain text uses `foreignObject slide:role="shape" slide:shape-type="text"`.
- Text `foreignObject` needs numeric `x`, `y`, `width`, and `height`.
- The first direct XHTML child must be `p`, `ul`, `ol`, `h1`, `h2`, `h3`, or `small`.
- Do not wrap text in `div` or `section`.
- Do not put bare text directly under `foreignObject`.
- Text style belongs in `style`.
- `font-size` must include `px`.
- Text color must be `rgb(...)` or `rgba(...)`.
- Text boxes must be sized to fit; static validation does not prove rendered wrapping.
## Shapes And Groups
- Geometry needs `slide:role="shape"` and a meaningful `slide:shape-type`.
- Common geometry includes `rect`, `ellipse`, `circle`, `path`, and `line`.
- Multi-element cards use `<g slide:role="group">`.
- Children inside a group still keep their own `slide:role`.
- A shape-with-text group is only for one geometry plus one text block. Cards with badges, icons, charts, or multiple text blocks must be regular groups.
- Custom paths must declare a meaningful `slide:width` and `slide:height` that match the path's real bounding box.
## Lines
- Lines use `<line slide:role="shape" slide:shape-type="line">`.
- Arrows use `slide:start-arrow` or `slide:end-arrow`.
- SVG marker arrows are forbidden.
## Images
- Images use `<image slide:role="image" slide:shape-type="image" href="...">`.
- Informational images preserve source aspect ratio.
- Do not wrap a single image in a group unless it is truly part of a larger multi-element composition.
- Borders and shadows belong on the image element itself when used.
## Charts
- Charts use `<rect slide:role="chart" href="..." x="..." y="..." width="..." height="...">`.
- The rect is a chart placeholder; it is not a drawn rectangle.
- Place charts at top level or inside `<g slide:role="group">`.
- Preserve chart `href` verbatim unless the user asks to change chart data, type, emphasis, theme, or source.
## Notes
- Speaker notes are optional and do not render on canvas.
- At most one `<slide:note>` may appear.
- Notes contain direct paragraph children.
## Colors
- Use `rgb(...)`, `rgba(...)`, or `url(#id)`.
- Do not use hex colors.
- Do not use named colors.
- Do not use `none` for `fill` or `stroke`; use `rgba(0,0,0,0)` for transparent fills.
## Animation
- Animation is part of delivery, not decoration.
- Most slides should be static.
- Presented decks may use progressive reveal for complex steps, charts, processes, timelines, or comparisons.
- Self-read, formal, board, or consulting decks should read fully without clicks.
- Use at most three builds on a slide.
- Use one effect type per slide.
- Animated elements need explicit `id`.
- Animate top-level elements or top-level groups.
- Use one deck-level page transition when needed; do not vary transition style slide by slide.
## Source Coverage
- Covers manifest sections: svg_reference, svg_document_rules
- Coverage mode: preserve hard SVG protocol requirements from the source while applying the CLI canvas adaptation to 960x540; visual guidance belongs in `visual-design.md`, not here.

View File

@@ -1,25 +0,0 @@
{
"version": "svg-slides.split-manifest.v1",
"source": "skills/lark-slides/references/svg-slides/source/full.debranded.md",
"source_export": "/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/lark_doc_KnCLd7xr5ohWONxhKsncZ3Lxnvd/full.debranded.md",
"source_role": "provenance_and_coverage_authority_not_default_runtime_context",
"sections": [
{"id": "title", "lines": [1, 1], "target": "README.md"},
{"id": "system_prompt_workflow", "lines": [3, 196], "target": "workflow.md"},
{"id": "svg_reference", "lines": [198, 865], "target": "protocol.md"},
{"id": "resolve_design_brief", "lines": [867, 1080], "target": "design-brief.md"},
{"id": "deck_design_reference_catalog", "lines": [1082, 1234], "target": "visual-design.md"},
{"id": "slide_outline_tool", "lines": [1236, 1254], "target": "workflow.md"},
{"id": "activate_slides_edit_tool", "lines": [1256, 1262], "target": "workflow.md"},
{"id": "slides_edit_tool", "lines": [1264, 1281], "target": "authoring-rules.md"},
{"id": "svg_document_rules", "lines": [1283, 1287], "target": "protocol.md"},
{"id": "image_usage", "lines": [1289, 1291], "target": "authoring-rules.md"},
{"id": "incremental_processing", "lines": [1293, 1331], "target": "workflow.md"},
{"id": "finish_slides_edit_tool", "lines": [1333, 1339], "target": "validation.md"},
{"id": "slide_organize_tool", "lines": [1341, 1347], "target": "editing-existing-decks.md"},
{"id": "compute_custom_shape_bbox_tool", "lines": [1349, 1355], "target": "authoring-rules.md"},
{"id": "generate_svg_chart_tool", "lines": [1357, 2356], "target": "chart-workflow.md"},
{"id": "slides_convert_tool", "lines": [2358, 2395], "target": "editing-existing-decks.md"},
{"id": "slides_parse_template_tool", "lines": [2397, 2420], "target": "editing-existing-decks.md"}
]
}

View File

@@ -1,120 +0,0 @@
# SVG Slides Validation
## Static Protocol Validator
Run:
```bash
node skills/lark-slides/scripts/validate_svg_deck.mjs <deck-dir-or-slides-dir> --json
```
The validator checks hard protocol rules:
- standalone SVG root;
- `slide:role="slide"`;
- required namespaces;
- `viewBox="0 0 960 540"`;
- background order;
- forbidden style blocks and CSS classes;
- forbidden text wrappers;
- color syntax;
- text `font-size` units;
- line role and arrow semantics;
- XML parseability.
The validator is a hard gate for publish-ready local bundles.
## Browser Text Boundary Check
Static XML cannot prove final rendered wrapping, CJK font fallback, or actual text height. Run browser text-boundary QA when Playwright is available:
```bash
node skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs <deck-dir-or-slides-dir> --out /tmp/svg-slides-text-bounds.json
```
If Playwright is unavailable, the script exits 2 and explains the missing optional dependency.
## Bundle Manifest
Run:
```bash
node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title "<title>"
```
The bundle manifest records:
- protocol version;
- title;
- slide list;
- validation receipt paths;
- `publish_ready=true`;
- `published=false`.
## Receipt Requirements
A local publish-ready bundle needs:
- `manifest.json`;
- `receipts/validate_svg_deck.json`;
- optional browser text-boundary receipt when browser QA ran;
- slide files listed in deterministic order.
## What Passing Validation Does Not Prove
Passing validation does not prove visual excellence, source quality, chart truth, or backend acceptance. It proves that the generated local SVG files obey the hard protocol rules represented by the validator.
Always separate:
- protocol pass;
- browser text-boundary pass;
- visual design review;
- live publish proof.
## Local Publish-Ready Bundle
Run:
```bash
node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title "<deck title>"
```
The command writes:
- `manifest.json`
- `receipts/validate_svg_deck.json`
The manifest uses:
```json
{
"version": "svglide.manifest.v1",
"protocol": "svg-slides.v1",
"size": {"width": 960, "height": 540},
"publish_ready": true,
"published": false
}
```
`publish_ready=true` means local static validation passed. It does not mean the deck was published to Lark Slides.
## Browser Text Boundary QA
When Playwright is available in the development environment, run:
```bash
node skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs <deck-dir> --out receipts/preview_text_bounds.json
```
Exit codes:
- `0`: no text-boundary problems.
- `1`: rendered text overflow was detected.
- `2`: the script could not run, for example Playwright is unavailable.
This browser check is a generation-quality gate. It is not a publish API proof.
## Source Coverage
- Covers manifest sections: finish_slides_edit_tool
- Coverage mode: preserve finish/validation gates and explicitly separate protocol pass from visual quality pass.

View File

@@ -1,74 +0,0 @@
# SVG Slides Visual Design
## Typography
Use real font families that are likely to render. Keep a stable display/body pairing across the deck.
Titles, hero numbers, and key labels may use display fonts. Body text should use readable fonts. English and CJK decks need compatible font choices rather than generic fallback everywhere.
Do not switch typography per slide without a structural reason.
## Layout Freedom
SVG Slides gives full coordinate-level layout control. Use that control to encode the page's logic.
Start each slide by identifying the relationship in the content:
- comparison;
- sequence;
- timeline;
- cycle;
- hierarchy;
- matrix or quadrant;
- funnel;
- part-to-whole;
- cause to effect;
- evidence to implication.
Then compose a bespoke structure using position, scale, alignment, grouping, flow direction, connectors, depth, and contrast. A layout invented for the slide's actual logic is better than a canned diagram.
## Visual Differentiation
Every substantive slide should have a visual idea: image, chart, diagram, process, comparison, spatial map, large number, table-like structure, or custom shape system.
Avoid repeating title-plus-bullets. Reuse deck-level motif and style, not the exact same page layout.
Cover, section divider, and closing pages are not exceptions. Unless the user explicitly requests no images, make these pages image-led with a high-impact hero image or generated visual. Text over imagery must use an intentional readability treatment, such as a translucent scrim or solid text zone, instead of relying on contrast by accident.
## Density
Density comes from audience and delivery mode. Split rather than cram when a slide needs more than one central idea.
Each slide should defend one central idea. Content slide titles should be declarative arguments, not topic labels. Cover, section, and closing slides can use shorter labels.
## Anti-Patterns
Avoid:
- generic white slides with bullets only;
- the same card grid on every page;
- low-contrast text;
- decorative lines crossing text;
- filler agenda or Q&A pages in short decks;
- placeholder images;
- unverified data visualization;
- text walls;
- overusing animation.
## Remaining Human Judgment
Static validation proves protocol shape, not taste. A deck can pass validation and still be visually weak.
Review visual quality separately:
- Does the layout express the slide's logic?
- Does each slide have a clear central claim?
- Are data and claims source-backed?
- Is typography intentional and consistent?
- Is the page readable in a browser at expected size?
- Does the deck vary composition while staying in one visual system?
## Source Coverage
- Covers manifest sections: deck_design_reference_catalog
- Coverage mode: preserve visual quality rules and examples as generation guidance; do not collapse them into generic style advice.

View File

@@ -1,98 +0,0 @@
# SVG Slides Workflow
## Layer Boundary
This workflow owns local SVG deck generation and local validation. It does not call live APIs, choose a PPE lane, or prove backend acceptance of the generated payload.
The output is a local publish-ready bundle: standalone SVG slide files, source notes, optional assets, validation receipts, and a manifest. It is not published.
## Phase 1: Understand Request
Decide whether the user wants a new deck, a continuation of an existing deck, a repair pass, or a visual-quality pass. Clarify only when the target file, target slides, audience, delivery mode, or requested outcome is genuinely ambiguous.
Audience means the final viewer, not the creator. A specific audience can drive density and evidence style directly. Generic labels such as "users", "clients", or "team" are not specific enough for broad generation unless the user asks not to be interrupted.
## Phase 2: Settle Goal Audience Delivery
Settle three values before designing slides:
- `goal`: what the presentation should make the viewer understand or decide.
- `audience`: who will read or watch it.
- `delivery_mode`: `presented` when a speaker talks over it, `self_read` when it must stand alone.
Do not ask the user to pick tone, palette, density, or style. Those are inferred later by the design brief.
## Phase 3: Build Source Material
Broad topic-only requests require real source material. Search snippets, memory, and internal knowledge are not enough.
Collect full source text before drafting claims. Save a local research file with data points, claims, caveats, and source references. Every important claim or number used later must be traceable from `slide_content.md` back to this source material.
## Phase 4: Resolve Design Brief
Create a design brief after goal, audience, delivery mode, language, page count, and source material are known.
The brief must include:
- `narrative_spine`: the sequence logic and discipline of the deck.
- `depth`: altitude, density, include/exclude rules, and main points per slide.
- `tone`: writing and evidence style.
- `visual_system`: color, typography, layout, imagery, material, and decoration direction.
The design brief is authoritative for the generated deck. Do not override it with generic taste while authoring pages.
## Phase 5: Confirm Outline
For broad topics, create an actual slide sequence, not a chapter list. Use the user's explicit page count when given. Otherwise use 8-12 substantive slides for normal decks, unless the user explicitly asked for a short deck.
When the user gave a detailed outline, use it. When the user reorders, removes, adds, or rewrites slides, the user's outline wins over the brief's `narrative_spine`.
## Phase 6: Write slide_content
Write `slide_content.md` before SVG authoring.
`slide_content.md` records the structure, slide roles, key material, data points, claims, quotes, and source references. It does not lock exact final sentences, image paths, chart layout, or final page composition.
## Phase 7: Lock Visual Direction And Plan Visuals
Translate `visual_system` into concrete deck-level style:
- `aesthetic_direction`: the design language and mood from the brief.
- `color_palette`: consistent deck palette, expressed later as `rgb(...)` / `rgba(...)`.
- `typography`: a stable display/body pairing that matches the brief's category and treatment.
- `visual_assets`: per-slide image and chart needs, including aspect ratio and placement intent.
Unless the user explicitly requests no images, cover, section divider, and closing pages default to a high-impact hero image or generated visual. Record the intended asset, crop/aspect ratio, placement, and text-readability overlay treatment in `visual_assets`; do not leave these page roles as text-only by default.
Plan charts before writing slides. Any real quantitative series that supports a slide's point should use the chart workflow rather than a hand-drawn fake chart.
## Phase 8: Author SVG Pages
Each page is a complete standalone SVG document. Compose freely for the page's content logic. Do not stamp out a fixed template pattern.
For each page, record authoring intent before writing:
- the central idea;
- the layout relationship being encoded;
- visual assets used;
- animation decision, or `static`;
- expected validation risks.
Do not regenerate the whole deck structure after slide files have been authored. Add or remove pages through the existing-deck workflow.
## Output Bundle
The local bundle should contain:
- `slides/*.svg`: one standalone SVG slide per page;
- `slide_content.md`: source-backed content plan;
- `research_notes.md` when source material was gathered;
- `resources/` for chart/image sidecars;
- `manifest.json` from `svg_slides_bundle.mjs`;
- `receipts/validate_svg_deck.json` from the static validator;
- optional browser text-boundary receipt.
## Source Coverage
- Covers manifest sections: system_prompt_workflow, slide_outline_tool, activate_slides_edit_tool, incremental_processing
- Coverage mode: preserve workflow semantics from the source while replacing product-specific tool names with local generation stages.

View File

@@ -1,84 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
function fail(message, code = 2) {
console.error(message);
process.exit(code);
}
const args = process.argv.slice(2);
const targetArg = args.find((arg) => !arg.startsWith("--"));
const outIndex = args.indexOf("--out");
const outPath = outIndex >= 0 ? args[outIndex + 1] : "";
if (!targetArg) {
fail("Usage: node skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs <deck-dir-or-slides-dir> [--out <json-path>]");
}
let chromium;
try {
({ chromium } = await import("playwright"));
} catch {
fail("playwright is not installed; install it in a dev environment before browser text-boundary QA", 2);
}
const target = path.resolve(targetArg);
const slidesDir = fs.existsSync(path.join(target, "slides")) ? path.join(target, "slides") : target;
if (!fs.existsSync(slidesDir)) {
fail(`Slides directory not found: ${slidesDir}`);
}
const slideFiles = fs.readdirSync(slidesDir).filter((file) => file.endsWith(".svg")).sort();
if (!slideFiles.length) {
fail(`No .svg files found in ${slidesDir}`);
}
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 960, height: 540 }, deviceScaleFactor: 1 });
const results = [];
for (const file of slideFiles) {
const abs = path.join(slidesDir, file);
const svg = fs.readFileSync(abs, "utf8");
await page.setContent(`<!doctype html><html><body style="margin:0">${svg}</body></html>`, { waitUntil: "load" });
const problems = await page.evaluate(() => {
return [...document.querySelectorAll("foreignObject")].flatMap((node, index) => {
if (node.getAttribute("slide:role") !== "shape" || node.getAttribute("slide:shape-type") !== "text") {
return [];
}
const box = node.getBoundingClientRect();
const children = [...node.children];
if (!children.length) {
return [{ index: index + 1, reason: "empty_text_object" }];
}
return children.map((child) => {
const childBox = child.getBoundingClientRect();
const overflowX = childBox.left < box.left - 0.5 || childBox.right > box.right + 0.5;
const overflowY = childBox.top < box.top - 0.5 || childBox.bottom > box.bottom + 0.5;
if (!overflowX && !overflowY) return null;
return {
index: index + 1,
reason: "text_bounds_overflow",
box: { x: box.x, y: box.y, width: box.width, height: box.height },
childBox: { x: childBox.x, y: childBox.y, width: childBox.width, height: childBox.height }
};
}).filter(Boolean);
});
});
results.push({ file: path.relative(process.cwd(), abs), problemCount: problems.length, problems });
}
await browser.close();
const problemCount = results.reduce((sum, item) => sum + item.problemCount, 0);
const report = { status: problemCount === 0 ? "passed" : "failed", problemCount, results };
const json = `${JSON.stringify(report, null, 2)}\n`;
if (outPath) {
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
fs.writeFileSync(outPath, json);
}
process.stdout.write(json);
process.exit(problemCount === 0 ? 0 : 1);

View File

@@ -1,70 +0,0 @@
#!/usr/bin/env node
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
function fail(message, code = 2) {
console.error(message);
process.exit(code);
}
const args = process.argv.slice(2);
const deckArg = args.find((arg) => !arg.startsWith("--"));
const titleIndex = args.indexOf("--title");
const title = titleIndex >= 0 ? args[titleIndex + 1] : "";
if (!deckArg || !title) {
fail("Usage: node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title <title>");
}
const root = path.resolve(deckArg);
const slidesDir = fs.existsSync(path.join(root, "slides")) ? path.join(root, "slides") : root;
if (!fs.existsSync(slidesDir)) {
fail(`Slides directory not found: ${slidesDir}`);
}
const validator = path.resolve("skills/lark-slides/scripts/validate_svg_deck.mjs");
const validate = spawnSync("node", [validator, root, "--json"], { encoding: "utf8" });
if (!validate.stdout.trim()) {
process.stderr.write(validate.stderr);
process.exit(validate.status || 1);
}
const receipt = JSON.parse(validate.stdout);
fs.mkdirSync(path.join(root, "receipts"), { recursive: true });
fs.writeFileSync(path.join(root, "receipts", "validate_svg_deck.json"), `${JSON.stringify(receipt, null, 2)}\n`);
if (receipt.totalErrors !== 0) {
fail(`SVG deck is not publish-ready: ${receipt.totalErrors} validation error(s)`, 1);
}
const slideFiles = fs.readdirSync(slidesDir)
.filter((file) => file.endsWith(".svg"))
.sort();
const pages = slideFiles.map((file, index) => {
const abs = path.join(slidesDir, file);
const raw = fs.readFileSync(abs);
return {
id: path.basename(file, ".svg"),
index: index + 1,
file: path.relative(root, abs).split(path.sep).join("/"),
sha256: crypto.createHash("sha256").update(raw).digest("hex")
};
});
const manifest = {
version: "svglide.manifest.v1",
protocol: "svg-slides.v1",
title,
size: { width: 960, height: 540 },
publish_ready: true,
published: false,
pages,
receipts: {
validate_svg_deck: "receipts/validate_svg_deck.json"
}
};
fs.writeFileSync(path.join(root, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
console.log(JSON.stringify({ ok: true, manifest: path.join(root, "manifest.json"), pages: pages.length }, null, 2));

View File

@@ -1,49 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const script = path.resolve("skills/lark-slides/scripts/svg_slides_bundle.mjs");
function tempDeck() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-bundle-"));
fs.mkdirSync(path.join(root, "slides"));
return root;
}
const validSlide = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="bundle_slide" viewBox="0 0 960 540">
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="600" height="80" style="font-size:32px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(15,23,42,1);line-height:1.2;letter-spacing:0px;padding:0px">
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:32px;color:rgba(15,23,42,1)">Bundle</p>
</foreignObject>
</svg>`;
test("bundle builder writes manifest and validation receipt", () => {
const root = tempDeck();
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide);
const result = spawnSync("node", [script, root, "--title", "Bundle Test"], { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr || result.stdout);
const manifest = JSON.parse(fs.readFileSync(path.join(root, "manifest.json"), "utf8"));
assert.equal(manifest.version, "svglide.manifest.v1");
assert.equal(manifest.protocol, "svg-slides.v1");
assert.equal(manifest.title, "Bundle Test");
assert.deepEqual(manifest.size, { width: 960, height: 540 });
assert.equal(manifest.publish_ready, true);
assert.equal(manifest.published, false);
assert.equal(manifest.pages.length, 1);
assert.match(manifest.pages[0].sha256, /^[a-f0-9]{64}$/);
const receipt = JSON.parse(fs.readFileSync(path.join(root, "receipts", "validate_svg_deck.json"), "utf8"));
assert.equal(receipt.totalErrors, 0);
});
test("bundle builder rejects invalid SVG deck", () => {
const root = tempDeck();
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide.replace("rgba(255,255,255,1)", "#fff"));
const result = spawnSync("node", [script, root, "--title", "Invalid"], { encoding: "utf8" });
assert.equal(result.status, 1);
assert.match(result.stderr, /not publish-ready/);
assert.equal(fs.existsSync(path.join(root, "receipts", "validate_svg_deck.json")), true);
assert.equal(fs.existsSync(path.join(root, "manifest.json")), false);
});

View File

@@ -1,98 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const rootArg = process.argv.find((arg) => !arg.startsWith("--") && arg !== process.argv[1] && arg !== process.argv[0]);
const root = path.resolve(rootArg || "skills/lark-slides/references/svg-slides");
const json = process.argv.includes("--json");
const manifestPath = path.join(root, "source", "split-manifest.json");
function readText(file) {
return fs.readFileSync(file, "utf8");
}
function coverageBlock(markdown) {
const lines = markdown.split(/\r?\n/);
const start = lines.findIndex((line) => line.trim() === "## Source Coverage");
if (start === -1) return "";
const block = [];
for (let i = start + 1; i < lines.length; i += 1) {
if (/^#{1,2}\s+/.test(lines[i])) break;
block.push(lines[i]);
}
return block.join("\n");
}
function coverageIds(block) {
const match = block.match(/^- Covers manifest sections:\s*(.+)$/m);
if (!match) return [];
return match[1].split(",").map((value) => value.trim()).filter(Boolean);
}
const errors = [];
if (!fs.existsSync(manifestPath)) {
errors.push(`missing manifest: ${manifestPath}`);
}
const manifest = errors.length === 0 ? JSON.parse(readText(manifestPath)) : { sections: [] };
const sectionsById = new Map(manifest.sections.map((section) => [section.id, section]));
const seen = new Map();
if (fs.existsSync(root)) {
for (const entry of fs.readdirSync(root)) {
if (!entry.endsWith(".md")) continue;
const filePath = path.join(root, entry);
const block = coverageBlock(readText(filePath));
if (!block) {
errors.push(`${entry}: missing ## Source Coverage`);
continue;
}
const ids = coverageIds(block);
if (ids.length === 0) {
errors.push(`${entry}: missing "- Covers manifest sections:" line`);
continue;
}
for (const id of ids) {
const section = sectionsById.get(id);
if (!section) {
errors.push(`${entry}: unknown manifest section "${id}"`);
continue;
}
if (section.target !== entry) {
errors.push(`${entry}: section "${id}" belongs to ${section.target}`);
}
const files = seen.get(id) || [];
files.push(entry);
seen.set(id, files);
}
}
}
for (const section of manifest.sections) {
const files = seen.get(section.id) || [];
if (files.length === 0) {
errors.push(`${section.id}: not covered by ${section.target}`);
}
if (files.length > 1) {
errors.push(`${section.id}: covered multiple times by ${files.join(", ")}`);
}
}
const report = {
root,
manifest: manifestPath,
sectionCount: manifest.sections.length,
coveredCount: seen.size,
errors
};
if (json) {
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
} else if (errors.length === 0) {
console.log(`Source coverage OK: ${report.coveredCount}/${report.sectionCount} sections`);
} else {
console.error(`Source coverage failed: ${errors.length} errors`);
for (const error of errors) console.error(`- ${error}`);
}
process.exit(errors.length === 0 ? 0 : 1);

View File

@@ -1,55 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const script = path.resolve("skills/lark-slides/scripts/svg_slides_source_coverage_check.mjs");
function tempRoot() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-coverage-"));
fs.mkdirSync(path.join(root, "source"), { recursive: true });
return root;
}
function writeManifest(root, sections) {
fs.writeFileSync(path.join(root, "source", "split-manifest.json"), JSON.stringify({
version: "svg-slides.split-manifest.v1",
source: "source/full.debranded.md",
sections
}, null, 2));
}
function run(root) {
return spawnSync("node", [script, root, "--json"], { encoding: "utf8" });
}
test("passes when each manifest section is covered by its target file", () => {
const root = tempRoot();
writeManifest(root, [{ id: "workflow", lines: [1, 10], target: "workflow.md" }]);
fs.writeFileSync(path.join(root, "workflow.md"), "# Workflow\n\n## Source Coverage\n\n- Covers manifest sections: workflow\n");
const result = run(root);
assert.equal(result.status, 0, result.stderr);
assert.equal(JSON.parse(result.stdout).errors.length, 0);
});
test("fails when a section is missing from Source Coverage", () => {
const root = tempRoot();
writeManifest(root, [{ id: "protocol", lines: [1, 10], target: "protocol.md" }]);
fs.writeFileSync(path.join(root, "protocol.md"), "# Protocol\n\n## Source Coverage\n\n- Covers manifest sections: other\n");
const result = run(root);
assert.equal(result.status, 1);
const report = JSON.parse(result.stdout);
assert.ok(report.errors.some((error) => error.includes("unknown manifest section")));
assert.ok(report.errors.some((error) => error.includes("not covered")));
});
test("fails when a section is declared by the wrong target file", () => {
const root = tempRoot();
writeManifest(root, [{ id: "visual", lines: [1, 10], target: "visual-design.md" }]);
fs.writeFileSync(path.join(root, "workflow.md"), "# Workflow\n\n## Source Coverage\n\n- Covers manifest sections: visual\n");
const result = run(root);
assert.equal(result.status, 1);
assert.ok(JSON.parse(result.stdout).errors.some((error) => error.includes("belongs to visual-design.md")));
});

View File

@@ -1,244 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
function usage() {
console.error("Usage: node skills/lark-slides/scripts/validate_svg_deck.mjs <deck-dir-or-slides-dir> [--json]");
}
const args = process.argv.slice(2);
const json = args.includes("--json");
const targetArg = args.find((arg) => !arg.startsWith("--"));
if (!targetArg) {
usage();
process.exit(2);
}
const target = path.resolve(targetArg);
const slidesDir = fs.existsSync(path.join(target, "slides")) ? path.join(target, "slides") : target;
if (!fs.existsSync(slidesDir)) {
console.error(`Slides directory not found: ${slidesDir}`);
process.exit(2);
}
const slideFiles = fs.readdirSync(slidesDir)
.filter((file) => file.endsWith(".svg"))
.sort()
.map((file) => path.join(slidesDir, file));
if (!slideFiles.length) {
console.error(`No .svg files found in: ${slidesDir}`);
process.exit(2);
}
function commandExists(name) {
const result = spawnSync("sh", ["-lc", `command -v ${name}`], { encoding: "utf8" });
return result.status === 0;
}
function checkXml(file, errors) {
if (!commandExists("xmllint")) {
errors.push({
rule: "xml.valid",
severity: "warn",
message: "xmllint is unavailable; XML parser check skipped",
});
return;
}
const result = spawnSync("xmllint", ["--noout", file], { encoding: "utf8" });
if (result.status !== 0) {
errors.push({
rule: "xml.valid",
severity: "error",
message: (result.stderr || result.stdout || "xmllint failed").trim(),
});
}
}
function firstElementAfterDefs(svg) {
const rootOpen = svg.match(/<svg\b[^>]*>/);
if (!rootOpen) return null;
let inner = svg.slice(rootOpen.index + rootOpen[0].length, svg.lastIndexOf("</svg>")).trim();
if (inner.startsWith("<defs")) {
const end = inner.indexOf("</defs>");
if (end === -1) return null;
inner = inner.slice(end + "</defs>".length).trim();
}
return inner.match(/^<([a-zA-Z][\w:-]*)\b([^>]*)>/)?.[0] || null;
}
function stripDefs(svg) {
return svg.replace(/<defs\b[\s\S]*?<\/defs>/g, "");
}
function attrValue(tag, name) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return tag.match(new RegExp(`${escaped}="([^"]*)"`))?.[1] || "";
}
function isColorValueAllowed(value) {
return /^(rgba?\([^)]*\)|url\(#[-\w]+\))$/.test(value.trim());
}
function checkSlide(file) {
const rel = path.relative(process.cwd(), file);
const svg = fs.readFileSync(file, "utf8");
const errors = [];
checkXml(file, errors);
const root = svg.match(/<svg\b[^>]*>/)?.[0] || "";
if (!root) {
errors.push({ rule: "svg.root", severity: "error", message: "missing <svg> root" });
} else {
if (!/xmlns="http:\/\/www\.w3\.org\/2000\/svg"/.test(root)) {
errors.push({ rule: "svg.root.xmlns", severity: "error", message: "missing SVG namespace" });
}
if (!/xmlns:slide="https:\/\/slides\.bytedance\.com\/ns"/.test(root)) {
errors.push({ rule: "svg.root.slide-xmlns", severity: "error", message: "missing slide namespace" });
}
if (!/slide:role="slide"/.test(root)) {
errors.push({ rule: "svg.root.slide-role", severity: "error", message: "root must have slide:role=\"slide\"" });
}
if (!/id="[^"]+"/.test(root)) {
errors.push({ rule: "svg.root.id", severity: "error", message: "root must have id" });
}
if (!/viewBox="0 0 960 540"/.test(root)) {
errors.push({ rule: "svg.root.viewBox", severity: "error", message: "expected viewBox=\"0 0 960 540\"" });
}
}
if (/<presentation\b/.test(svg)) {
errors.push({ rule: "svg.no-presentation-wrapper", severity: "error", message: "single slide file must not wrap with <presentation>" });
}
const first = firstElementAfterDefs(svg);
const backgroundCount = (svg.match(/slide:role="background"/g) || []).length;
if (backgroundCount !== 1) {
errors.push({ rule: "background.count", severity: "error", message: `expected exactly one background, found ${backgroundCount}` });
}
if (!first || !/^(<rect\b|<image\b)/.test(first) || !/slide:role="background"/.test(first)) {
errors.push({ rule: "background.first-child", severity: "error", message: "first rendered child after optional <defs> must be the background" });
}
const bodyNoDefs = stripDefs(svg);
const forbidden = [
{ rule: "forbid.style-block", re: /<style\b/, message: "slide SVG must not rely on <style> blocks" },
{ rule: "forbid.css-class", re: /\bclass="/, message: "slide SVG must not rely on CSS classes" },
{ rule: "forbid.div-wrapper", re: /<div\b/, message: "text foreignObject must not contain <div>" },
{ rule: "forbid.section-wrapper", re: /<section\b/, message: "text foreignObject must not contain <section>" },
{ rule: "forbid.svg-text", re: /<text\b/, message: "use foreignObject rich text, not SVG <text>" },
{ rule: "forbid.svg-marker", re: /\bmarker-(start|end|mid)=|<marker\b/, message: "line arrowheads must use slide:* arrow attributes, not SVG marker" },
{ rule: "forbid.legacy-fontSize", re: /\bfontSize="/, message: "text visual properties must be in style, not legacy fontSize attribute" },
{ rule: "forbid.legacy-bold", re: /\bbold="/, message: "text visual properties must be in style, not legacy bold attribute" },
];
for (const item of forbidden) {
if (item.re.test(bodyNoDefs)) {
errors.push({ rule: item.rule, severity: "error", message: item.message });
}
}
for (const match of bodyNoDefs.matchAll(/\b(fill|stroke|stop-color)="([^"]+)"/g)) {
const [, attr, value] = match;
if (!isColorValueAllowed(value)) {
errors.push({
rule: "color.attr",
severity: "error",
message: `${attr} must be rgb(...), rgba(...), or url(#...); got ${JSON.stringify(value)}`,
});
}
}
for (const match of bodyNoDefs.matchAll(/(?:^|;)\s*color\s*:\s*([^;"]+)/g)) {
const value = match[1].trim();
if (!/^rgba?\([^)]*\)$/.test(value)) {
errors.push({
rule: "color.css",
severity: "error",
message: `CSS color must be rgb(...) or rgba(...); got ${JSON.stringify(value)}`,
});
}
}
const foreignObjects = [...svg.matchAll(/<foreignObject\b([^>]*)>([\s\S]*?)<\/foreignObject>/g)];
for (const [index, match] of foreignObjects.entries()) {
const attrText = match[1];
const inner = match[2].trim();
const label = `foreignObject #${index + 1}`;
const isTextObject = /slide:role="shape"/.test(attrText) && /slide:shape-type="text"/.test(attrText);
if (!isTextObject) continue;
for (const attr of ["x", "y", "width", "height"]) {
if (!new RegExp(`\\b${attr}="[-0-9.]+`).test(attrText)) {
errors.push({ rule: "text.geometry", severity: "error", message: `${label} missing numeric ${attr}` });
}
}
const styleText = attrValue(match[0], "style");
if (!/font-size:\s*\d+(?:\.\d+)?px/.test(styleText)) {
errors.push({ rule: "text.style.font-size", severity: "error", message: `${label} missing font-size with px suffix in style` });
}
if (!/color:\s*rgba?\(/.test(styleText)) {
errors.push({ rule: "text.style.color", severity: "error", message: `${label} missing rgb/rgba color in style` });
}
if (!/^<(p|ul|ol|h1|h2|h3|small)\b/.test(inner)) {
errors.push({
rule: "text.direct-child",
severity: "error",
message: `${label} first direct child must be p/ul/ol/h1/h2/h3/small, got ${inner.slice(0, 40) || "empty"}`,
});
}
if (/<(div|section)\b/.test(inner)) {
errors.push({ rule: "text.no-wrapper", severity: "error", message: `${label} contains an invalid wrapper element` });
}
if (/^([^<]|\s)+$/.test(inner)) {
errors.push({ rule: "text.no-bare-text", severity: "error", message: `${label} contains bare text instead of xhtml children` });
}
}
for (const match of bodyNoDefs.matchAll(/<line\b([^>]*)>/g)) {
const attrs = match[1];
if (!/slide:role="shape"/.test(attrs) || !/slide:shape-type="line"/.test(attrs)) {
errors.push({ rule: "line.role", severity: "error", message: "line must carry slide:role=\"shape\" and slide:shape-type=\"line\"" });
}
if (!/\bstroke="rgba?\(/.test(attrs)) {
errors.push({ rule: "line.stroke", severity: "error", message: "line must have rgb/rgba stroke" });
}
}
return { file: rel, errorCount: errors.filter((item) => item.severity === "error").length, errors };
}
const results = slideFiles.map(checkSlide);
const totalErrors = results.reduce((sum, result) => sum + result.errorCount, 0);
const report = {
target: path.relative(process.cwd(), target),
slidesDir: path.relative(process.cwd(), slidesDir),
slideCount: slideFiles.length,
totalErrors,
results,
};
if (json) {
console.log(JSON.stringify(report, null, 2));
} else {
console.log(`SVG deck validation: ${report.target}`);
console.log(`Slides: ${report.slideCount}`);
console.log(`Errors: ${report.totalErrors}`);
for (const result of results) {
const status = result.errorCount ? "FAIL" : "PASS";
console.log(`\n[${status}] ${result.file}`);
for (const error of result.errors) {
if (error.severity === "warn") {
console.log(` WARN ${error.rule}: ${error.message}`);
} else {
console.log(` ${error.rule}: ${error.message}`);
}
}
}
}
process.exit(totalErrors ? 1 : 0);

View File

@@ -1,75 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const script = path.resolve("skills/lark-slides/scripts/validate_svg_deck.mjs");
function tempDeck() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-validator-"));
fs.mkdirSync(path.join(root, "slides"));
return root;
}
function writeSlide(root, name, body) {
fs.writeFileSync(path.join(root, "slides", name), body);
}
function runValidator(root) {
return spawnSync("node", [script, root, "--json"], { encoding: "utf8" });
}
const validSlide = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="valid" viewBox="0 0 960 540">
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="600" height="80" style="font-size:32px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(15,23,42,1);line-height:1.2;letter-spacing:0px;padding:0px">
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:32px;color:rgba(15,23,42,1)">Valid</p>
</foreignObject>
</svg>`;
test("valid SVG deck passes", () => {
const root = tempDeck();
writeSlide(root, "slide_01.svg", validSlide);
const result = runValidator(root);
assert.equal(result.status, 0, result.stderr);
const report = JSON.parse(result.stdout);
assert.equal(report.slideCount, 1);
assert.equal(report.totalErrors, 0);
});
test("invalid SVG deck reports protocol errors", () => {
const root = tempDeck();
writeSlide(root, "slide_01.svg", `<svg xmlns="http://www.w3.org/2000/svg" id="bad" viewBox="0 0 960 540">
<style>.t{fill:red}</style>
<rect width="960" height="540" fill="#fff"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="300" height="80" style="font-size:32;color:#111">
<div xmlns="http://www.w3.org/1999/xhtml"><p>Bad</p></div>
</foreignObject>
</svg>`);
const result = runValidator(root);
assert.equal(result.status, 1);
const report = JSON.parse(result.stdout);
const rules = report.results.flatMap((item) => item.errors.map((error) => error.rule));
assert.ok(rules.includes("svg.root.slide-xmlns"));
assert.ok(rules.includes("svg.root.slide-role"));
assert.ok(rules.includes("background.first-child"));
assert.ok(rules.includes("forbid.style-block"));
assert.ok(rules.includes("forbid.div-wrapper"));
assert.ok(rules.includes("color.attr"));
assert.ok(rules.includes("text.style.font-size"));
assert.ok(rules.includes("text.style.color"));
});
test("examples directory remains protocol-valid", () => {
const examples = path.resolve("skills/lark-slides/references/svg-slides/examples");
const result = spawnSync("node", [script, examples, "--json"], { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
const report = JSON.parse(result.stdout);
assert.equal(report.slideCount, 3);
assert.equal(report.totalErrors, 0);
});

View File

@@ -0,0 +1,166 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// setSlashCommandDryRunEnv isolates config and supplies stub credentials so
// dry-run / the pre-Execute confirmation gate short-circuit before identity
// resolution touches a real keychain. Mirrors tests/cli_e2e/apps/helpers_test.go
// and tests/cli_e2e/calendar/calendar_update_dryrun_test.go.
func setSlashCommandDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "application_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "application_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
const slashCommandBasePath = "/open-apis/application/v7/app_slash_commands"
// TestSlashCommandList_DryRunShowsGetPath pins the read-only GET shape for
// `application +slash-command-list --dry-run`.
func TestSlashCommandList_DryRunShowsGetPath(t *testing.T) {
setSlashCommandDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"application", "+slash-command-list",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
assert.Equal(t, "GET", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath, gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
}
// TestSlashCommandCreate_DryRunShowsPostBody pins the POST body shape for
// `application +slash-command-create --dry-run`: icon sits at the TOP LEVEL,
// a sibling of description (not nested inside description) - the official
// create sample nesting icon inside description is a documented doc bug -
// and description.i18n carries the localized map.
func TestSlashCommandCreate_DryRunShowsPostBody(t *testing.T) {
setSlashCommandDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"application", "+slash-command-create",
"--command", "greet",
"--description", "say hi",
"--description-i18n", "zh_cn=你好",
"--icon-key", "skill_outlined",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
assert.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath, gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
assert.Equal(t, "greet", gjson.Get(out, "api.0.body.command").String(), "stdout:\n%s", out)
assert.Equal(t, "say hi", gjson.Get(out, "api.0.body.description.default_value").String(), "stdout:\n%s", out)
assert.Equal(t, "你好", gjson.Get(out, "api.0.body.description.i18n.zh_cn").String(), "stdout:\n%s", out)
// icon is a top-level key, sibling of description.
assert.Equal(t, "skill_outlined", gjson.Get(out, "api.0.body.icon.icon_key").String(), "stdout:\n%s", out)
assert.False(t, gjson.Get(out, "api.0.body.description.icon").Exists(), "icon must not be nested inside description:\n%s", out)
}
// TestSlashCommandUpdate_DryRunShowsPatchPath pins the PATCH shape for
// `application +slash-command-update --command-id --dry-run`.
func TestSlashCommandUpdate_DryRunShowsPatchPath(t *testing.T) {
setSlashCommandDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"application", "+slash-command-update",
"--command-id", "id_dry",
"--description", "updated description",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
assert.Equal(t, "PATCH", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath+"/id_dry", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
assert.Equal(t, "updated description", gjson.Get(out, "api.0.body.description.default_value").String(), "stdout:\n%s", out)
}
// TestSlashCommandDelete_DryRunShowsDeletePath pins the DELETE shape for
// `application +slash-command-delete --command-id --yes --dry-run`. Dry-run
// short-circuits before the high-risk-write confirmation gate (see
// shortcuts/common/runner.go), but --yes is passed anyway to match the
// eventual real invocation the agent would run.
func TestSlashCommandDelete_DryRunShowsDeletePath(t *testing.T) {
setSlashCommandDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"application", "+slash-command-delete",
"--command-id", "id_dry",
"--dry-run",
},
Yes: true,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
assert.Equal(t, "DELETE", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath+"/id_dry", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
}
// TestSlashCommandDelete_WithoutYesRequiresConfirmation asserts the
// high-risk-write gate fires BEFORE any HTTP call: no --dry-run, no --yes ->
// exit 10 (ExitConfirmationRequired) with a confirmation_required envelope on
// stderr (see internal/output/exitcode.go and cmd/root.go handleRootError).
func TestSlashCommandDelete_WithoutYesRequiresConfirmation(t *testing.T) {
setSlashCommandDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"application", "+slash-command-delete",
"--command-id", "id_dry",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 10)
assert.Equal(t, "confirmation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
assert.Equal(t, "confirmation_required", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
}

View File

@@ -1,9 +1,9 @@
# Mail CLI E2E Coverage
## Metrics
- Denominator: 65 leaf commands
- Covered: 16
- Coverage: 24.6%
- Denominator: 63 leaf commands
- Covered: 14
- Coverage: 22.2%
## Summary
- TestMail_DraftLifecycleWorkflowAsUser: proves a self-contained user draft workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail user_mailbox.drafts list`, `mail user_mailbox.drafts get`, `mail +draft-edit`, and `mail user_mailbox.drafts delete`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create draft with shortcut as user`, `list draft as user`, `get created draft as user`, `inspect created draft as user`, `update draft subject with shortcut as user`, `inspect updated draft as user`, `delete draft as user`, and `verify draft removed from list as user`.
@@ -20,8 +20,6 @@
| ✓ | mail +draft-send | shortcut | mail_draft_send_workflow_test.go::TestMail_DraftSendWorkflowAsUser/send draft with shortcut as user; mail_draft_send_dryrun_test.go::TestMail_DraftSendDryRun | `--draft-id`; `--mailbox me`; `--yes`; dry-run repeated/comma-separated `--draft-id` | sends a self-addressed draft through the batch shortcut and locks dry-run request shape |
| ✓ | mail +forward | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/forward received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect forward draft as user | `--message-id`; `--to`; `--body`; `--plain-text` | uses self-generated inbox message as source and inspects forwarded draft projection |
| ✓ | mail +message | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get sent message as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get received message as user | `--mailbox me`; `--message-id` | verifies both SENT and INBOX copies after self-send |
| ✓ | mail +message-modify | shortcut | shortcuts/mail/mail_message_manage_test.go::TestMessageModify_DryRunShowsPlanWithoutValidationGET; shortcuts/mail/mail_message_manage_test.go::TestMessageModify_BatchesAndAggregatesPartialFailure | `--message-ids`; `--add-label-ids`; `--remove-label-ids`; `--add-folder`; `--dry-run` | unit/dry-run coverage locks validation, batching, request shape, and partial failure aggregation; live E2E needs controlled disposable messages/labels/folders |
| ✓ | mail +message-trash | shortcut | shortcuts/mail/mail_message_manage_test.go::TestMessageTrash_RequiresYesAndBatches | `--message-ids`; `--yes`; `--dry-run` | unit coverage locks high-risk confirmation and batch_trash request shape; live E2E needs controlled disposable messages |
| ✓ | mail +messages | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get both self sent messages as user | `--mailbox me`; `--message-ids` | batch reads both sent and received message copies |
| ✓ | mail +reply | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/reply to received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect reply draft as user | `--message-id`; `--body`; `--plain-text` | creates reply draft from self-generated inbox message and inspects quoted content |
| ✕ | mail +reply-all | shortcut | | none | self-send traffic leaves no stable non-self recipient set for deterministic reply-all assertions |