-
Notifications
You must be signed in to change notification settings - Fork 823
feat(mail): add +rule-reorder shortcut with slot-replacement auto-fill #979
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <skill-name>", | ||
| 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 <skill-name>/references/<name>.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 { | ||
|
Comment on lines
+69
to
+74
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate and read user-derived paths via
As per coding guidelines: "Use 🤖 Prompt for AI Agents |
||
| 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_dir>/ — binary at repo root after `make build` | ||
| // 3. <binary_dir>/../ — 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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")) | ||
| }, | ||
|
Comment on lines
+43
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dry-run does not preview the reorder write request.
💡 Suggested fix 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"))
+ Set("user_rule_ids", runtime.Str("rule-ids")).
+ POST(mailboxPath(mailboxID, "rules", "reorder")).
+ Set("rule_ids", []string{"<merged IDs from step 2>"})
},🤖 Prompt for AI Agents |
||
| 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,5 +25,6 @@ | |
| MailShareToChat, | ||
| MailTemplateCreate, | ||
| MailTemplateUpdate, | ||
| MailRuleReorder, | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use command error wrappers in
RunEinstead offmt.Errorf.Line 44 should return
output.Errorf(oroutput.ErrWithHint) so stderr remains machine-parseable for AI agents.As per coding guidelines: "
cmd/**/*.go:RunEfunctions in commands must returnoutput.Errorf/output.ErrWithHint— never barefmt.Errorf, because AI agents parse stderr as JSON".🤖 Prompt for AI Agents