Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"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"
Expand Down Expand Up @@ -121,6 +122,7 @@
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(cmdskill.NewCmdSkill(f))

Check warning on line 125 in cmd/build.go

View check run for this annotation

Codecov / codecov/patch

cmd/build.go#L125

Added line #L125 was not covered by tests
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)

Expand Down
110 changes: 110 additions & 0 deletions cmd/skill/skill.go
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")

Check warning on line 44 in cmd/skill/skill.go

View check run for this annotation

Codecov / codecov/patch

cmd/skill/skill.go#L42-L44

Added lines #L42 - L44 were not covered by tests
}
Comment on lines +41 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use command error wrappers in RunE instead of fmt.Errorf.

Line 44 should return output.Errorf (or output.ErrWithHint) so stderr remains machine-parseable for AI agents.

As per coding guidelines: "cmd/**/*.go: RunE functions in commands must return output.Errorf / output.ErrWithHint — never bare fmt.Errorf, because AI agents parse stderr as JSON".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/skill/skill.go` around lines 41 - 45, Replace the bare fmt.Errorf
returned from the RunE function in skill.go with the command error wrapper used
across CLI commands (use output.Errorf or output.ErrWithHint) so stderr stays
machine-parseable; specifically, in the RunE closure where you check `if name ==
""` return output.Errorf("--name is required") (or output.ErrWithHint with an
additional hint string), and ensure the output package is imported/used
consistently in this file and any callers of the RunE error behavior.

content, err := readSkillReference(skillName, name)
if err != nil {
return err

Check warning on line 48 in cmd/skill/skill.go

View check run for this annotation

Codecov / codecov/patch

cmd/skill/skill.go#L46-L48

Added lines #L46 - L48 were not covered by tests
}
fmt.Fprint(f.IOStreams.Out, content)
return nil

Check warning on line 51 in cmd/skill/skill.go

View check run for this annotation

Codecov / codecov/patch

cmd/skill/skill.go#L50-L51

Added lines #L50 - L51 were not covered by tests
},
}
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) {

Check warning on line 63 in cmd/skill/skill.go

View check run for this annotation

Codecov / codecov/patch

cmd/skill/skill.go#L63

Added line #L63 was not covered by tests
// Sanitize inputs to prevent path traversal.
if strings.ContainsAny(skillName, "/\\..") || strings.ContainsAny(name, "/\\..") {
return "", fmt.Errorf("invalid skill or reference name")

Check warning on line 66 in cmd/skill/skill.go

View check run for this annotation

Codecov / codecov/patch

cmd/skill/skill.go#L65-L66

Added lines #L65 - L66 were not covered by tests
}

relPath := filepath.Join("skills", skillName, "references", name+".md")

Check warning on line 69 in cmd/skill/skill.go

View check run for this annotation

Codecov / codecov/patch

cmd/skill/skill.go#L69

Added line #L69 was not covered by tests

for _, dir := range candidateDirs() {
fullPath := filepath.Join(dir, relPath)
data, err := os.ReadFile(fullPath)
if err == nil {
Comment on lines +69 to +74
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate and read user-derived paths via validate.SafeInputPath + vfs.*.

fullPath is built from untrusted CLI args (skillName, name) and then read with os.ReadFile. This bypasses required path validation and the repo’s filesystem abstraction.

As per coding guidelines: "Use vfs.* instead of os.* for all filesystem access" and "Validate paths before reading with validate.SafeInputPath because CLI arguments are untrusted (they come from AI agents)".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/skill/skill.go` around lines 69 - 74, relPath and fullPath are built from
untrusted CLI inputs and then read with os.ReadFile; replace this with path
validation and the repo VFS: call validate.SafeInputPath on the user-derived
path (e.g., validate.SafeInputPath(relPath) or on skillName/name before building
relPath) and skip the candidate when validation fails, then use the project's
virtual FS API (vfs.ReadFile or the repo's vfs.* equivalent) instead of
os.ReadFile to open/read fullPath; keep the loop over candidateDirs() and use
the same relPath/fullPath symbols so the change is localized to validation and
replacing os.ReadFile with vfs.ReadFile.

return string(data), nil

Check warning on line 75 in cmd/skill/skill.go

View check run for this annotation

Codecov / codecov/patch

cmd/skill/skill.go#L71-L75

Added lines #L71 - L75 were not covered by tests
}
if !errors.Is(err, fs.ErrNotExist) {
return "", fmt.Errorf("reading skill reference: %w", err)

Check warning on line 78 in cmd/skill/skill.go

View check run for this annotation

Codecov / codecov/patch

cmd/skill/skill.go#L77-L78

Added lines #L77 - L78 were not covered by tests
}
}

return "", fmt.Errorf("skill reference not found: %s/%s (skill: %s, name: %s)",
skillName, name, skillName, name)

Check warning on line 83 in cmd/skill/skill.go

View check run for this annotation

Codecov / codecov/patch

cmd/skill/skill.go#L82-L83

Added lines #L82 - L83 were not covered by tests
}

// 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

Check warning on line 94 in cmd/skill/skill.go

View check run for this annotation

Codecov / codecov/patch

cmd/skill/skill.go#L93-L94

Added lines #L93 - L94 were not covered by tests

if env := os.Getenv("LARKSUITE_CLI_SKILLS_DIR"); env != "" {
dirs = append(dirs, env)

Check warning on line 97 in cmd/skill/skill.go

View check run for this annotation

Codecov / codecov/patch

cmd/skill/skill.go#L96-L97

Added lines #L96 - L97 were not covered by tests
}

exe, err := os.Executable()
if err == nil {
binDir := filepath.Dir(exe)
dirs = append(dirs,
binDir,
filepath.Join(binDir, ".."),
)

Check warning on line 106 in cmd/skill/skill.go

View check run for this annotation

Codecov / codecov/patch

cmd/skill/skill.go#L100-L106

Added lines #L100 - L106 were not covered by tests
}

return dirs

Check warning on line 109 in cmd/skill/skill.go

View check run for this annotation

Codecov / codecov/patch

cmd/skill/skill.go#L109

Added line #L109 was not covered by tests
}
160 changes: 160 additions & 0 deletions shortcuts/mail/mail_rule_reorder.go
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")

Check warning on line 32 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L29-L32

Added lines #L29 - L32 were not covered by tests
}
ids, err := parseRuleIDs(raw)
if err != nil {
return err

Check warning on line 36 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L34-L36

Added lines #L34 - L36 were not covered by tests
}
if len(ids) == 0 {
return output.ErrValidation("--rule-ids: must provide at least one rule ID")

Check warning on line 39 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L38-L39

Added lines #L38 - L39 were not covered by tests
}
return nil

Check warning on line 41 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L41

Added line #L41 was not covered by tests
},
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Dry-run does not preview the reorder write request.

DryRun only records the list (GET) step. It currently misses the reorder (POST .../rules/reorder) intent, so users cannot preview the write operation path/payload in dry-run mode.

💡 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/mail/mail_rule_reorder.go` around lines 43 - 49, DryRun currently
only records the GET of rules; update the DryRun lambda (the DryRun function
that returns common.NewDryRunAPI) to also record the intended reorder write by
chaining a POST to mailboxPath(mailboxID, "rules/reorder") so the dry-run shows
the write path and payload; include the same merged/user rule ids from runtime
(e.g., runtime.Str("rule-ids") or the appropriate merged id key) in the POST
recording (using the DryRun API's Set/Body method) so users can preview the
reorder request alongside the GET.

Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
mailboxID := resolveMailboxID(runtime)
userIDs, err := parseRuleIDs(runtime.Str("rule-ids"))
if err != nil {
return err

Check warning on line 54 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L43-L54

Added lines #L43 - L54 were not covered by tests
}

// Step 1: list current rules
listResp, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "rules"), nil, nil)
if err != nil {
return err

Check warning on line 60 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L58-L60

Added lines #L58 - L60 were not covered by tests
}
currentIDs, err := extractRuleIDs(listResp)
if err != nil {
return err

Check warning on line 64 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L62-L64

Added lines #L62 - L64 were not covered by tests
}

// Step 2: validate all user IDs exist in the current list
if err := validateRuleIDsExist(userIDs, currentIDs); err != nil {
return err

Check warning on line 69 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L68-L69

Added lines #L68 - L69 were not covered by tests
}

// Step 3: slot-replacement merge
mergedIDs := slotReplace(currentIDs, userIDs)

Check warning on line 73 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L73

Added line #L73 was not covered by tests

// 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)

Check warning on line 79 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L76-L79

Added lines #L76 - L79 were not covered by tests
}

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

Check warning on line 86 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L82-L86

Added lines #L82 - L86 were not covered by tests
},
}

// 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

Check warning on line 95 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L92-L95

Added lines #L92 - L95 were not covered by tests
}
slots := make([]int, 0, len(userIDs))
for i, id := range currentIDs {
if userSet[id] {
slots = append(slots, i)

Check warning on line 100 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L97-L100

Added lines #L97 - L100 were not covered by tests
}
}
result := make([]string, len(currentIDs))
copy(result, currentIDs)
for j, slot := range slots {
result[slot] = userIDs[j]

Check warning on line 106 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L103-L106

Added lines #L103 - L106 were not covered by tests
}
return result

Check warning on line 108 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L108

Added line #L108 was not covered by tests
}

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

Check warning on line 118 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L111-L118

Added lines #L111 - L118 were not covered by tests
}
if seen[id] {
return nil, output.ErrValidation("--rule-ids: duplicate rule ID %q", id)

Check warning on line 121 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L120-L121

Added lines #L120 - L121 were not covered by tests
}
seen[id] = true
result = append(result, id)

Check warning on line 124 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L123-L124

Added lines #L123 - L124 were not covered by tests
}
return result, nil

Check warning on line 126 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L126

Added line #L126 was not covered by tests
}

func validateRuleIDsExist(userIDs, currentIDs []string) error {
currentSet := make(map[string]bool, len(currentIDs))
for _, id := range currentIDs {
currentSet[id] = true

Check warning on line 132 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L129-L132

Added lines #L129 - L132 were not covered by tests
}
for _, id := range userIDs {
if !currentSet[id] {
return output.ErrValidation("--rule-ids: rule ID %q not found in mailbox", id)

Check warning on line 136 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L134-L136

Added lines #L134 - L136 were not covered by tests
}
}
return nil

Check warning on line 139 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L139

Added line #L139 was not covered by tests
}

func extractRuleIDs(resp map[string]interface{}) ([]string, error) {

Check warning on line 142 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L142

Added line #L142 was not covered by tests
// CallAPI/HandleApiResult already extracts the "data" field, so resp is directly {"items": [...]}.
items, ok := resp["items"].([]interface{})
if !ok {
return []string{}, nil

Check warning on line 146 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L144-L146

Added lines #L144 - L146 were not covered by tests
}
ids := make([]string, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue

Check warning on line 152 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L148-L152

Added lines #L148 - L152 were not covered by tests
}
id, _ := m["id"].(string)
if id != "" {
ids = append(ids, id)

Check warning on line 156 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L154-L156

Added lines #L154 - L156 were not covered by tests
}
}
return ids, nil

Check warning on line 159 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L159

Added line #L159 was not covered by tests
}
1 change: 1 addition & 0 deletions shortcuts/mail/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@
MailShareToChat,
MailTemplateCreate,
MailTemplateUpdate,
MailRuleReorder,

Check warning on line 28 in shortcuts/mail/shortcuts.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/shortcuts.go#L28

Added line #L28 was not covered by tests
}
}
Loading
Loading