diff --git a/cmd/build.go b/cmd/build.go index 6b5d1e5c1..1a71c1bc1 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -16,6 +16,7 @@ import ( "github.com/larksuite/cli/cmd/profile" "github.com/larksuite/cli/cmd/schema" "github.com/larksuite/cli/cmd/service" + cmdskill "github.com/larksuite/cli/cmd/skill" cmdupdate "github.com/larksuite/cli/cmd/update" _ "github.com/larksuite/cli/events" "github.com/larksuite/cli/internal/build" @@ -121,6 +122,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B rootCmd.AddCommand(completion.NewCmdCompletion(f)) rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f)) rootCmd.AddCommand(cmdevent.NewCmdEvents(f)) + rootCmd.AddCommand(cmdskill.NewCmdSkill(f)) service.RegisterServiceCommandsWithContext(ctx, rootCmd, f) shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f) diff --git a/cmd/skill/skill.go b/cmd/skill/skill.go new file mode 100644 index 000000000..6f665e836 --- /dev/null +++ b/cmd/skill/skill.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package skill + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +// NewCmdSkill creates the top-level "skill" command with its subcommands. +func NewCmdSkill(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "skill", + Short: "Manage and query AI agent skills bundled with lark-cli", + } + cmdutil.DisableAuthCheck(cmd) + cmd.AddCommand(newCmdSkillReference(f)) + return cmd +} + +// newCmdSkillReference creates the "skill reference" subcommand. +func newCmdSkillReference(f *cmdutil.Factory) *cobra.Command { + var name string + + cmd := &cobra.Command{ + Use: "reference ", + Short: "Print a skill reference document to stdout", + Long: `Print the contents of a skill reference document to stdout. + +Example: + lark-cli skill reference lark-mail --name lark-mail-rule-reorder`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + skillName := args[0] + if name == "" { + return fmt.Errorf("--name is required") + } + content, err := readSkillReference(skillName, name) + if err != nil { + return err + } + fmt.Fprint(f.IOStreams.Out, content) + return nil + }, + } + cmdutil.DisableAuthCheck(cmd) + cmdutil.SetRisk(cmd, "read") + cmd.Flags().StringVar(&name, "name", "", "name of the reference document (without .md extension)") + _ = cmd.MarkFlagRequired("name") + return cmd +} + +// readSkillReference reads /references/.md from the skills +// directory, resolving the location relative to the running binary. +func readSkillReference(skillName, name string) (string, error) { + // Sanitize inputs to prevent path traversal. + if strings.ContainsAny(skillName, "/\\..") || strings.ContainsAny(name, "/\\..") { + return "", fmt.Errorf("invalid skill or reference name") + } + + relPath := filepath.Join("skills", skillName, "references", name+".md") + + for _, dir := range candidateDirs() { + fullPath := filepath.Join(dir, relPath) + data, err := os.ReadFile(fullPath) + if err == nil { + return string(data), nil + } + if !errors.Is(err, fs.ErrNotExist) { + return "", fmt.Errorf("reading skill reference: %w", err) + } + } + + return "", fmt.Errorf("skill reference not found: %s/%s (skill: %s, name: %s)", + skillName, name, skillName, name) +} + +// candidateDirs returns candidate root directories where the skills/ subtree +// may be located, in priority order. +// +// Lookup order: +// 1. LARKSUITE_CLI_SKILLS_DIR env override (testing / custom installs) +// 2. / — binary at repo root after `make build` +// 3. /../ — binary in bin/, skills one level up (npm pkg layout) +func candidateDirs() []string { + var dirs []string + + if env := os.Getenv("LARKSUITE_CLI_SKILLS_DIR"); env != "" { + dirs = append(dirs, env) + } + + exe, err := os.Executable() + if err == nil { + binDir := filepath.Dir(exe) + dirs = append(dirs, + binDir, + filepath.Join(binDir, ".."), + ) + } + + return dirs +} diff --git a/shortcuts/mail/mail_rule_reorder.go b/shortcuts/mail/mail_rule_reorder.go new file mode 100644 index 000000000..5f9fafc87 --- /dev/null +++ b/shortcuts/mail/mail_rule_reorder.go @@ -0,0 +1,160 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// MailRuleReorder reorders inbox rules. Partial ID lists are auto-filled +// using slot-replacement from the current server-side order. +var MailRuleReorder = common.Shortcut{ + Service: "mail", + Command: "+rule-reorder", + Description: "Reorder inbox rules. Provide a partial or full list of rule IDs in the desired order; missing rules are auto-filled from the current order using slot-replacement.", + Risk: "write", + Scopes: []string{"mail:user_mailbox.rule:write"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "mailbox", Default: "me", Desc: "mailbox address (default: me)"}, + {Name: "rule-ids", Desc: "comma-separated rule IDs in desired order (required); omitted rules are auto-filled"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + raw := runtime.Str("rule-ids") + if strings.TrimSpace(raw) == "" { + return output.ErrValidation("--rule-ids: required, must be a comma-separated list of rule IDs") + } + ids, err := parseRuleIDs(raw) + if err != nil { + return err + } + if len(ids) == 0 { + return output.ErrValidation("--rule-ids: must provide at least one rule ID") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailboxID := resolveMailboxID(runtime) + return common.NewDryRunAPI(). + Desc("Step 1: list rules; Step 2: apply slot-replacement fill; Step 3: reorder with merged IDs"). + GET(mailboxPath(mailboxID, "rules")). + Set("user_rule_ids", runtime.Str("rule-ids")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + mailboxID := resolveMailboxID(runtime) + userIDs, err := parseRuleIDs(runtime.Str("rule-ids")) + if err != nil { + return err + } + + // Step 1: list current rules + listResp, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "rules"), nil, nil) + if err != nil { + return err + } + currentIDs, err := extractRuleIDs(listResp) + if err != nil { + return err + } + + // Step 2: validate all user IDs exist in the current list + if err := validateRuleIDsExist(userIDs, currentIDs); err != nil { + return err + } + + // Step 3: slot-replacement merge + mergedIDs := slotReplace(currentIDs, userIDs) + + // Step 4: reorder (write op — do NOT auto-retry) + _, err = runtime.CallAPI("POST", mailboxPath(mailboxID, "rules", "reorder"), nil, + map[string]interface{}{"rule_ids": mergedIDs}) + if err != nil { + return fmt.Errorf("%w; rule list may have changed, please re-run the full command", err) + } + + out := map[string]interface{}{"rule_ids": mergedIDs} + runtime.OutFormat(out, &output.Meta{Count: len(mergedIDs)}, func(w io.Writer) { + fmt.Fprintf(w, "reordered %d rules. new order: %v\n", len(mergedIDs), mergedIDs) + }) + return nil + }, +} + +// slotReplace fills userIDs into the positional slots they occupy in currentIDs. +// Non-specified IDs stay in their original positions. +func slotReplace(currentIDs, userIDs []string) []string { + userSet := make(map[string]bool, len(userIDs)) + for _, id := range userIDs { + userSet[id] = true + } + slots := make([]int, 0, len(userIDs)) + for i, id := range currentIDs { + if userSet[id] { + slots = append(slots, i) + } + } + result := make([]string, len(currentIDs)) + copy(result, currentIDs) + for j, slot := range slots { + result[slot] = userIDs[j] + } + return result +} + +func parseRuleIDs(raw string) ([]string, error) { + parts := strings.Split(raw, ",") + seen := make(map[string]bool, len(parts)) + result := make([]string, 0, len(parts)) + for _, p := range parts { + id := strings.TrimSpace(p) + if id == "" { + continue + } + if seen[id] { + return nil, output.ErrValidation("--rule-ids: duplicate rule ID %q", id) + } + seen[id] = true + result = append(result, id) + } + return result, nil +} + +func validateRuleIDsExist(userIDs, currentIDs []string) error { + currentSet := make(map[string]bool, len(currentIDs)) + for _, id := range currentIDs { + currentSet[id] = true + } + for _, id := range userIDs { + if !currentSet[id] { + return output.ErrValidation("--rule-ids: rule ID %q not found in mailbox", id) + } + } + return nil +} + +func extractRuleIDs(resp map[string]interface{}) ([]string, error) { + // CallAPI/HandleApiResult already extracts the "data" field, so resp is directly {"items": [...]}. + items, ok := resp["items"].([]interface{}) + if !ok { + return []string{}, nil + } + ids := make([]string, 0, len(items)) + for _, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + id, _ := m["id"].(string) + if id != "" { + ids = append(ids, id) + } + } + return ids, nil +} diff --git a/shortcuts/mail/shortcuts.go b/shortcuts/mail/shortcuts.go index 8bd7a7f01..64eb192c7 100644 --- a/shortcuts/mail/shortcuts.go +++ b/shortcuts/mail/shortcuts.go @@ -25,5 +25,6 @@ func Shortcuts() []common.Shortcut { MailShareToChat, MailTemplateCreate, MailTemplateUpdate, + MailRuleReorder, } } diff --git a/skills/lark-mail/references/lark-mail-rule-reorder.md b/skills/lark-mail/references/lark-mail-rule-reorder.md new file mode 100644 index 000000000..5214b0f47 --- /dev/null +++ b/skills/lark-mail/references/lark-mail-rule-reorder.md @@ -0,0 +1,125 @@ +# mail +rule-reorder + +> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +对收件箱规则重新排序。用户只需传入**部分**规则 ID(期望的相对顺序),其余未指定的规则由 CLI 自动从服务端当前顺序中补全,使用 **slot-replacement 算法**生成完整列表后调用 Reorder API。 + +本 skill 对应 shortcut:`lark-cli mail +rule-reorder`。 + +## 使用时机 + +- 用户想把某几条规则的优先级调高/调低,但不想手动列出所有规则 ID +- 用户只知道"让规则 E 排在规则 A 前面",而不关心其他规则的顺序 +- 需要一次性重排全部规则时,也可传入完整 ID 列表(直接替换) + +## 命令 + +```bash +# 将规则 E 提前到规则 A 之前(只需传入希望调整的规则 ID,其余自动补全) +lark-cli mail +rule-reorder --rule-ids "E,A" + +# 指定邮箱 +lark-cli mail +rule-reorder --mailbox shared@example.com --rule-ids "E,A" + +# 传入全量 ID 列表(全量重排) +lark-cli mail +rule-reorder --rule-ids "D,E,G,C,B,A" + +# Dry Run(不真改,仅显示请求意图) +lark-cli mail +rule-reorder --rule-ids "E,A" --dry-run +``` + +## 参数 + +| 参数 | 必填 | 默认 | 说明 | +|------|------|------|------| +| `--rule-ids ` | 是 | — | 按目标顺序排列的规则 ID,逗号分隔,禁止重复。至少填 1 个。未传入的规则 ID 由 slot-replacement 算法自动补全 | +| `--mailbox ` | 否 | `me` | 操作哪个邮箱;`me` 代表当前 OAuth token 对应的邮箱 | +| `--dry-run` | 否 | — | 仅打印请求意图,不执行写操作 | + +## 行为细节 + +### slot-replacement 算法 + +1. 调用 `GET /open-apis/mail/v1/user_mailboxes/{mailbox}/rules` 获取当前全量规则顺序 `currentIDs` +2. 找出 `--rule-ids` 中每个 ID 在 `currentIDs` 中的槽位(下标),按升序收集为 `slots` +3. 将 `currentIDs` 复制为 `result`,依次把用户期望顺序中的第 j 个 ID 填入 `slots[j]` 位置 +4. 非指定 ID 原位不动 +5. 用合并后的完整列表调用 `POST /open-apis/mail/v1/user_mailboxes/{mailbox}/rules/reorder` + +**示例**:当前顺序 `[D, A, G, C, B, E]`,用户传 `--rule-ids "E,A"`: + +- A 在 index=1,E 在 index=5 → slots=[1, 5] +- result[1]=E,result[5]=A → 最终:`[D, E, G, C, B, A]` + +### 前置校验(Validate 阶段,无 API 调用) + +- `--rule-ids` 非空且解析后长度 ≥ 1 +- `--rule-ids` 中无重复 ID + +### 执行阶段校验 + +- 所有传入的 rule ID 必须存在于当前邮箱规则列表中(不存在时返回 validation 错误) + +### 写操作安全 + +- POST Reorder 为写操作,**不自动重试** +- List 与 Reorder 之间存在极小竞态窗口;若 Reorder 失败,CLI 提示用户重新执行完整命令 + +## 返回值 + +```json +{ + "ok": true, + "meta": { "count": 6 }, + "data": { + "rule_ids": ["D", "E", "G", "C", "B", "A"] + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `data.rule_ids` | `[]string` | 最终写入服务端的完整规则顺序(合并后) | +| `meta.count` | int | 规则总数 | + +## 典型场景 + +### 场景 1:将高优先级规则提前 + +```bash +# 当前顺序:[D, A, G, C, B, E] +# 目标:让 E 排到 A 前面 + +lark-cli mail +rule-reorder --rule-ids "E,A" +# CLI 内部:slot-replacement → [D, E, G, C, B, A] +# 输出:reordered 6 rules. new order: [D, E, G, C, B, A] +``` + +### 场景 2:先查看当前规则顺序 + +```bash +# 查当前规则列表(获取 ID) +lark-cli api GET '/open-apis/mail/v1/user_mailboxes/me/rules' --as user | jq '.data.items[].id' + +# 按需调整 +lark-cli mail +rule-reorder --rule-ids "rule-id-3,rule-id-1" +``` + +### 场景 3:Dry Run 确认意图 + +```bash +lark-cli mail +rule-reorder --rule-ids "E,A" --dry-run +# 不执行写操作,仅打印 GET 和 POST 请求意图 +``` + +## 不要这样做 + +- 不要传入邮箱中不存在的规则 ID — CLI 会在 Execute 阶段报 validation 错误 +- 不要传入重复的规则 ID — Validate 阶段即报错 +- 不要省略 `--rule-ids` — 该参数为必填,缺少时返回 validation 错误 +- 不要假设返回的 `rule_ids` 顺序就是"最终服务端顺序" — 极小概率存在 List-Reorder 竟态窗口(建议重跑确认) + +## 相关命令 + +- `lark-cli api GET '/open-apis/mail/v1/user_mailboxes/me/rules' --as user` — 查看当前规则列表及优先级顺序 +- `lark-cli mail +rule-reorder --dry-run` — 预览操作意图,不实际执行