Compare commits

..

4 Commits

Author SHA1 Message Date
huangmengxuan
0ff9c84cd8 feat(skill): add relative-path-only rule to lark-shared security section
lark-cli rejects absolute paths for --file, --output, --output-dir,
and @file with 'unsafe file path'. Document this in lark-shared so
agents know to use cwd-relative paths or stdin for data input.

Change-Id: I50cf801c2c5d0e3cbb98a76e1752d410518c8636
2026-06-03 13:59:06 +08:00
huangmengxuan
175c9f6ffc feat(skill): add CRITICAL instruction to lark-task, lark-contact, lark-slides
Same enforcement as the previous commit — require reading reference
docs (or -h) before calling shortcuts. These three skills use
non-standard section headers but still have shortcut tables with
reference links.

Change-Id: I5170cc763c15e3030c4117a36af36c9f1e94501e
2026-06-03 13:59:06 +08:00
huangmengxuan
575bcc407b feat(skill): add CRITICAL instruction to enforce reference reading before shortcut execution
Evaluation data shows AI call failure rate <1% when reference docs are
read vs ~29% when not. Add a CRITICAL line to the Shortcuts section of
14 SKILL.md files and the skill template, requiring agents to read the
linked reference doc (or run -h for commands without one) before
invoking any shortcut.

Change-Id: Ia4204518eb43a9f6c8295b95633ee5d9cf2f5352
2026-06-03 13:59:06 +08:00
MaxHuang22
24ce3ec151 feat: add --json flag as no-op alias for --format json (#1104)
* feat(api): add --json flag as no-op alias for --format json

* feat(service): add --json flag as no-op alias for --format json

* feat(shortcut): add --json flag as no-op alias for --format json

Skip registration when a custom --json flag already exists on the
command (e.g. base shortcuts use --json for body input).

Change-Id: If66236cadeea7fa81811061cce775deff51b92ce
2026-06-03 13:58:14 +08:00
59 changed files with 265 additions and 3575 deletions

View File

@@ -90,6 +90,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")

View File

@@ -718,3 +718,23 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
}
}
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("--json should be accepted without error, got: %v", err)
}
if gotOpts.Method != "GET" {
t.Errorf("expected method GET, got %s", gotOpts.Method)
}
}

View File

@@ -527,10 +527,10 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
if domainSet[sc.GetService()] && shortcutSupportsIdentity(sc, identity) {
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
for _, s := range sc.DeclaredScopesForIdentity(identity) {
scopeSet[s] = true
}
@@ -557,11 +557,11 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
}
}
for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
if !registry.HasAuthDomain(sc.GetService()) {
domains[sc.GetService()] = true
if !registry.HasAuthDomain(sc.Service) {
domains[sc.Service] = true
}
}
return domains
@@ -580,8 +580,8 @@ func sortedKnownDomains(brand core.LarkBrand) []string {
// shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot").
// Empty AuthTypes defaults to ["user"].
func shortcutSupportsIdentity(sc common.ShortcutDescriptor, identity string) bool {
authTypes := sc.GetAuthTypes()
func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
authTypes := sc.AuthTypes
if len(authTypes) == 0 {
authTypes = []string{"user"}
}

View File

@@ -64,13 +64,12 @@ func getDomainMetadata(lang string) []domainMeta {
shortcutOnlySet[n] = true
}
for _, sc := range shortcuts.AllShortcuts() {
svc := sc.GetService()
if !seen[svc] {
if shortcutOnlySet[svc] && !registry.HasAuthDomain(svc) {
dm := buildDomainMeta(svc, lang)
if !seen[sc.Service] {
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
dm := buildDomainMeta(sc.Service, lang)
domains = append(domains, dm)
}
seen[svc] = true
seen[sc.Service] = true
}
}

View File

@@ -98,7 +98,7 @@ func TestNormalizeScopeInput(t *testing.T) {
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
// Empty AuthTypes defaults to ["user"]
sc := &common.Shortcut{AuthTypes: nil}
sc := common.Shortcut{AuthTypes: nil}
if !shortcutSupportsIdentity(sc, "user") {
t.Error("expected default to support 'user'")
}
@@ -108,7 +108,7 @@ func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
}
func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
sc := &common.Shortcut{AuthTypes: []string{"user", "bot"}}
sc := common.Shortcut{AuthTypes: []string{"user", "bot"}}
if !shortcutSupportsIdentity(sc, "user") {
t.Error("expected to support 'user'")
}
@@ -121,7 +121,7 @@ func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
}
func TestShortcutSupportsIdentity_BotOnly(t *testing.T) {
sc := &common.Shortcut{AuthTypes: []string{"bot"}}
sc := common.Shortcut{AuthTypes: []string{"bot"}}
if shortcutSupportsIdentity(sc, "user") {
t.Error("expected bot-only to NOT support 'user'")
}

View File

@@ -47,8 +47,8 @@ func diagAllKnownDomains() []string {
seen[p] = true
}
for _, s := range shortcuts.AllShortcuts() {
if s.GetService() != "" {
seen[s.GetService()] = true
if s.Service != "" {
seen[s.Service] = true
}
}
result := make([]string, 0, len(seen))
@@ -94,17 +94,17 @@ func diagBuild(domains []string) diagOutput {
}
for _, sc := range allSC {
if sc.GetService() != domain || !diagShortcutSupportsIdentity(sc, identity) {
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
continue
}
for _, scope := range sc.DeclaredScopesForIdentity(identity) {
k := methodKey{domain, "shortcut", sc.GetCommand(), scope}
k := methodKey{domain, "shortcut", sc.Command, scope}
if e, ok := merged[k]; ok {
e.Identity = appendUniq(e.Identity, identity)
} else {
merged[k] = &diagMethodEntry{
Domain: domain, Type: "shortcut",
Method: sc.GetCommand(),
Method: sc.Command,
Scope: scope, Identity: []string{identity},
}
}
@@ -148,12 +148,11 @@ func diagBuild(domains []string) diagOutput {
return diagOutput{Methods: methods, Scopes: scopes}
}
func diagShortcutSupportsIdentity(sc shortcutTypes.ShortcutDescriptor, identity string) bool {
authTypes := sc.GetAuthTypes()
if len(authTypes) == 0 {
func diagShortcutSupportsIdentity(sc *shortcutTypes.Shortcut, identity string) bool {
if len(sc.AuthTypes) == 0 {
return identity == "user"
}
for _, a := range authTypes {
for _, a := range sc.AuthTypes {
if a == identity {
return true
}

View File

@@ -105,7 +105,7 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
service := cmd.Parent().Name()
for _, sc := range shortcuts.AllShortcuts() {
if sc.GetService() != service || sc.GetCommand() != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
continue
}
scopes := sc.DeclaredScopesForIdentity(identity)
@@ -154,8 +154,8 @@ func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []s
// shortcutSupportsIdentity reports whether a shortcut supports the requested
// identity, applying the default user-only behavior when AuthTypes is empty.
func shortcutSupportsIdentity(sc shortcutcommon.ShortcutDescriptor, identity string) bool {
authTypes := sc.GetAuthTypes()
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
authTypes := sc.AuthTypes
if len(authTypes) == 0 {
authTypes = []string{string(core.AsUser)}
}

View File

@@ -180,6 +180,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
if risk == "high-risk-write" {

View File

@@ -765,3 +765,22 @@ func TestDetectFileFields(t *testing.T) {
})
}
}
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
})
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--json should be accepted without error, got: %v", err)
}
if captured == nil {
t.Fatal("expected runF to be called")
}
}

View File

@@ -1,14 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
// Subtypes raised by the typed shortcut protocol (shortcuts/common). Only
// cross-field semantic failures need their own subtype here; per-field
// failures (required missing / enum invalid / typed-primitive format) reuse
// SubtypeInvalidArgument.
const (
SubtypeShortcutOneOfMissing Subtype = "shortcut_oneof_missing"
SubtypeShortcutOneOfMultiple Subtype = "shortcut_oneof_multiple"
SubtypeShortcutGroupIncomplete Subtype = "shortcut_group_incomplete"
)

View File

@@ -1,25 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import "testing"
func TestShortcutSubtypes_Values(t *testing.T) {
tests := []struct {
name string
got Subtype
want string
}{
{"OneOfMissing", SubtypeShortcutOneOfMissing, "shortcut_oneof_missing"},
{"OneOfMultiple", SubtypeShortcutOneOfMultiple, "shortcut_oneof_multiple"},
{"GroupIncomplete", SubtypeShortcutGroupIncomplete, "shortcut_group_incomplete"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if string(tt.got) != tt.want {
t.Errorf("got %q, want %q", string(tt.got), tt.want)
}
})
}
}

View File

@@ -1,44 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// ChatID is a typed Lark chat identifier with the "oc_" prefix.
// Satisfies common.Validatable.
type ChatID string
// ValidateValue checks the oc_ prefix and trims whitespace. Empty values are
// rejected here even though required-ness is enforced by the binder; this
// keeps the type safe to call as a standalone validator.
func (id ChatID) ValidateValue(_ *common.RuntimeContext, flagName string) error {
s := strings.TrimSpace(string(id))
if s == "" {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "chat ID is required",
Hint: "pass --chat-id oc_xxx",
},
Param: flagName,
}
}
if !strings.HasPrefix(s, "oc_") {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "invalid chat ID format: expected oc_xxx",
},
Param: flagName,
}
}
return nil
}

View File

@@ -1,47 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
func TestChatID_ValidatePass(t *testing.T) {
id := ChatID("oc_abc123")
if err := id.ValidateValue(nil, "chat-id"); err != nil {
t.Errorf("oc_ prefix should pass, got: %v", err)
}
}
func TestChatID_ValidateReject(t *testing.T) {
tests := []struct {
name string
v string
}{
{"empty", ""},
{"wrong prefix", "ou_abc"},
{"random", "abc123"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ChatID(tt.v).ValidateValue(nil, "chat-id")
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want invalid_argument", ve.Subtype)
}
if ve.Param != "chat-id" {
t.Errorf("Param = %q, want chat-id", ve.Param)
}
})
}
}

View File

@@ -1,38 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// MediaInput is the tri-state media-field value used by im image/file/video/
// audio flags: URL, "img_xxx"/"file_xxx" key, or cwd-relative local path.
// URL and key forms bypass path safety checks; local paths go through the
// same SafePath rules. Does not emit absolute paths in hints (log safety).
type MediaInput string
// IsURL reports whether the value looks like an http(s) URL.
func (m MediaInput) IsURL() bool {
s := string(m)
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
// IsMediaKey reports whether the value is an already-uploaded media key.
func (m MediaInput) IsMediaKey() bool {
s := string(m)
return strings.HasPrefix(s, "img_") || strings.HasPrefix(s, "file_")
}
func (m MediaInput) ValidateValue(rt *common.RuntimeContext, flagName string) error {
if string(m) == "" {
return nil
}
if m.IsURL() || m.IsMediaKey() {
return nil
}
return SafePath(m).ValidateValue(rt, flagName)
}

View File

@@ -1,36 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"testing"
)
func TestMediaInput_AcceptURL(t *testing.T) {
for _, v := range []string{"https://example.com/x.png", "http://a.b/y"} {
if err := MediaInput(v).ValidateValue(nil, "image"); err != nil {
t.Errorf("URL %q should pass: %v", v, err)
}
}
}
func TestMediaInput_AcceptKey(t *testing.T) {
for _, v := range []string{"img_abc123", "file_xyz"} {
if err := MediaInput(v).ValidateValue(nil, "image"); err != nil {
t.Errorf("key %q should pass: %v", v, err)
}
}
}
func TestMediaInput_RejectAbsolutePath(t *testing.T) {
if err := MediaInput("/etc/passwd").ValidateValue(nil, "image"); err == nil {
t.Fatal("absolute path must be rejected")
}
}
func TestMediaInput_AcceptRelativePath(t *testing.T) {
if err := MediaInput("./pic.png").ValidateValue(nil, "image"); err != nil {
t.Errorf("relative path should pass: %v", err)
}
}

View File

@@ -1,63 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// SafePath is a strict cwd-relative file path. Absolute paths and ".."
// segments are rejected. Does NOT emit absolute path back to stderr in any
// hint (log safety).
type SafePath string
func (p SafePath) ValidateValue(_ *common.RuntimeContext, flagName string) error {
s := string(p)
if s == "" {
return nil
}
if filepath.IsAbs(s) {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "path must be cwd-relative; absolute paths are rejected",
Hint: "use a relative path like ./file.txt",
},
Param: flagName,
}
}
// Check the RAW input for ".." segments before filepath.Clean collapses
// them — Clean turns "a/../b" into "b", which would otherwise hide a
// parent-traversal segment the user actually typed. Split on both
// separators so "\.." on Windows-style input is caught too.
for _, seg := range strings.FieldsFunc(s, func(r rune) bool { return r == '/' || r == '\\' }) {
if seg == ".." {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "path must not contain '..' segments",
},
Param: flagName,
}
}
}
clean := filepath.Clean(s)
if strings.HasPrefix(clean, "..") || strings.Contains(clean, "/../") || strings.Contains(clean, `\..\`) {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "path must not contain '..' segments",
},
Param: flagName,
}
}
return nil
}

View File

@@ -1,47 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
func TestSafePath_AcceptRelative(t *testing.T) {
for _, p := range []string{"local.txt", "./sub/dir/x", "a/b/c"} {
if err := SafePath(p).ValidateValue(nil, "file"); err != nil {
t.Errorf("%q should pass, got: %v", p, err)
}
}
}
func TestSafePath_RejectAbsolute(t *testing.T) {
err := SafePath("/etc/passwd").ValidateValue(nil, "file")
if err == nil {
t.Fatal("absolute path must be rejected")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "file" {
t.Errorf("wrong wrap: %v", err)
}
}
func TestSafePath_RejectDotDot(t *testing.T) {
err := SafePath("../leak").ValidateValue(nil, "file")
if err == nil {
t.Fatal("'..' segment must be rejected")
}
}
func TestSafePath_RejectMidPathDotDot(t *testing.T) {
// filepath.Clean collapses "a/../b" to "b"; the raw-segment scan must
// still reject it because the user literally typed a parent segment.
for _, p := range []string{"a/../b", "sub/../../etc", `win\..\x`} {
if err := SafePath(p).ValidateValue(nil, "file"); err == nil {
t.Errorf("%q should be rejected (raw .. segment)", p)
}
}
}

View File

@@ -1,74 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"context"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// SpreadsheetRef is a Lark Sheets reference: either a raw "shtcn..." token
// or a feishu.cn/larksuite.com URL containing one. Normalize extracts the
// token from URLs; ValidateValue checks the final token shape. URL→token is
// a business-canonicalisation hint and is safe to emit to stderr.
type SpreadsheetRef string
func (s SpreadsheetRef) Normalize(_ context.Context, raw string) (SpreadsheetRef, []string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", nil, nil
}
if !strings.Contains(trimmed, "://") {
return SpreadsheetRef(trimmed), nil, nil
}
for _, seg := range strings.Split(trimmed, "/") {
if strings.HasPrefix(seg, "shtcn") {
// Strip any ?query or #fragment suffix so a URL like
// .../shtcnXXX?sheet=0#row=5 yields a clean token, not one
// polluted by trailing parameters that would later pass the
// prefix-only ValidateValue check.
token := seg
if i := strings.IndexAny(token, "?#"); i >= 0 {
token = token[:i]
}
return SpreadsheetRef(token), []string{"extracted spreadsheet token from URL"}, nil
}
}
return "", nil, &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "URL does not contain a recognisable spreadsheet token",
},
Param: "",
}
}
func (s SpreadsheetRef) ValidateValue(_ *common.RuntimeContext, flagName string) error {
v := strings.TrimSpace(string(s))
if v == "" {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "spreadsheet reference is required",
},
Param: flagName,
}
}
if !strings.HasPrefix(v, "shtcn") {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "spreadsheet token must start with 'shtcn'",
},
Param: flagName,
}
}
return nil
}

View File

@@ -1,64 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"context"
"testing"
)
func TestSpreadsheetRef_NormalizeFromURL(t *testing.T) {
url := "https://feishu.cn/sheets/shtcnAbCdEf01234567"
v, hints, err := SpreadsheetRef(url).Normalize(context.Background(), url)
if err != nil {
t.Fatalf("Normalize error: %v", err)
}
if string(v) != "shtcnAbCdEf01234567" {
t.Errorf("expected extracted token, got %q", v)
}
if len(hints) == 0 {
t.Errorf("expected at least one hint about URL extraction")
}
}
func TestSpreadsheetRef_NormalizeStripsQueryAndFragment(t *testing.T) {
for _, url := range []string{
"https://feishu.cn/sheets/shtcnAbCdEf01234567?sheet=0",
"https://feishu.cn/sheets/shtcnAbCdEf01234567#row=5",
"https://feishu.cn/sheets/shtcnAbCdEf01234567?sheet=0#row=5",
} {
v, _, err := SpreadsheetRef(url).Normalize(context.Background(), url)
if err != nil {
t.Fatalf("Normalize(%q) error: %v", url, err)
}
if string(v) != "shtcnAbCdEf01234567" {
t.Errorf("Normalize(%q): expected clean token, got %q", url, v)
}
}
}
func TestSpreadsheetRef_NormalizePassThroughToken(t *testing.T) {
v, hints, err := SpreadsheetRef("shtcnXyz").Normalize(context.Background(), "shtcnXyz")
if err != nil {
t.Fatalf("Normalize error: %v", err)
}
if string(v) != "shtcnXyz" {
t.Errorf("raw token should pass through, got %q", v)
}
if len(hints) != 0 {
t.Errorf("no hint expected for raw token, got %v", hints)
}
}
func TestSpreadsheetRef_ValidateValueRejectsEmpty(t *testing.T) {
if err := SpreadsheetRef("").ValidateValue(nil, "spreadsheet"); err == nil {
t.Fatal("empty value should fail validation")
}
}
func TestSpreadsheetRef_ValidateValueAcceptsToken(t *testing.T) {
if err := SpreadsheetRef("shtcnAbCd").ValidateValue(nil, "spreadsheet"); err != nil {
t.Errorf("token should pass: %v", err)
}
}

View File

@@ -1,40 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// UserOpenID is a typed Lark user identifier with the "ou_" prefix.
type UserOpenID string
func (id UserOpenID) ValidateValue(_ *common.RuntimeContext, flagName string) error {
s := strings.TrimSpace(string(id))
if s == "" {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "user open_id is required",
Hint: "pass --user-id ou_xxx",
},
Param: flagName,
}
}
if !strings.HasPrefix(s, "ou_") {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "invalid user open_id format: expected ou_xxx",
},
Param: flagName,
}
}
return nil
}

View File

@@ -1,31 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
func TestUserOpenID_ValidatePass(t *testing.T) {
if err := UserOpenID("ou_abc").ValidateValue(nil, "user-id"); err != nil {
t.Errorf("ou_ prefix should pass, got: %v", err)
}
}
func TestUserOpenID_ValidateReject(t *testing.T) {
for _, v := range []string{"", "oc_abc", "abc"} {
err := UserOpenID(v).ValidateValue(nil, "user-id")
if err == nil {
t.Errorf("expected error for %q", v)
continue
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "user-id" {
t.Errorf("wrong wrap for %q: %v", v, err)
}
}
}

View File

@@ -1,795 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"fmt"
"reflect"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
)
// fieldSpec is the binder's intermediate representation of one Args field.
// It captures both reflection metadata and the parsed tag values so later
// binder stages don't repeat the parsing work.
type fieldSpec struct {
GoFieldName string
FlagName string
Description string
DefaultValue string
EnumValues []string
Input []string
Required bool
Hidden bool
NoSplit bool
IsOneOfBkt bool
IsGroup bool
IsPtr bool
FieldType reflect.Type
StructType reflect.Type
}
// walkArgs reflects an Args struct (must be *T where T is struct) and
// returns one fieldSpec per top-level field. Duplicate flag tags inside
// the same Args struct panic at Mount time — cross-shortcut duplicates are
// not checked (cobra's own per-command Add check covers that).
func walkArgs(t reflect.Type) ([]fieldSpec, error) {
if t.Kind() != reflect.Ptr || t.Elem().Kind() != reflect.Struct {
return nil, fmt.Errorf("Args must be *struct, got %s", t)
}
st := t.Elem()
specs := make([]fieldSpec, 0, st.NumField())
seen := map[string]string{}
for i := 0; i < st.NumField(); i++ {
f := st.Field(i)
spec, err := parseFieldSpec(f)
if err != nil {
return nil, err
}
if spec.FlagName != "" {
if owner, dup := seen[spec.FlagName]; dup {
panic(fmt.Sprintf("duplicate flag tag %q in Args struct: fields %s and %s",
spec.FlagName, owner, f.Name))
}
seen[spec.FlagName] = f.Name
}
specs = append(specs, spec)
}
return specs, nil
}
// parseFieldSpec extracts the relevant tag values from one struct field.
// Sub-struct (OneOf bucket / group) detection is delegated to caller stages;
// here we only set flags about the field shape.
func parseFieldSpec(f reflect.StructField) (fieldSpec, error) {
spec := fieldSpec{
GoFieldName: f.Name,
FlagName: f.Tag.Get("flag"),
Description: f.Tag.Get("desc"),
DefaultValue: f.Tag.Get("default"),
}
if enum := f.Tag.Get("enum"); enum != "" {
spec.EnumValues = strings.Split(enum, ",")
}
if _, has := f.Tag.Lookup("required"); has {
spec.Required = true
}
if input := f.Tag.Get("input"); input != "" {
for _, src := range strings.Split(input, ",") {
switch src = strings.TrimSpace(src); src {
case "":
// tolerate stray commas
case File, Stdin:
spec.Input = append(spec.Input, src)
default:
return spec, fmt.Errorf("field %s: unknown input source %q (allowed: %q, %q)", f.Name, src, File, Stdin)
}
}
}
if _, has := f.Tag.Lookup("hidden"); has {
spec.Hidden = true
}
switch split := strings.TrimSpace(f.Tag.Get("split")); split {
case "", "comma":
// default: cobra StringSlice (comma-separated, also repeatable)
case "none":
spec.NoSplit = true // cobra StringArray: repeatable, no comma split
default:
return spec, fmt.Errorf("field %s: unknown split mode %q (allowed: comma, none)", f.Name, split)
}
ft := f.Type
spec.FieldType = ft
if ft.Kind() == reflect.Ptr {
spec.IsPtr = true
ft = ft.Elem()
}
if ft.Kind() == reflect.Struct {
spec.StructType = ft
ptr := reflect.PointerTo(ft)
marker := reflect.TypeOf((*OneOfMarker)(nil)).Elem()
if ft.Implements(marker) || ptr.Implements(marker) {
spec.IsOneOfBkt = true
} else {
spec.IsGroup = true
}
return spec, nil
}
// Leaf field validation. Multi-value flags are []string (cobra
// StringSlice / StringArray); any other slice element type is unsupported.
// enum / input only make sense on plain string leaves (string or a
// string-alias like ChatID); split only on []string. Reject mismatches at
// Mount time instead of silently skipping or panicking at runtime.
isStringSlice := ft.Kind() == reflect.Slice && ft.Elem().Kind() == reflect.String
if ft.Kind() == reflect.Slice && !isStringSlice {
return spec, fmt.Errorf("field %s: only []string slices are supported, got %s", f.Name, ft)
}
if spec.NoSplit && !isStringSlice {
return spec, fmt.Errorf("field %s: split tag is only supported on []string fields", f.Name)
}
if ft.Kind() != reflect.String {
if len(spec.EnumValues) > 0 {
return spec, fmt.Errorf("field %s: enum tag is only supported on string fields", f.Name)
}
if len(spec.Input) > 0 {
return spec, fmt.Errorf("field %s: input tag is only supported on string fields", f.Name)
}
}
return spec, nil
}
// registerFlags registers cobra flags for the given specs. Sub-struct fields
// (OneOf bucket / group) recurse into their inner fields.
func registerFlags(cmd *cobra.Command, specs []fieldSpec) error {
for _, s := range specs {
if s.IsOneOfBkt || s.IsGroup {
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
if err := registerFlags(cmd, inner); err != nil {
return err
}
continue
}
if err := registerLeaf(cmd, s, s.FieldType); err != nil {
return err
}
}
return nil
}
// registerLeaf registers a single primitive flag based on the underlying type.
func registerLeaf(cmd *cobra.Command, s fieldSpec, t reflect.Type) error {
if s.FlagName == "" {
return nil
}
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
switch t.Kind() {
case reflect.Bool:
def := s.DefaultValue == "true"
cmd.Flags().Bool(s.FlagName, def, s.Description)
case reflect.Int, reflect.Int64:
def := 0
if s.DefaultValue != "" {
def, _ = strconv.Atoi(s.DefaultValue)
}
cmd.Flags().Int(s.FlagName, def, s.Description)
case reflect.Slice:
// []string (validated at parse time). NoSplit → StringArray
// (repeatable, literal); default → StringSlice (comma-separated).
if s.NoSplit {
cmd.Flags().StringArray(s.FlagName, nil, s.Description)
} else {
cmd.Flags().StringSlice(s.FlagName, nil, s.Description)
}
default:
cmd.Flags().String(s.FlagName, s.DefaultValue, s.Description)
}
if s.Required {
_ = cmd.MarkFlagRequired(s.FlagName)
}
if s.Hidden {
_ = cmd.Flags().MarkHidden(s.FlagName)
}
if len(s.EnumValues) > 0 {
vals := s.EnumValues
cmdutil.RegisterFlagCompletion(cmd, s.FlagName, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return vals, cobra.ShellCompDirectiveNoFileComp
})
}
return nil
}
// resolveTypedInputs applies @file / stdin resolution to every leaf flag that
// declared an `input:"file,stdin"` tag, recursing into OneOf buckets and groups
// (cobra flags are flat, so a nested variant's flag resolves the same way). It
// runs before bindFlags so the file/stdin content is read back into the cobra
// flag and then bound into the Args struct. This is the typed counterpart of
// runShortcut's resolveInputFlags, which only sees the legacy shell's (empty)
// Flags slice — both ultimately call resolveInputForFlag.
func resolveTypedInputs(rctx *RuntimeContext, specs []fieldSpec) error {
stdinUsed := false
return resolveTypedInputsRec(rctx, specs, &stdinUsed)
}
func resolveTypedInputsRec(rctx *RuntimeContext, specs []fieldSpec, stdinUsed *bool) error {
for _, s := range specs {
if s.IsOneOfBkt || s.IsGroup {
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
if err := resolveTypedInputsRec(rctx, inner, stdinUsed); err != nil {
return err
}
continue
}
if s.FlagName == "" || len(s.Input) == 0 {
continue
}
if err := resolveInputForFlag(rctx, s.FlagName, s.Input, stdinUsed); err != nil {
return err
}
}
return nil
}
// bindFlags writes cobra-parsed values back into the Args struct. argsVal
// is a reflect.Value of the struct (not pointer).
func bindFlags(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
for _, s := range specs {
if s.IsOneOfBkt || s.IsGroup {
continue
}
if s.FlagName == "" {
continue
}
if err := bindLeaf(cmd, argsVal, s); err != nil {
return err
}
}
return nil
}
func bindLeaf(cmd *cobra.Command, argsVal reflect.Value, s fieldSpec) error {
fv := argsVal.FieldByName(s.GoFieldName)
if !fv.CanSet() {
return nil
}
// Pointer leaves preserve "nil = not given" semantics: only allocate when
// the user explicitly set the flag. This mirrors the OneOf bucket
// convention (`Chat *ChatID` — nil means the variant wasn't selected) and
// lets typed shortcuts express tri-state bool / int / string flags using
// plain Go pointers instead of a separate Maybe[T] wrapper type.
if s.IsPtr && !cmd.Flags().Changed(s.FlagName) {
return nil
}
leafType := s.FieldType
if leafType.Kind() == reflect.Ptr {
leafType = leafType.Elem()
}
switch leafType.Kind() {
case reflect.Bool:
v, _ := cmd.Flags().GetBool(s.FlagName)
setLeaf(fv, reflect.ValueOf(v))
case reflect.Int, reflect.Int64:
v, _ := cmd.Flags().GetInt(s.FlagName)
setLeaf(fv, reflect.ValueOf(int64(v)).Convert(leafType))
case reflect.Slice:
var vals []string
if s.NoSplit {
vals, _ = cmd.Flags().GetStringArray(s.FlagName)
} else {
vals, _ = cmd.Flags().GetStringSlice(s.FlagName)
}
setLeaf(fv, stringsToSlice(vals, leafType))
default:
v, _ := cmd.Flags().GetString(s.FlagName)
setLeaf(fv, reflect.ValueOf(v).Convert(leafType))
}
return nil
}
// stringsToSlice builds a slice value of type t (kind Slice with string-kinded
// elements) from raw strings, element-by-element via SetString. This handles a
// plain []string, a named slice type (type IDs []string), AND a named element
// type (type ID string → []ID) — reflect.Convert would panic on the last case
// because []string is not convertible to []ID.
func stringsToSlice(vals []string, t reflect.Type) reflect.Value {
out := reflect.MakeSlice(t, len(vals), len(vals))
for i, v := range vals {
out.Index(i).SetString(v)
}
return out
}
func setLeaf(dst reflect.Value, src reflect.Value) {
if dst.Kind() == reflect.Ptr {
ptr := reflect.New(dst.Type().Elem())
ptr.Elem().Set(src.Convert(dst.Type().Elem()))
dst.Set(ptr)
return
}
dst.Set(src.Convert(dst.Type()))
}
// bindBuckets allocates and populates OneOf bucket / group sub-struct fields
// from cobra flag state. The shared bindFlags() above only writes top-level
// leaves; this function is the framework's recursion into nested Args structs
// so future typed shortcuts don't each have to ship a bespoke binder helper.
//
// Conventions:
// - Pointer-leaf in a bucket (e.g. *string, *argstype.ChatID): set iff
// cobra reports the flag was explicitly provided. nil means "variant not
// selected" — the framework's runFrameworkRules and runValidateValue
// both honor this nil/non-nil split.
// - Non-pointer leaf in a group (e.g. a typed-primitive field inside a
// paired group struct): always copy the cobra flag value back. Empty
// string is a valid "not provided" sentinel for group completeness checks.
// - Pointer-to-group / pointer-to-bucket (a nested group/bucket pointer
// inside an outer OneOf bucket): allocate iff at least one inner flag was
// Changed, then recurse to bind the inner fields.
func bindBuckets(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
for _, s := range specs {
if !s.IsOneOfBkt {
continue
}
fv := argsVal.FieldByName(s.GoFieldName)
if !fv.IsValid() || !fv.CanSet() {
continue
}
target := fv
if target.Kind() == reflect.Ptr {
if target.IsNil() {
target.Set(reflect.New(s.StructType))
}
target = target.Elem()
}
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
if err := bindBucketInner(cmd, target, inner); err != nil {
return err
}
}
return nil
}
// bindGroups is the top-level counterpart to bindBuckets for IsGroup fields
// (regular nested structs without an OneOf() marker). A group's inner flags
// are registered and validated for completeness, but bindFlags above skips
// the field; this function fills the binding gap so an Args struct can place
// a group directly at the top level (not just nested inside an OneOf bucket).
//
// Conventions mirror bindBuckets / bindBucketInner:
// - Value-type group: always populated; inner fields receive cobra flag
// values (including defaults).
// - Pointer-type group: allocated iff at least one inner flag was Changed,
// so a nil group still signals "user did not engage this group at all".
func bindGroups(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
for _, s := range specs {
if !s.IsGroup {
continue
}
fv := argsVal.FieldByName(s.GoFieldName)
if !fv.IsValid() || !fv.CanSet() {
continue
}
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
if fv.Kind() == reflect.Ptr {
anyChanged := false
for _, g := range inner {
if g.FlagName != "" && cmd.Flags().Changed(g.FlagName) {
anyChanged = true
break
}
}
if !anyChanged {
continue
}
if fv.IsNil() {
fv.Set(reflect.New(s.StructType))
}
if err := bindBucketInner(cmd, fv.Elem(), inner); err != nil {
return err
}
continue
}
// Value-type group: populate inner fields directly.
if err := bindBucketInner(cmd, fv, inner); err != nil {
return err
}
}
return nil
}
func bindBucketInner(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
for _, s := range specs {
if s.IsOneOfBkt || s.IsGroup {
grandSpecs, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
anyChanged := false
for _, g := range grandSpecs {
if g.FlagName != "" && cmd.Flags().Changed(g.FlagName) {
anyChanged = true
break
}
}
if !anyChanged {
continue
}
fv := argsVal.FieldByName(s.GoFieldName)
if !fv.IsValid() || !fv.CanSet() {
continue
}
target := fv
if fv.Kind() == reflect.Ptr {
if fv.IsNil() {
fv.Set(reflect.New(s.StructType))
}
target = fv.Elem()
}
if err := bindBucketInner(cmd, target, grandSpecs); err != nil {
return err
}
continue
}
if s.FlagName == "" {
continue
}
fv := argsVal.FieldByName(s.GoFieldName)
if !fv.IsValid() || !fv.CanSet() {
continue
}
if s.IsPtr {
if !cmd.Flags().Changed(s.FlagName) {
continue
}
elemType := fv.Type().Elem()
ptr := reflect.New(elemType)
ptr.Elem().Set(bucketLeafValue(cmd, s.FlagName, elemType, s.NoSplit))
fv.Set(ptr)
continue
}
fv.Set(bucketLeafValue(cmd, s.FlagName, fv.Type(), s.NoSplit))
}
return nil
}
// bucketLeafValue reads a single cobra flag and returns it as a reflect.Value
// convertible to targetType. It dispatches on the underlying kind so nested
// bucket/group leaves typed as bool or int bind correctly instead of being
// force-read through GetString (which would panic on reflect conversion of a
// string into a numeric/bool type).
func bucketLeafValue(cmd *cobra.Command, flagName string, targetType reflect.Type, noSplit bool) reflect.Value {
kind := targetType.Kind()
if kind == reflect.Ptr {
kind = targetType.Elem().Kind()
}
switch kind {
case reflect.Bool:
v, _ := cmd.Flags().GetBool(flagName)
return reflect.ValueOf(v).Convert(targetType)
case reflect.Int, reflect.Int64:
v, _ := cmd.Flags().GetInt(flagName)
return reflect.ValueOf(int64(v)).Convert(targetType)
case reflect.Slice:
var vals []string
if noSplit {
vals, _ = cmd.Flags().GetStringArray(flagName)
} else {
vals, _ = cmd.Flags().GetStringSlice(flagName)
}
return stringsToSlice(vals, targetType)
default:
v, _ := cmd.Flags().GetString(flagName)
return reflect.ValueOf(v).Convert(targetType)
}
}
// runNormalize invokes the Normalize method (via reflection) on every field
// whose type implements Normalizable[T]. The canonical value is written back.
// Plan's static type assertion can't work because Normalizable is generic —
// the method's return type is the typed T, not any — so we dispatch by
// reflection on method shape.
//
// Local-path normalization hints are dropped at the primitive layer; only
// non-path hints reach stderr (log safety).
func runNormalize(ctx context.Context, rt *RuntimeContext, argsVal reflect.Value, specs []fieldSpec) error {
ctxType := reflect.TypeOf((*context.Context)(nil)).Elem()
for _, s := range specs {
fv := argsVal.FieldByName(s.GoFieldName)
if !fv.IsValid() {
continue
}
m := fv.MethodByName("Normalize")
if !m.IsValid() {
continue
}
mt := m.Type()
if mt.NumIn() != 2 || mt.NumOut() != 3 {
continue
}
if !ctxType.AssignableTo(mt.In(0)) || mt.In(1).Kind() != reflect.String {
continue
}
raw := asString(fv)
rets := m.Call([]reflect.Value{reflect.ValueOf(ctx), reflect.ValueOf(raw)})
if errRet := rets[2]; !errRet.IsNil() {
return errRet.Interface().(error)
}
canon := rets[0]
if fv.CanSet() && canon.Type().AssignableTo(fv.Type()) {
fv.Set(canon)
}
hints, _ := rets[1].Interface().([]string)
if len(hints) > 0 && rt != nil && rt.Cmd != nil {
for _, h := range hints {
_, _ = rt.Cmd.ErrOrStderr().Write([]byte(h + "\n"))
}
}
}
return nil
}
func asString(fv reflect.Value) string {
if fv.Kind() == reflect.String {
return fv.String()
}
if fv.Kind() == reflect.Ptr && !fv.IsNil() {
return asString(fv.Elem())
}
return ""
}
// runValidateValue calls ValidateValue on every Validatable field, recursing
// into OneOf bucket / group sub-structs so typed-primitive leaves inside
// nested Args structs (e.g. a typed ID primitive inside a OneOf bucket) still
// get their format check. Returns the first error to keep error envelopes
// deterministic.
func runValidateValue(rt *RuntimeContext, argsVal reflect.Value, specs []fieldSpec) error {
for _, s := range specs {
fv := argsVal.FieldByName(s.GoFieldName)
if !fv.IsValid() {
continue
}
if s.IsOneOfBkt || s.IsGroup {
structVal := fv
if structVal.Kind() == reflect.Ptr {
if structVal.IsNil() {
continue
}
structVal = structVal.Elem()
}
// Some buckets/groups implement Validatable themselves (e.g. a
// raw-JSON variant that checks JSON validity in its ValidateValue).
// Call the struct-level check BEFORE recursing into inner fields so
// the cross-field rule fires even when none of the inner leaves are
// individually Validatable.
if fv.CanInterface() {
if val, ok := fv.Interface().(Validatable); ok {
if err := val.ValidateValue(rt, s.FlagName); err != nil {
return err
}
}
}
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
if err := runValidateValue(rt, structVal, inner); err != nil {
return err
}
continue
}
// Leaf field. Skip nil pointers (variant not selected).
if fv.Kind() == reflect.Ptr && fv.IsNil() {
continue
}
if !fv.CanInterface() {
continue
}
v := fv.Interface()
if val, ok := v.(Validatable); ok {
if err := val.ValidateValue(rt, s.FlagName); err != nil {
return err
}
continue
}
// Pointer leaf: dereference and re-check (for value-receiver
// ValidateValue methods on the pointee type).
if fv.Kind() == reflect.Ptr {
if val, ok := fv.Elem().Interface().(Validatable); ok {
if err := val.ValidateValue(rt, s.FlagName); err != nil {
return err
}
}
}
}
return nil
}
// runFrameworkRules enforces OneOf / group / required / enum invariants and
// returns a *errs.ValidationError on the first violation. Each rule's
// stderr-facing param is the Args field name (not the inner struct type name),
// so OneOf bucket errors mention the user-visible field (e.g. "Target") rather
// than the implementation-detail type name behind it.
//
// Recurses into OneOf bucket sub-structs so a nested group inside a bucket
// still gets its checkGroup fire automatically.
func runFrameworkRules(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
for _, s := range specs {
fv := argsVal.FieldByName(s.GoFieldName)
switch {
case s.IsOneOfBkt:
if err := checkOneOf(cmd, fv, s); err != nil {
return err
}
structVal := fv
if structVal.Kind() == reflect.Ptr {
if structVal.IsNil() {
continue
}
structVal = structVal.Elem()
}
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
if err := runFrameworkRules(cmd, structVal, inner); err != nil {
return err
}
case s.IsGroup:
if err := checkGroup(cmd, fv, s); err != nil {
return err
}
default:
if err := checkEnumAndRequired(cmd, fv, s); err != nil {
return err
}
}
}
return nil
}
// checkOneOf counts how many variants the user attempted inside the OneOf
// bucket; exactly one must be attempted. A variant counts as "attempted" if:
// - it's a simple pointer leaf (e.g. *string, *ChatID) and its own flag was
// explicitly provided, or
// - it's a nested group / bucket and ANY of its inner flags was explicitly
// provided. No "trigger" field is required — supplying any flag of a
// group is enough to mark the variant as attempted, and a follow-up
// checkGroup catches the partial-fill case with shortcut_group_incomplete.
func checkOneOf(cmd *cobra.Command, _ reflect.Value, s fieldSpec) error {
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
var triggered []string
for _, child := range inner {
// Simple pointer leaf variant: its own flag is the signal.
if child.IsPtr && !child.IsOneOfBkt && !child.IsGroup {
if child.FlagName != "" && cmd.Flags().Changed(child.FlagName) {
triggered = append(triggered, "--"+child.FlagName)
}
continue
}
// Nested group / bucket variant: any inner flag Changed counts.
if child.IsGroup || child.IsOneOfBkt {
grand, _ := walkArgs(reflect.PointerTo(child.StructType))
for _, g := range grand {
if g.FlagName != "" && cmd.Flags().Changed(g.FlagName) {
triggered = append(triggered, "--"+g.FlagName)
break
}
}
}
}
switch len(triggered) {
case 0:
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeShortcutOneOfMissing,
Message: "exactly one " + s.GoFieldName + " variant must be provided",
},
Param: s.GoFieldName,
}
case 1:
return nil
default:
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeShortcutOneOfMultiple,
Message: "choose only one of " + strings.Join(triggered, ", "),
},
Param: s.GoFieldName,
}
}
}
// checkGroup ensures all fields of a group sub-struct were provided when
// the group's trigger (or first field) was set.
func checkGroup(cmd *cobra.Command, _ reflect.Value, s fieldSpec) error {
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
anySet := false
var missing []string
for _, child := range inner {
if child.FlagName == "" {
continue
}
if cmd.Flags().Changed(child.FlagName) {
anySet = true
continue
}
// Flags with a default value are never "missing" — the default is a
// valid implicit value (e.g. an enum flag that defaults to a value).
// Only flags without defaults need explicit user input when the
// group is partially populated.
if child.DefaultValue != "" {
continue
}
missing = append(missing, "--"+child.FlagName)
}
if anySet && len(missing) > 0 {
// Group errors use the inner struct TYPE name (the group struct's own
// name), not the Args field name. This matches the spec's
// "shortcut_group_incomplete" envelope contract — callers identify
// the *kind* of group that is incomplete, which is the type name.
// OneOf bucket errors use the field name instead (see checkOneOf).
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeShortcutGroupIncomplete,
Message: s.StructType.Name() + " requires " + strings.Join(missing, ", "),
},
Param: s.StructType.Name(),
}
}
return nil
}
// checkEnumAndRequired enforces enum membership. Required-presence is already
// enforced at cobra level via MarkFlagRequired, so this only adds the enum
// check.
func checkEnumAndRequired(cmd *cobra.Command, _ reflect.Value, s fieldSpec) error {
if len(s.EnumValues) == 0 {
return nil
}
v, _ := cmd.Flags().GetString(s.FlagName)
if v == "" && s.DefaultValue == "" {
return nil
}
for _, allowed := range s.EnumValues {
if v == allowed {
return nil
}
}
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "--" + s.FlagName + ": value must be one of " + strings.Join(s.EnumValues, "|"),
},
Param: s.FlagName,
}
}

View File

@@ -1,294 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"bytes"
"context"
"errors"
"reflect"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
)
// --- top-level pointer leaf: nil = not given (mirrors OneOf bucket convention) ---
type ptrLeafArgs struct {
Notify *bool `flag:"notify"`
Limit *int `flag:"limit"`
Name *string `flag:"name"`
}
func TestBindLeaf_PtrNilWhenAbsent(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&ptrLeafArgs{}))
if err := registerFlags(cmd, specs); err != nil {
t.Fatalf("registerFlags: %v", err)
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
out := &ptrLeafArgs{}
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bindFlags: %v", err)
}
if out.Notify != nil || out.Limit != nil || out.Name != nil {
t.Errorf("expected all pointer leaves nil when no flag given; got Notify=%v Limit=%v Name=%v",
out.Notify, out.Limit, out.Name)
}
}
// TestBindLeaf_PtrSetWhenChanged covers the tri-state contract that previously
// required Maybe[T]: a pointer leaf set to its zero value (e.g. --notify=false)
// MUST come back non-nil so business code can tell it apart from "not given".
func TestBindLeaf_PtrSetWhenChanged(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&ptrLeafArgs{}))
_ = registerFlags(cmd, specs)
if err := cmd.ParseFlags([]string{"--notify=false", "--limit", "5", "--name", "alice"}); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
out := &ptrLeafArgs{}
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bindFlags: %v", err)
}
if out.Notify == nil || *out.Notify != false {
t.Errorf("Notify = %v, want non-nil false (tri-state: zero value is still 'set')", out.Notify)
}
if out.Limit == nil || *out.Limit != 5 {
t.Errorf("Limit = %v, want non-nil 5", out.Limit)
}
if out.Name == nil || *out.Name != "alice" {
t.Errorf("Name = %v, want non-nil alice", out.Name)
}
}
// --- OneOf bucket binding: bindBuckets / bindBucketInner / bucketLeafValue ----
type bcLeafBucket struct {
S *string `flag:"lb-s"`
I *int `flag:"lb-i"`
B *bool `flag:"lb-b"`
Plain string `flag:"lb-plain"` // non-pointer leaf exercises the value branch
}
func (bcLeafBucket) OneOf() {}
type bcValueBucketArgs struct {
Sel bcLeafBucket
}
func TestBindBuckets_ValueBucketTypedLeaves(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&bcValueBucketArgs{}))
if err := registerFlags(cmd, specs); err != nil {
t.Fatalf("registerFlags: %v", err)
}
if err := cmd.ParseFlags([]string{"--lb-s", "hi", "--lb-i", "7", "--lb-b", "--lb-plain", "p"}); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
out := &bcValueBucketArgs{}
val := reflect.ValueOf(out).Elem()
if err := bindFlags(cmd, val, specs); err != nil {
t.Fatalf("bindFlags: %v", err)
}
if err := bindBuckets(cmd, val, specs); err != nil {
t.Fatalf("bindBuckets: %v", err)
}
if out.Sel.S == nil || *out.Sel.S != "hi" {
t.Errorf("Sel.S = %v, want hi", out.Sel.S)
}
if out.Sel.I == nil || *out.Sel.I != 7 {
t.Errorf("Sel.I = %v, want 7", out.Sel.I)
}
if out.Sel.B == nil || *out.Sel.B != true {
t.Errorf("Sel.B = %v, want true", out.Sel.B)
}
if out.Sel.Plain != "p" {
t.Errorf("Sel.Plain = %q, want p", out.Sel.Plain)
}
}
type bcPtrBucketArgs struct {
Sel *bcLeafBucket
}
func TestBindBuckets_PointerBucketAllocates(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&bcPtrBucketArgs{}))
_ = registerFlags(cmd, specs)
if err := cmd.ParseFlags([]string{"--lb-s", "x"}); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
out := &bcPtrBucketArgs{}
val := reflect.ValueOf(out).Elem()
if err := bindBuckets(cmd, val, specs); err != nil {
t.Fatalf("bindBuckets: %v", err)
}
if out.Sel == nil {
t.Fatal("Sel pointer was not allocated")
}
if out.Sel.S == nil || *out.Sel.S != "x" {
t.Errorf("Sel.S = %v, want x", out.Sel.S)
}
}
// --- runNormalize / asString --------------------------------------------------
type bcNormField string
func (n bcNormField) Normalize(_ context.Context, raw string) (bcNormField, []string, error) {
if raw == "boom" {
return "", nil, errors.New("normalize failed")
}
return bcNormField("c:" + raw), []string{"hint: " + raw}, nil
}
type bcNormArgs struct {
Token bcNormField `flag:"token"`
TokenPtr *bcNormField `flag:"token-ptr"`
}
func TestRunNormalize_CanonicalizesAndEmitsHints(t *testing.T) {
var buf bytes.Buffer
cmd := &cobra.Command{Use: "test"}
cmd.SetErr(&buf)
rt := &RuntimeContext{Cmd: cmd}
ptr := bcNormField("xy")
args := &bcNormArgs{Token: "raw", TokenPtr: &ptr}
specs, _ := walkArgs(reflect.TypeOf(args))
if err := runNormalize(context.Background(), rt, reflect.ValueOf(args).Elem(), specs); err != nil {
t.Fatalf("runNormalize: %v", err)
}
if args.Token != "c:raw" {
t.Errorf("Token = %q, want c:raw", args.Token)
}
if got := buf.String(); got == "" || !bytes.Contains([]byte(got), []byte("hint: raw")) {
t.Errorf("stderr = %q, want it to contain the normalize hint", got)
}
}
type bcBadNormArgs struct {
Token bcNormField `flag:"token"`
}
func TestRunNormalize_PropagatesError(t *testing.T) {
args := &bcBadNormArgs{Token: "boom"}
specs, _ := walkArgs(reflect.TypeOf(args))
err := runNormalize(context.Background(), nil, reflect.ValueOf(args).Elem(), specs)
if err == nil {
t.Fatal("expected error from Normalize")
}
}
// --- checkGroup (via runFrameworkRules) ---------------------------------------
type bcGroupBody struct {
A string `flag:"g-a"`
B string `flag:"g-b"`
C string `flag:"g-c" default:"x"` // default means never "missing"
}
type bcGroupArgs struct {
Grp bcGroupBody
}
func TestCheckGroup_IncompleteReportsMissing(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&bcGroupArgs{}))
_ = registerFlags(cmd, specs)
// Only --g-a set: B is missing (no default), C has a default so it is fine.
_ = cmd.ParseFlags([]string{"--g-a", "v"})
out := &bcGroupArgs{}
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
if err == nil {
t.Fatal("expected group_incomplete error")
}
ve := mustValidationError(t, err)
if ve.Subtype != errs.SubtypeShortcutGroupIncomplete {
t.Errorf("Subtype = %q, want group_incomplete", ve.Subtype)
}
}
func TestCheckGroup_CompleteAndUntouched(t *testing.T) {
specs, _ := walkArgs(reflect.TypeOf(&bcGroupArgs{}))
// Complete: A and B provided.
cmd1 := &cobra.Command{Use: "t1"}
_ = registerFlags(cmd1, specs)
_ = cmd1.ParseFlags([]string{"--g-a", "1", "--g-b", "2"})
if err := runFrameworkRules(cmd1, reflect.ValueOf(&bcGroupArgs{}).Elem(), specs); err != nil {
t.Errorf("complete group should pass, got %v", err)
}
// Untouched: nothing set → group rule does not fire.
cmd2 := &cobra.Command{Use: "t2"}
_ = registerFlags(cmd2, specs)
_ = cmd2.ParseFlags(nil)
if err := runFrameworkRules(cmd2, reflect.ValueOf(&bcGroupArgs{}).Elem(), specs); err != nil {
t.Errorf("untouched group should pass, got %v", err)
}
}
// --- checkEnumAndRequired (via runFrameworkRules) -----------------------------
type bcEnumArgs struct {
Mode string `flag:"mode" enum:"a,b,c"`
}
func TestCheckEnum(t *testing.T) {
specs, _ := walkArgs(reflect.TypeOf(&bcEnumArgs{}))
// Invalid value.
cmd1 := &cobra.Command{Use: "t1"}
_ = registerFlags(cmd1, specs)
_ = cmd1.ParseFlags([]string{"--mode", "z"})
err := runFrameworkRules(cmd1, reflect.ValueOf(&bcEnumArgs{}).Elem(), specs)
if err == nil {
t.Fatal("expected invalid_argument error for bad enum value")
}
if ve := mustValidationError(t, err); ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want invalid_argument", ve.Subtype)
}
// Valid value.
cmd2 := &cobra.Command{Use: "t2"}
_ = registerFlags(cmd2, specs)
_ = cmd2.ParseFlags([]string{"--mode", "b"})
if err := runFrameworkRules(cmd2, reflect.ValueOf(&bcEnumArgs{}).Elem(), specs); err != nil {
t.Errorf("valid enum value should pass, got %v", err)
}
// Empty (no default) → enum check skipped.
cmd3 := &cobra.Command{Use: "t3"}
_ = registerFlags(cmd3, specs)
_ = cmd3.ParseFlags(nil)
if err := runFrameworkRules(cmd3, reflect.ValueOf(&bcEnumArgs{}).Elem(), specs); err != nil {
t.Errorf("empty enum value should pass, got %v", err)
}
}
// --- runValidateValue recursion into a group sub-struct -----------------------
type bcValGroup struct {
ID dummyValidatable `flag:"vg-id"`
}
type bcValGroupArgs struct {
Grp bcValGroup
}
func TestRunValidateValue_RecursesIntoGroup(t *testing.T) {
args := &bcValGroupArgs{}
specs, _ := walkArgs(reflect.TypeOf(args))
rt := &RuntimeContext{}
if err := runValidateValue(rt, reflect.ValueOf(args).Elem(), specs); err != nil {
t.Errorf("runValidateValue into group: %v", err)
}
}

View File

@@ -1,237 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"os"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
)
// newTypedInputRuntime registers the given specs on a fresh cobra command,
// parses argv, and returns a RuntimeContext wired with a fake stdin — the
// typed-binder analogue of newTestRuntimeWithStdin in runner_input_test.go.
func newTypedInputRuntime(t *testing.T, specs []fieldSpec, argv []string, stdin string) *RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
if err := registerFlags(cmd, specs); err != nil {
t.Fatalf("registerFlags: %v", err)
}
if err := cmd.ParseFlags(argv); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
return &RuntimeContext{
Cmd: cmd,
Factory: &cmdutil.Factory{
IOStreams: &cmdutil.IOStreams{In: strings.NewReader(stdin)},
},
}
}
// --- @file / stdin on typed shortcuts -------------------------------------
type fileInputArgs struct {
Content string `flag:"content" input:"file,stdin"`
}
func TestResolveTypedInputs_File(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
body := "## Title\n\nbody from a file\n"
if err := os.WriteFile("body.md", []byte(body), 0o644); err != nil {
t.Fatal(err)
}
specs, err := walkArgs(reflect.TypeOf(&fileInputArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
rt := newTypedInputRuntime(t, specs, []string{"--content", "@body.md"}, "")
if err := resolveTypedInputs(rt, specs); err != nil {
t.Fatalf("resolveTypedInputs: %v", err)
}
out := &fileInputArgs{}
if err := bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bindFlags: %v", err)
}
if out.Content != body {
t.Errorf("Content = %q, want file body %q", out.Content, body)
}
}
func TestResolveTypedInputs_Stdin(t *testing.T) {
specs, _ := walkArgs(reflect.TypeOf(&fileInputArgs{}))
rt := newTypedInputRuntime(t, specs, []string{"--content", "-"}, "piped stdin body")
if err := resolveTypedInputs(rt, specs); err != nil {
t.Fatalf("resolveTypedInputs: %v", err)
}
out := &fileInputArgs{}
_ = bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs)
if out.Content != "piped stdin body" {
t.Errorf("Content = %q, want stdin body", out.Content)
}
}
func TestResolveTypedInputs_PlainValueUnchanged(t *testing.T) {
specs, _ := walkArgs(reflect.TypeOf(&fileInputArgs{}))
rt := newTypedInputRuntime(t, specs, []string{"--content", "literal text"}, "")
if err := resolveTypedInputs(rt, specs); err != nil {
t.Fatalf("resolveTypedInputs: %v", err)
}
out := &fileInputArgs{}
_ = bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs)
if out.Content != "literal text" {
t.Errorf("Content = %q, want unchanged literal", out.Content)
}
}
// A OneOf variant flag that declares @file/stdin must resolve too — the binder
// recurses into buckets because cobra flags are flat regardless of nesting.
type nestedInputVariant struct {
Body *string `flag:"body" input:"file,stdin"`
Raw *string `flag:"raw"`
}
func (nestedInputVariant) OneOf() {}
type nestedInputArgs struct {
Content nestedInputVariant
}
func TestResolveTypedInputs_NestedInOneOf(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
body := "nested variant body\n"
if err := os.WriteFile("v.md", []byte(body), 0o644); err != nil {
t.Fatal(err)
}
specs, err := walkArgs(reflect.TypeOf(&nestedInputArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
rt := newTypedInputRuntime(t, specs, []string{"--body", "@v.md"}, "")
if err := resolveTypedInputs(rt, specs); err != nil {
t.Fatalf("resolveTypedInputs: %v", err)
}
out := &nestedInputArgs{}
if err := bindBuckets(rt.Cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bindBuckets: %v", err)
}
if out.Content.Body == nil {
t.Fatal("Content.Body is nil — variant not bound")
}
if *out.Content.Body != body {
t.Errorf("Content.Body = %q, want file body %q", *out.Content.Body, body)
}
}
// --- Mount-time validation: enum / input only on string leaves ------------
type enumOnIntArgs struct {
Level int `flag:"level" enum:"1,2,3"`
}
func TestWalkArgs_EnumOnNonStringErrors(t *testing.T) {
_, err := walkArgs(reflect.TypeOf(&enumOnIntArgs{}))
if err == nil {
t.Fatal("expected error for enum on int field")
}
if !strings.Contains(err.Error(), "enum tag is only supported on string") {
t.Errorf("unexpected error: %v", err)
}
}
type inputOnBoolArgs struct {
Flag bool `flag:"flag" input:"file"`
}
func TestWalkArgs_InputOnNonStringErrors(t *testing.T) {
_, err := walkArgs(reflect.TypeOf(&inputOnBoolArgs{}))
if err == nil {
t.Fatal("expected error for input on bool field")
}
if !strings.Contains(err.Error(), "input tag is only supported on string") {
t.Errorf("unexpected error: %v", err)
}
}
type unknownInputSrcArgs struct {
Content string `flag:"content" input:"bogus"`
}
func TestWalkArgs_UnknownInputSource(t *testing.T) {
_, err := walkArgs(reflect.TypeOf(&unknownInputSrcArgs{}))
if err == nil {
t.Fatal("expected error for unknown input source")
}
if !strings.Contains(err.Error(), "unknown input source") {
t.Errorf("unexpected error: %v", err)
}
}
// A string-alias enum field (e.g. an argstype primitive) must be accepted.
type enumOnStringArgs struct {
Priority string `flag:"priority" enum:"low,normal,high"`
}
func TestWalkArgs_EnumOnStringOK(t *testing.T) {
specs, err := walkArgs(reflect.TypeOf(&enumOnStringArgs{}))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(specs) != 1 || len(specs[0].EnumValues) != 3 {
t.Errorf("enum not parsed: %+v", specs)
}
}
// --- enum shell completion + help candidate rendering ---------------------
func TestRegisterLeaf_EnumCompletion(t *testing.T) {
prev := cmdutil.FlagCompletionsEnabled()
cmdutil.SetFlagCompletionsEnabled(true)
defer cmdutil.SetFlagCompletionsEnabled(prev)
specs, _ := walkArgs(reflect.TypeOf(&enumOnStringArgs{}))
cmd := &cobra.Command{Use: "test"}
if err := registerFlags(cmd, specs); err != nil {
t.Fatalf("registerFlags: %v", err)
}
fn, ok := cmd.GetFlagCompletionFunc("priority")
if !ok || fn == nil {
t.Fatal("expected a completion func registered for --priority")
}
vals, _ := fn(cmd, nil, "")
want := map[string]bool{"low": true, "normal": true, "high": true}
if len(vals) != 3 {
t.Fatalf("completion candidates = %v, want low/normal/high", vals)
}
for _, v := range vals {
if !want[v] {
t.Errorf("unexpected completion candidate %q", v)
}
}
}
func TestFormatLeafLine_EnumCandidates(t *testing.T) {
s := fieldSpec{FlagName: "priority", Description: "the priority", EnumValues: []string{"low", "normal", "high"}}
line := formatLeafLine(" ", s)
if !strings.Contains(line, "(one of: low|normal|high)") {
t.Errorf("help line missing enum candidates: %q", line)
}
}
func TestFormatLeafLine_EnumAndDefault(t *testing.T) {
s := fieldSpec{FlagName: "priority", Description: "the priority", EnumValues: []string{"low", "high"}, DefaultValue: "low"}
line := formatLeafLine(" ", s)
if !strings.Contains(line, "(one of: low|high)") || !strings.Contains(line, `(default "low")`) {
t.Errorf("help line missing enum or default: %q", line)
}
}

View File

@@ -1,95 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"reflect"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
// --- []<named string> and named slice types bind without panicking --------
// Regression guard: reflect.Convert([]string -> []myID) panics, so the binder
// must build the slice element-by-element via SetString instead.
type myID string
type namedElemArgs struct {
Xs []myID `flag:"xs"`
}
func TestSliceFlag_NamedElementType(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, err := walkArgs(reflect.TypeOf(&namedElemArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
if err := registerFlags(cmd, specs); err != nil {
t.Fatalf("registerFlags: %v", err)
}
if err := cmd.ParseFlags([]string{"--xs", "a,b"}); err != nil {
t.Fatalf("parse: %v", err)
}
out := &namedElemArgs{}
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bind: %v", err)
}
if !reflect.DeepEqual(out.Xs, []myID{"a", "b"}) {
t.Errorf("Xs = %#v, want []myID{a b}", out.Xs)
}
}
type myIDList []string
type namedSliceArgs struct {
Ids myIDList `flag:"ids"`
}
func TestSliceFlag_NamedSliceType(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&namedSliceArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags([]string{"--ids", "x,y,z"})
out := &namedSliceArgs{}
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bind: %v", err)
}
if !reflect.DeepEqual(out.Ids, myIDList{"x", "y", "z"}) {
t.Errorf("Ids = %#v, want myIDList{x y z}", out.Ids)
}
}
// --- nil Execute → not mounted (parity with legacy Shortcut) ---------------
type nilExecArgs struct {
Name string `flag:"name"`
}
func TestMountTyped_NilExecuteNotMounted(t *testing.T) {
root := &cobra.Command{Use: "root"}
ts := TypedShortcut[*nilExecArgs]{
Service: "x", Command: "+noexec", AuthTypes: []string{"user"}, Risk: "read",
// Execute intentionally nil — legacy skips mounting such shortcuts.
}
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
if sub, _, _ := root.Find([]string{"+noexec"}); sub != nil && sub.Name() == "+noexec" {
t.Error("nil-Execute typed shortcut must NOT be mounted (parity with legacy)")
}
}
func TestMountTyped_WithExecuteMounted(t *testing.T) {
root := &cobra.Command{Use: "root"}
ts := TypedShortcut[*nilExecArgs]{
Service: "x", Command: "+yesexec", AuthTypes: []string{"user"}, Risk: "read",
Execute: func(ctx context.Context, args *nilExecArgs, rt *RuntimeContext) error { return nil },
}
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
if sub, _, _ := root.Find([]string{"+yesexec"}); sub == nil || sub.Name() != "+yesexec" {
t.Error("typed shortcut with Execute should be mounted")
}
}

View File

@@ -1,192 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"bytes"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
// --- multi-value ([]string) flags -----------------------------------------
type sliceArgs struct {
Ids []string `flag:"ids"` // default → StringSlice (comma-split)
Tags []string `flag:"tags" split:"none"` // StringArray (repeatable, no split)
}
func TestSliceFlag_StringSliceDefault(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, err := walkArgs(reflect.TypeOf(&sliceArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
if err := registerFlags(cmd, specs); err != nil {
t.Fatalf("registerFlags: %v", err)
}
if err := cmd.ParseFlags([]string{"--ids", "a,b,c"}); err != nil {
t.Fatalf("parse: %v", err)
}
out := &sliceArgs{}
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bind: %v", err)
}
if !reflect.DeepEqual(out.Ids, []string{"a", "b", "c"}) {
t.Errorf("Ids = %#v, want [a b c] (comma-split)", out.Ids)
}
}
func TestSliceFlag_StringArrayNoSplit(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&sliceArgs{}))
_ = registerFlags(cmd, specs)
// repeated; a value containing a comma must NOT be split (StringArray)
if err := cmd.ParseFlags([]string{"--tags", "a,b", "--tags", "c"}); err != nil {
t.Fatalf("parse: %v", err)
}
out := &sliceArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
if !reflect.DeepEqual(out.Tags, []string{"a,b", "c"}) {
t.Errorf("Tags = %#v, want [\"a,b\" \"c\"] (no split, repeatable)", out.Tags)
}
}
func TestSliceFlag_UnsetIsEmpty(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&sliceArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags(nil)
out := &sliceArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
if len(out.Ids) != 0 {
t.Errorf("Ids = %#v, want empty when unset", out.Ids)
}
}
type sliceGroup struct {
Items []string `flag:"items"`
Note string `flag:"note"`
}
type groupSliceArgs struct {
G sliceGroup
}
func TestSliceFlag_InGroup(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, err := walkArgs(reflect.TypeOf(&groupSliceArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
_ = registerFlags(cmd, specs)
if err := cmd.ParseFlags([]string{"--items", "x,y", "--note", "hi"}); err != nil {
t.Fatalf("parse: %v", err)
}
out := &groupSliceArgs{}
if err := bindGroups(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bindGroups: %v", err)
}
if !reflect.DeepEqual(out.G.Items, []string{"x", "y"}) {
t.Errorf("G.Items = %#v, want [x y]", out.G.Items)
}
}
// --- Mount-time validation for slices / split -----------------------------
type splitOnStringArgs struct {
S string `flag:"s" split:"none"`
}
func TestWalkArgs_SplitOnNonSliceErrors(t *testing.T) {
_, err := walkArgs(reflect.TypeOf(&splitOnStringArgs{}))
if err == nil || !strings.Contains(err.Error(), "split tag is only supported on []string") {
t.Fatalf("expected split-on-non-slice error, got %v", err)
}
}
type intSliceArgs struct {
N []int `flag:"n"`
}
func TestWalkArgs_NonStringSliceErrors(t *testing.T) {
_, err := walkArgs(reflect.TypeOf(&intSliceArgs{}))
if err == nil || !strings.Contains(err.Error(), "only []string slices are supported") {
t.Fatalf("expected []int error, got %v", err)
}
}
type unknownSplitArgs struct {
S []string `flag:"s" split:"bogus"`
}
func TestWalkArgs_UnknownSplitMode(t *testing.T) {
_, err := walkArgs(reflect.TypeOf(&unknownSplitArgs{}))
if err == nil || !strings.Contains(err.Error(), "unknown split mode") {
t.Fatalf("expected unknown split mode error, got %v", err)
}
}
// --- per-flag hidden ------------------------------------------------------
type hiddenArgs struct {
Visible string `flag:"visible"`
Secret string `flag:"secret" hidden:"true"`
}
func TestHiddenFlag_RegisteredButHidden(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&hiddenArgs{}))
_ = registerFlags(cmd, specs)
f := cmd.Flags().Lookup("secret")
if f == nil {
t.Fatal("secret flag not registered")
}
if !f.Hidden {
t.Error("secret flag should be marked hidden")
}
// hidden does not mean disabled — it still binds.
_ = cmd.ParseFlags([]string{"--secret", "shh", "--visible", "v"})
out := &hiddenArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
if out.Secret != "shh" {
t.Errorf("Secret = %q, want shh (hidden but functional)", out.Secret)
}
}
func TestHiddenFlag_SkippedInHelp(t *testing.T) {
specs, _ := walkArgs(reflect.TypeOf(&hiddenArgs{}))
cmd := &cobra.Command{Use: "test"}
_ = registerFlags(cmd, specs)
var buf bytes.Buffer
cmd.SetOut(&buf)
buildTypedHelp(specs, nil)(cmd, nil)
out := buf.String()
if !strings.Contains(out, "--visible") {
t.Errorf("help should show --visible:\n%s", out)
}
if strings.Contains(out, "--secret") {
t.Errorf("help must NOT show hidden --secret:\n%s", out)
}
}
// --- @file / stdin help hint ----------------------------------------------
func TestFormatLeafLine_InputHintBoth(t *testing.T) {
s := fieldSpec{FlagName: "content", Description: "the content", Input: []string{File, Stdin}}
line := formatLeafLine(" ", s)
if !strings.Contains(line, "(supports @file, - for stdin)") {
t.Errorf("missing input hint: %q", line)
}
}
func TestFormatLeafLine_InputHintFileOnly(t *testing.T) {
s := fieldSpec{FlagName: "content", Description: "c", Input: []string{File}}
line := formatLeafLine(" ", s)
if !strings.Contains(line, "(supports @file)") || strings.Contains(line, "stdin") {
t.Errorf("file-only hint wrong: %q", line)
}
}

View File

@@ -1,312 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"errors"
"reflect"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
)
type simpleArgs struct {
Name string `flag:"name" desc:"a name"`
Count int `flag:"count" default:"3"`
}
func TestWalkArgs_Simple(t *testing.T) {
specs, err := walkArgs(reflect.TypeOf(&simpleArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
if len(specs) != 2 {
t.Fatalf("expected 2 field specs, got %d", len(specs))
}
if specs[0].FlagName != "name" || specs[1].FlagName != "count" {
t.Errorf("flag names: %+v", specs)
}
}
type dupTagArgs struct {
A string `flag:"x"`
B string `flag:"x"`
}
func TestWalkArgs_DuplicateTagPanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic for duplicate flag tag")
}
}()
_, _ = walkArgs(reflect.TypeOf(&dupTagArgs{}))
}
type bindArgs struct {
Name string `flag:"name"`
Count int `flag:"count" default:"7"`
}
func TestRegisterAndBind_StringInt(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&bindArgs{}))
if err := registerFlags(cmd, specs); err != nil {
t.Fatalf("registerFlags: %v", err)
}
_ = cmd.ParseFlags([]string{"--name", "alice", "--count", "12"})
out := &bindArgs{}
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bindFlags: %v", err)
}
if out.Name != "alice" {
t.Errorf("Name = %q, want alice", out.Name)
}
if out.Count != 12 {
t.Errorf("Count = %d, want 12", out.Count)
}
}
func TestRegister_DefaultApplies(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&bindArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags(nil)
out := &bindArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
if out.Count != 7 {
t.Errorf("default not applied: Count = %d, want 7", out.Count)
}
}
type withValidatable struct {
ID dummyValidatable `flag:"id"`
}
func TestRunValidateValue_CallsValidatable(t *testing.T) {
v := &withValidatable{}
specs, _ := walkArgs(reflect.TypeOf(v))
rt := &RuntimeContext{}
if err := runValidateValue(rt, reflect.ValueOf(v).Elem(), specs); err != nil {
t.Errorf("runValidateValue: %v", err)
}
}
type oneOfArgs struct {
A *string `flag:"a"`
B *string `flag:"b"`
}
func (oneOfArgs) OneOf() {}
type bucketArgs struct {
Bucket oneOfArgs
}
func TestFrameworkRules_OneOfMissing(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&bucketArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags(nil)
out := &bucketArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
if err == nil {
t.Fatal("expected oneof_missing error")
}
ve := mustValidationError(t, err)
if ve.Subtype != errs.SubtypeShortcutOneOfMissing {
t.Errorf("Subtype = %q", ve.Subtype)
}
}
func TestFrameworkRules_OneOfMultiple(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&bucketArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags([]string{"--a", "1", "--b", "2"})
out := &bucketArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
if err == nil {
t.Fatal("expected oneof_multiple error")
}
ve := mustValidationError(t, err)
if ve.Subtype != errs.SubtypeShortcutOneOfMultiple {
t.Errorf("Subtype = %q", ve.Subtype)
}
}
// --- top-level group binding (bindGroups) ---
type dateRange struct {
From string `flag:"from"`
To string `flag:"to"`
}
type groupValueArgs struct {
Range dateRange
}
func TestBindGroups_TopLevelValueGroup(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&groupValueArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags([]string{"--from", "2026-01-01", "--to", "2026-12-31"})
out := &groupValueArgs{}
argsVal := reflect.ValueOf(out).Elem()
_ = bindFlags(cmd, argsVal, specs)
if err := bindGroups(cmd, argsVal, specs); err != nil {
t.Fatalf("bindGroups: %v", err)
}
if out.Range.From != "2026-01-01" || out.Range.To != "2026-12-31" {
t.Errorf("Range = %+v", out.Range)
}
}
type defaultedGroup struct {
Port string `flag:"port" default:"8080"`
Host string `flag:"host" default:"localhost"`
}
type groupDefaultArgs struct {
Conf defaultedGroup
}
func TestBindGroups_TopLevelValueGroup_AppliesDefaults(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&groupDefaultArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags(nil)
out := &groupDefaultArgs{}
argsVal := reflect.ValueOf(out).Elem()
_ = bindFlags(cmd, argsVal, specs)
if err := bindGroups(cmd, argsVal, specs); err != nil {
t.Fatalf("bindGroups: %v", err)
}
if out.Conf.Port != "8080" || out.Conf.Host != "localhost" {
t.Errorf("defaults not applied: Conf = %+v", out.Conf)
}
}
type proxyConf struct {
Host string `flag:"proxy-host"`
Port string `flag:"proxy-port"`
}
type groupPtrArgs struct {
Proxy *proxyConf
}
func TestBindGroups_TopLevelPtrGroup_AllocatedWhenChanged(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&groupPtrArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags([]string{"--proxy-host", "p.example.com"})
out := &groupPtrArgs{}
argsVal := reflect.ValueOf(out).Elem()
_ = bindFlags(cmd, argsVal, specs)
if err := bindGroups(cmd, argsVal, specs); err != nil {
t.Fatalf("bindGroups: %v", err)
}
if out.Proxy == nil {
t.Fatal("expected Proxy to be allocated when an inner flag was changed")
}
if out.Proxy.Host != "p.example.com" {
t.Errorf("Proxy.Host = %q", out.Proxy.Host)
}
}
func TestBindGroups_TopLevelPtrGroup_NilWhenAbsent(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&groupPtrArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags(nil)
out := &groupPtrArgs{}
argsVal := reflect.ValueOf(out).Elem()
_ = bindFlags(cmd, argsVal, specs)
if err := bindGroups(cmd, argsVal, specs); err != nil {
t.Fatalf("bindGroups: %v", err)
}
if out.Proxy != nil {
t.Errorf("expected Proxy nil when no inner flag set, got %+v", out.Proxy)
}
}
// --- OneOf with a nested group variant (no oneof_trigger; any inner flag
// counts as attempting that variant) ---
type vidGroup struct {
File string `flag:"vid-file"`
Cover string `flag:"vid-cover"`
}
type contentBucket struct {
Text *string `flag:"ct"`
Video *vidGroup
}
func (contentBucket) OneOf() {}
type contentBucketArgs struct {
Bucket contentBucket
}
func TestCheckOneOf_GroupCompanionAloneTriggersVariant(t *testing.T) {
// Companion --vid-cover alone (no --vid-file) should count as attempting
// the Video variant; OneOf check passes (1 variant attempted) and the
// group completeness check then surfaces shortcut_group_incomplete with
// the specific missing flag, not a misleading shortcut_oneof_missing.
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&contentBucketArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags([]string{"--vid-cover", "c.png"})
out := &contentBucketArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
if err == nil {
t.Fatal("expected group_incomplete error")
}
ve := mustValidationError(t, err)
if ve.Subtype != errs.SubtypeShortcutGroupIncomplete {
t.Errorf("Subtype = %q, want shortcut_group_incomplete", ve.Subtype)
}
}
func TestCheckOneOf_GroupVariantBothFieldsOK(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&contentBucketArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags([]string{"--vid-file", "v.mp4", "--vid-cover", "c.png"})
out := &contentBucketArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
if err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Errorf("expected OK, got %v", err)
}
}
func TestCheckOneOf_SimpleAndGroupBothAttempted_Multiple(t *testing.T) {
// Text variant set AND Video group's companion set → both variants are
// attempted; expect shortcut_oneof_multiple.
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&contentBucketArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags([]string{"--ct", "hi", "--vid-cover", "c.png"})
out := &contentBucketArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
if err == nil {
t.Fatal("expected oneof_multiple error")
}
ve := mustValidationError(t, err)
if ve.Subtype != errs.SubtypeShortcutOneOfMultiple {
t.Errorf("Subtype = %q, want shortcut_oneof_multiple", ve.Subtype)
}
}
func mustValidationError(t *testing.T, err error) *errs.ValidationError {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
return ve
}

View File

@@ -1,76 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// ShortcutDescriptor exposes the read-only metadata that auth, scope-hint,
// shortcuts.json generation, and diagnose-scope consumers need. Both legacy
// Shortcut and the new TypedShortcut[T] satisfy it (see types.go and
// typed_shortcut.go for the implementations).
type ShortcutDescriptor interface {
GetService() string
GetCommand() string
GetDescription() string
GetAuthTypes() []string
GetRisk() string
ScopesForIdentity(identity string) []string
ConditionalScopesForIdentity(identity string) []string
DeclaredScopesForIdentity(identity string) []string
}
// Mountable is the registration contract for register.go. ShortcutDescriptor
// is embedded so a single interface slice covers both metadata reads and
// cobra mounting.
type Mountable interface {
ShortcutDescriptor
MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory)
}
// OneOfMarker is the opt-in marker for "exactly one variant" sub-structs.
// Variant fields must be pointers; the binder treats a non-nil pointer as
// "this variant was selected by user-set trigger flag". See spec §"OneOf
// trigger semantics" for the trigger rule.
type OneOfMarker interface {
OneOf()
}
// Validatable is implemented by typed primitives (and may be by sub-structs
// that validate a composite value, e.g. a raw JSON body) that own their
// format check. The binder calls
// ValidateValue per field after Normalize. Returning an error must produce
// a *errs.ValidationError so the stderr envelope carries type/subtype/param.
type Validatable interface {
ValidateValue(rt *RuntimeContext, flagName string) error
}
// Normalizable[T] is implemented by typed primitives that canonicalize raw
// user input (e.g. SpreadsheetRef extracting token from URL). The binder
// calls Normalize before ValidateValue and writes the canonical value back.
// hints, if any, are written to stderr once during the Validate phase.
//
// Local-path Normalize MUST NOT emit absolute paths in hints (log safety).
type Normalizable[T any] interface {
Normalize(ctx context.Context, raw string) (value T, hints []string, err error)
}
// ArgsValidator is the cross-field escape hatch. An Args struct may opt in
// by adding this method; the binder calls it after framework-derived
// validation (required / enum / OneOf / group), before the user-defined
// TypedShortcut.Validate hook.
type ArgsValidator interface {
Validate(ctx context.Context, rt *RuntimeContext) error
}
// HelpExample appears in TypedShortcut.Examples and is rendered by
// typed_help under an "EXAMPLES:" section.
type HelpExample struct {
Title string
Cmd string
}

View File

@@ -1,53 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"testing"
)
// dummyOneOf demonstrates how a sub-struct opts into OneOfMarker by adding
// the empty OneOf() method.
type dummyOneOf struct{ A, B *string }
func (dummyOneOf) OneOf() {}
func TestOneOfMarker_Detection(t *testing.T) {
var v interface{} = dummyOneOf{}
if _, ok := v.(OneOfMarker); !ok {
t.Fatal("dummyOneOf should satisfy OneOfMarker")
}
}
// dummyValidatable implements Validatable.
type dummyValidatable struct{}
func (dummyValidatable) ValidateValue(rt *RuntimeContext, flag string) error { return nil }
func TestValidatable_InterfaceShape(t *testing.T) {
var _ Validatable = dummyValidatable{}
}
// dummyNormalizable implements Normalizable[string].
type dummyNormalizable struct{}
func (dummyNormalizable) Normalize(ctx context.Context, raw string) (string, []string, error) {
return raw, nil, nil
}
func TestNormalizable_InterfaceShape(t *testing.T) {
var n Normalizable[string] = dummyNormalizable{}
got, hints, err := n.Normalize(context.Background(), "x")
if err != nil || got != "x" || hints != nil {
t.Errorf("dummy Normalize round-trip: got=%q hints=%v err=%v", got, hints, err)
}
}
func TestHelpExample_Fields(t *testing.T) {
ex := HelpExample{Title: "send text", Cmd: "--chat-id oc_x --text hi"}
if ex.Title != "send text" || ex.Cmd != "--chat-id oc_x --text hi" {
t.Errorf("HelpExample: got %+v", ex)
}
}

View File

@@ -47,7 +47,6 @@ type RuntimeContext struct {
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
larkSDK *lark.Client // eagerly initialized in mountDeclarative
typedArgs any // per-run typed Args, set by binder; consumed by DryRun/Execute
}
// ── Identity ──
@@ -1018,69 +1017,56 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
stdinUsed := false
for _, fl := range flags {
if err := resolveInputForFlag(rctx, fl.Name, fl.Input, &stdinUsed); err != nil {
return err
if len(fl.Input) == 0 {
continue
}
}
return nil
}
// resolveInputForFlag applies @file / stdin / @@-escape resolution to a single
// string flag. sources lists the accepted inputs (File / Stdin); empty sources
// or an empty/plain flag value is a no-op. stdinUsed is shared across all flags
// of one invocation so stdin (-) is consumed by at most one flag. Both the
// legacy resolveInputFlags loop and the typed binder (resolveTypedInputs) call
// through here, so @file / stdin behaves identically on TypedShortcut[T].
func resolveInputForFlag(rctx *RuntimeContext, name string, sources []string, stdinUsed *bool) error {
if len(sources) == 0 {
return nil
}
raw, err := rctx.Cmd.Flags().GetString(name)
if err != nil {
return FlagErrorf("--%s: Input is only supported for string flags", name)
}
if raw == "" {
return nil
}
// stdin: -
if raw == "-" {
if !slices.Contains(sources, Stdin) {
return FlagErrorf("--%s does not support stdin (-)", name)
}
if *stdinUsed {
return FlagErrorf("--%s: stdin (-) can only be used by one flag", name)
}
*stdinUsed = true
data, err := io.ReadAll(rctx.IO().In)
raw, err := rctx.Cmd.Flags().GetString(fl.Name)
if err != nil {
return FlagErrorf("--%s: failed to read from stdin: %v", name, err)
return FlagErrorf("--%s: Input is only supported for string flags", fl.Name)
}
if raw == "" {
continue
}
rctx.Cmd.Flags().Set(name, string(data))
return nil
}
// escape: @@ → literal @
if strings.HasPrefix(raw, "@@") {
rctx.Cmd.Flags().Set(name, raw[1:]) // strip first @
return nil
}
// stdin: -
if raw == "-" {
if !slices.Contains(fl.Input, Stdin) {
return FlagErrorf("--%s does not support stdin (-)", fl.Name)
}
if stdinUsed {
return FlagErrorf("--%s: stdin (-) can only be used by one flag", fl.Name)
}
stdinUsed = true
data, err := io.ReadAll(rctx.IO().In)
if err != nil {
return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err)
}
rctx.Cmd.Flags().Set(fl.Name, string(data))
continue
}
// file: @path
if strings.HasPrefix(raw, "@") {
if !slices.Contains(sources, File) {
return FlagErrorf("--%s does not support file input (@path)", name)
// escape: @@ → literal @
if strings.HasPrefix(raw, "@@") {
rctx.Cmd.Flags().Set(fl.Name, raw[1:]) // strip first @
continue
}
path := strings.TrimSpace(raw[1:])
if path == "" {
return FlagErrorf("--%s: file path cannot be empty after @", name)
// file: @path
if strings.HasPrefix(raw, "@") {
if !slices.Contains(fl.Input, File) {
return FlagErrorf("--%s does not support file input (@path)", fl.Name)
}
path := strings.TrimSpace(raw[1:])
if path == "" {
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
}
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil {
return FlagErrorf("--%s: %v", fl.Name, err)
}
rctx.Cmd.Flags().Set(fl.Name, string(data))
continue
}
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil {
return FlagErrorf("--%s: %v", name, err)
}
rctx.Cmd.Flags().Set(name, string(data))
return nil
}
return nil
}
@@ -1190,6 +1176,9 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
if cmd.Flags().Lookup("json") == nil {
cmd.Flags().Bool("json", false, "shorthand for --format json")
}
}
if s.Risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
@@ -1197,12 +1186,3 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
}
// TypedArgs returns the per-run Args struct populated by the binder. Returns
// nil for runs that didn't go through TypedShortcut[T]. Callers should
// type-assert to the concrete *Args type.
func (ctx *RuntimeContext) TypedArgs() any { return ctx.typedArgs }
// SetTypedArgs stores the per-run Args struct. Called once by the binder
// after bind + Normalize complete. Must not be called by user hooks.
func (ctx *RuntimeContext) SetTypedArgs(v any) { ctx.typedArgs = v }

View File

@@ -56,17 +56,3 @@ func TestRejectPositionalArgs_NoArgs(t *testing.T) {
t.Fatalf("expected no error for empty args, got: %v", err)
}
}
func TestRuntimeContext_TypedArgs_RoundTrip(t *testing.T) {
type sample struct{ X int }
rt := &RuntimeContext{}
if got := rt.TypedArgs(); got != nil {
t.Fatalf("fresh RuntimeContext.TypedArgs() = %v, want nil", got)
}
in := &sample{X: 42}
rt.SetTypedArgs(in)
out, ok := rt.TypedArgs().(*sample)
if !ok || out != in {
t.Errorf("round-trip failed: got %v ok=%v", rt.TypedArgs(), ok)
}
}

View File

@@ -96,3 +96,76 @@ func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
t.Fatal("did not expect completion func for --format when disabled")
}
}
func TestShortcutMount_JsonFlag_AcceptedWhenHasFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "test",
Command: "+read",
Description: "test read",
HasFormat: true,
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+read"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
if flag := cmd.Flags().Lookup("json"); flag == nil {
t.Fatal("expected --json flag to be registered on HasFormat shortcut")
}
}
func TestShortcutMount_JsonFlag_SkippedWhenConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "test",
Command: "+update",
Description: "test update",
HasFormat: true,
Flags: []Flag{
{Name: "json", Desc: "body JSON object", Required: true},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+update"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
// --json flag exists (from custom Flags), but should be the string type, not bool.
flag := cmd.Flags().Lookup("json")
if flag == nil {
t.Fatal("expected --json flag from custom Flags")
}
if flag.DefValue != "" {
t.Errorf("expected empty default (string flag), got %q", flag.DefValue)
}
}
func TestShortcutMount_JsonFlag_RegisteredWithoutHasFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "test",
Command: "+write",
Description: "test write",
HasFormat: false,
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+write"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
// --format is now registered for all shortcuts (regardless of HasFormat),
// so --json should also be present.
if flag := cmd.Flags().Lookup("json"); flag == nil {
t.Fatal("expected --json flag to be registered even when HasFormat is false")
}
}

View File

@@ -1,229 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"fmt"
"io"
"reflect"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/internal/cmdutil"
)
// buildTypedHelp returns a cobra.HelpFunc that renders typed shortcuts in
// sections (CHOOSE ONE <FIELD> / OPTIONAL / EXAMPLES / GLOBAL FLAGS / Risk: /
// Tips:). Cobra's default HelpFunc is preserved for all non-typed commands;
// we only override per-command via cmd.SetHelpFunc.
//
// Section titles use the Args struct's field name (e.g. "TARGET", "CONTENT"),
// not the inner Go type name behind the field, so the help mirrors the
// user-visible variable name rather than an implementation detail.
func buildTypedHelp(specs []fieldSpec, examples []HelpExample) func(*cobra.Command, []string) {
return func(cmd *cobra.Command, _ []string) {
w := cmd.OutOrStdout()
fmt.Fprintf(w, "%s — %s\n\n", cmd.Use, cmd.Short)
rendered := map[string]struct{}{}
renderOneOfSections(w, specs, rendered)
renderRequiredSection(w, specs, rendered)
renderOptionalSection(w, specs, rendered)
renderExamples(w, examples)
renderGlobalFlags(w, cmd, rendered)
if r, ok := cmdutil.GetRisk(cmd); ok && r != "" {
fmt.Fprintf(w, "Risk: %s\n\n", r)
}
for _, tip := range cmdutil.GetTips(cmd) {
fmt.Fprintf(w, "Tips: %s\n", tip)
}
}
}
// formatLeafLine renders one leaf flag line — "--name description" with an
// optional `(default "x")` suffix when the field declares a default. Reused by
// every section renderer so REQUIRED / OPTIONAL / CHOOSE ONE all surface the
// same information density as cobra's legacy default help.
func formatLeafLine(indent string, s fieldSpec) string {
line := fmt.Sprintf("%s--%s %s", indent, s.FlagName, s.Description)
if len(s.EnumValues) > 0 {
line += fmt.Sprintf(" (one of: %s)", strings.Join(s.EnumValues, "|"))
}
if len(s.Input) > 0 {
var srcs []string
for _, src := range s.Input {
switch src {
case File:
srcs = append(srcs, "@file")
case Stdin:
srcs = append(srcs, "- for stdin")
}
}
line += fmt.Sprintf(" (supports %s)", strings.Join(srcs, ", "))
}
if s.DefaultValue != "" {
line += fmt.Sprintf(" (default %q)", s.DefaultValue)
}
return line
}
// renderOneOfSections walks each OneOf bucket and prints "CHOOSE ONE <FIELD>:"
// followed by every flag inside the bucket — including flags inside nested
// groups (a paired group's companion flag under its trigger) and nested
// raw-content variants (a raw-JSON variant's body + msg-type flags).
func renderOneOfSections(w io.Writer, specs []fieldSpec, rendered map[string]struct{}) {
for _, s := range specs {
if !s.IsOneOfBkt {
continue
}
fmt.Fprintf(w, "CHOOSE ONE %s:\n", strings.ToUpper(s.GoFieldName))
inner, _ := walkArgs(reflect.PointerTo(s.StructType))
renderFlagsInBucket(w, inner, " ", rendered)
fmt.Fprintln(w)
}
}
// renderFlagsInBucket renders bucket inner flags, recursing into nested group
// / OneOf sub-structs. The structure is FLATTENED — every leaf flag of an
// inner variant renders at the parent's indent. The framework treats any
// inner flag of a group variant equally (providing any of them counts as
// selecting that variant), so the help no longer visually distinguishes a
// "trigger" from a "companion".
func renderFlagsInBucket(w io.Writer, specs []fieldSpec, indent string, rendered map[string]struct{}) {
for _, child := range specs {
if child.IsGroup || child.IsOneOfBkt {
inner, _ := walkArgs(reflect.PointerTo(child.StructType))
for _, leaf := range inner {
if leaf.IsGroup || leaf.IsOneOfBkt {
grand, _ := walkArgs(reflect.PointerTo(leaf.StructType))
renderFlagsInBucket(w, grand, indent+" ", rendered)
continue
}
if leaf.FlagName == "" || leaf.Hidden {
continue
}
fmt.Fprintln(w, formatLeafLine(indent, leaf))
rendered[leaf.FlagName] = struct{}{}
}
continue
}
if child.FlagName == "" || child.Hidden {
continue
}
fmt.Fprintln(w, formatLeafLine(indent, child))
rendered[child.FlagName] = struct{}{}
}
}
// renderRequiredSection prints top-level leaf flags that carry the `required`
// tag under a "REQUIRED:" header so users can see at a glance which flags they
// must supply. Sub-struct fields are skipped here because they're surfaced
// under the CHOOSE ONE sections above.
func renderRequiredSection(w io.Writer, specs []fieldSpec, rendered map[string]struct{}) {
anyFlag := false
for _, s := range specs {
if s.IsOneOfBkt || s.IsGroup {
continue
}
if s.FlagName == "" || !s.Required || s.Hidden {
continue
}
if !anyFlag {
fmt.Fprintln(w, "REQUIRED:")
anyFlag = true
}
fmt.Fprintln(w, formatLeafLine(" ", s))
rendered[s.FlagName] = struct{}{}
}
if anyFlag {
fmt.Fprintln(w)
}
}
// renderOptionalSection prints top-level leaf flags that don't belong to any
// OneOf bucket and aren't tagged required (those are handled by
// renderRequiredSection above). Sub-struct fields are skipped here because
// they live under CHOOSE ONE sections above.
func renderOptionalSection(w io.Writer, specs []fieldSpec, rendered map[string]struct{}) {
anyFlag := false
for _, s := range specs {
if s.IsOneOfBkt || s.IsGroup {
continue
}
if s.FlagName == "" || s.Required || s.Hidden {
continue
}
if !anyFlag {
fmt.Fprintln(w, "OPTIONAL:")
anyFlag = true
}
fmt.Fprintln(w, formatLeafLine(" ", s))
rendered[s.FlagName] = struct{}{}
}
if anyFlag {
fmt.Fprintln(w)
}
}
func renderExamples(w io.Writer, examples []HelpExample) {
if len(examples) == 0 {
return
}
fmt.Fprintln(w, "EXAMPLES:")
for _, e := range examples {
fmt.Fprintf(w, " %-20s %s\n", e.Title+":", e.Cmd)
}
fmt.Fprintln(w)
}
// renderGlobalFlags prints framework-injected and cobra-inherited flags
// (--as / --dry-run / --jq / --format / -h / --help) that the typed Args
// struct does NOT define. The `rendered` set captures every flag name we
// already emitted under CHOOSE ONE / OPTIONAL — anything else surfaced by
// cmd.Flags() or cmd.InheritedFlags() falls under GLOBAL FLAGS.
func renderGlobalFlags(w io.Writer, cmd *cobra.Command, rendered map[string]struct{}) {
type globalFlag struct {
Name string
Shorthand string
Usage string
}
var globals []globalFlag
seen := map[string]bool{}
collect := func(fs *pflag.FlagSet) {
fs.VisitAll(func(f *pflag.Flag) {
if f.Hidden {
return
}
if _, alreadyRendered := rendered[f.Name]; alreadyRendered {
return
}
if seen[f.Name] {
return
}
seen[f.Name] = true
globals = append(globals, globalFlag{Name: f.Name, Shorthand: f.Shorthand, Usage: f.Usage})
})
}
collect(cmd.Flags())
collect(cmd.InheritedFlags())
// Cobra auto-injects --help on every command, but it does not appear in
// LocalFlags or InheritedFlags until the command has been resolved at
// invocation time. Ensure it is always documented.
if !seen["help"] {
globals = append(globals, globalFlag{Name: "help", Shorthand: "h", Usage: "show help"})
}
if len(globals) == 0 {
return
}
fmt.Fprintln(w, "GLOBAL FLAGS:")
for _, g := range globals {
if g.Shorthand != "" {
fmt.Fprintf(w, " -%s, --%s %s\n", g.Shorthand, g.Name, g.Usage)
} else {
fmt.Fprintf(w, " --%s %s\n", g.Name, g.Usage)
}
}
fmt.Fprintln(w)
}

View File

@@ -1,108 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"bytes"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
type helpDemoTarget struct {
Chat *string `flag:"chat-id"`
User *string `flag:"user-id"`
}
func (helpDemoTarget) OneOf() {}
type helpDemoArgs struct {
Target helpDemoTarget
Idemp string `flag:"idempotency-key"`
}
func TestTypedHelp_RendersSections(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cmd := &cobra.Command{Use: "+demo", Short: "demo command"}
cmdutil.SetRisk(cmd, "high-risk-write")
cmdutil.SetTips(cmd, []string{"call carefully"})
specs, err := walkArgs(reflect.TypeOf(&helpDemoArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
cmd.SetHelpFunc(buildTypedHelp(specs, []HelpExample{{Title: "demo", Cmd: "--chat-id oc_x"}}))
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.HelpFunc()(cmd, nil)
out := buf.String()
for _, section := range []string{"CHOOSE ONE", "OPTIONAL", "EXAMPLES", "Risk:", "Tips:"} {
if !strings.Contains(out, section) {
t.Errorf("help missing %q section; got:\n%s", section, out)
}
}
}
// helpReqDefaultsArgs covers two cases the renderer previously botched:
//
// 1. A required flag (--limit) should land under "REQUIRED:" instead of
// being silently glued into "OPTIONAL:".
// 2. A flag with default (--page-size) should display (default "20").
type helpReqDefaultsArgs struct {
Limit string `flag:"limit" required:"true" desc:"max items"`
PageSize string `flag:"page-size" default:"20" desc:"page size"`
Verbose string `flag:"verbose" desc:"output mode"`
}
func TestTypedHelp_RequiredSectionAndDefaults(t *testing.T) {
cmd := &cobra.Command{Use: "+demo", Short: "demo command"}
specs, err := walkArgs(reflect.TypeOf(&helpReqDefaultsArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
cmd.SetHelpFunc(buildTypedHelp(specs, nil))
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.HelpFunc()(cmd, nil)
out := buf.String()
if !strings.Contains(out, "REQUIRED:") {
t.Errorf("expected REQUIRED: section, got:\n%s", out)
}
// --limit must be under REQUIRED, NOT under OPTIONAL.
reqIdx := strings.Index(out, "REQUIRED:")
optIdx := strings.Index(out, "OPTIONAL:")
limitIdx := strings.Index(out, "--limit")
if reqIdx < 0 || optIdx < 0 || limitIdx < 0 {
t.Fatalf("layout markers missing: REQUIRED@%d OPTIONAL@%d --limit@%d\n%s", reqIdx, optIdx, limitIdx, out)
}
if !(reqIdx < limitIdx && limitIdx < optIdx) {
t.Errorf("--limit should appear under REQUIRED before OPTIONAL; got:\n%s", out)
}
// Default value must be surfaced for --page-size.
if !strings.Contains(out, `(default "20")`) {
t.Errorf("expected default value rendering for --page-size; got:\n%s", out)
}
// Plain flag without default or required tag must NOT carry a (default …) suffix.
verboseLine := ""
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "--verbose") {
verboseLine = line
break
}
}
if verboseLine == "" {
t.Fatalf("expected --verbose flag to be rendered; got:\n%s", out)
}
if strings.Contains(verboseLine, "(default") {
t.Errorf("--verbose has no default, should not carry default suffix: %q", verboseLine)
}
}

View File

@@ -1,231 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"fmt"
"reflect"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// TypedShortcut is the generic counterpart of the legacy Shortcut struct.
// Args must be a pointer to a struct (e.g. *MyArgs). Mounting reflects the
// struct, registers cobra flags, then delegates to a synthesized Shortcut
// shell so the existing run pipeline (identity / scopes / @file / stdin /
// jq / dry-run / high-risk gate) is reused verbatim.
type TypedShortcut[T any] struct {
Service, Command, Description string
Risk string
Scopes, UserScopes, BotScopes []string
ConditionalScopes []string
ConditionalUserScopes []string
ConditionalBotScopes []string
AuthTypes []string
HasFormat bool
Tips []string
Hidden bool
Examples []HelpExample
DryRun func(ctx context.Context, args T, rt *RuntimeContext) *DryRunAPI
Validate func(ctx context.Context, args T, rt *RuntimeContext) error
Execute func(ctx context.Context, args T, rt *RuntimeContext) error
PostMount func(cmd *cobra.Command)
}
func (s TypedShortcut[T]) GetService() string { return s.Service }
func (s TypedShortcut[T]) GetCommand() string { return s.Command }
func (s TypedShortcut[T]) GetDescription() string { return s.Description }
func (s TypedShortcut[T]) GetAuthTypes() []string { return s.AuthTypes }
func (s TypedShortcut[T]) GetRisk() string { return s.Risk }
func (s TypedShortcut[T]) ScopesForIdentity(identity string) []string {
switch identity {
case "user":
if len(s.UserScopes) > 0 {
return s.UserScopes
}
case "bot":
if len(s.BotScopes) > 0 {
return s.BotScopes
}
}
return s.Scopes
}
func (s TypedShortcut[T]) ConditionalScopesForIdentity(identity string) []string {
switch identity {
case "user":
if len(s.ConditionalUserScopes) > 0 {
return s.ConditionalUserScopes
}
case "bot":
if len(s.ConditionalBotScopes) > 0 {
return s.ConditionalBotScopes
}
}
return s.ConditionalScopes
}
func (s TypedShortcut[T]) DeclaredScopesForIdentity(identity string) []string {
base := s.ScopesForIdentity(identity)
extra := s.ConditionalScopesForIdentity(identity)
if len(base) == 0 && len(extra) == 0 {
return nil
}
out := make([]string, 0, len(base)+len(extra))
seen := map[string]struct{}{}
for _, scope := range append(base, extra...) {
if scope == "" {
continue
}
if _, ok := seen[scope]; ok {
continue
}
seen[scope] = struct{}{}
out = append(out, scope)
}
if len(out) == 0 {
return nil
}
return out
}
// Mount registers the typed shortcut on a parent command, mirroring the legacy
// Shortcut.Mount API so migrating common.Shortcut → common.TypedShortcut[T]
// does not force existing callers (or their tests) to also rewrite the Mount
// call site. Delegates to MountWithContext with a background context.
func (s TypedShortcut[T]) Mount(parent *cobra.Command, f *cmdutil.Factory) {
s.MountWithContext(context.Background(), parent, f)
}
// MountWithContext is implemented in Task 16's adapter section.
func (s TypedShortcut[T]) MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
mountTyped[T](ctx, parent, f, s)
}
// mountTyped synthesizes a legacy *Shortcut shell that delegates back to the
// typed hooks. The shell's Validate/DryRun/Execute closures read or write
// the per-run typed args via RuntimeContext.{TypedArgs,SetTypedArgs}.
//
// Pipeline order inside the shell (matches runShortcut at runner.go:748):
//
// 1. identity / scopes / runtime — handled by Shortcut.runShortcut
// 2. validateEnumFlags — Shortcut machinery
// 3. resolveInputFlags — @file / stdin (legacy shell only; the
// synthesized shell's Flags slice is empty, so this is a no-op for typed —
// typed flags declare inputs via the `input` tag, resolved in step 5)
// 4. ValidateJqFlags — --jq
// 5. shell.Validate — resolveTypedInputs (@file / stdin for
// `input`-tagged flags), binds T, runs Normalize / ValidateValue /
// framework rules / ArgsValidator / user-typed Validate
// 6. --dry-run gate — shell.DryRun reads typed args from rt
// 7. high-risk-write confirmation — when Risk == "high-risk-write"
// 8. shell.Execute — reads typed args from rt
func mountTyped[T any](ctx context.Context, parent *cobra.Command, f *cmdutil.Factory, s TypedShortcut[T]) {
// Mirror legacy Shortcut.MountWithContext: a shortcut with no Execute is
// not a runnable command, so it is not mounted at all (rather than mounted
// and erroring at invocation time). Keeps the command tree identical to
// legacy after migration.
if s.Execute == nil {
return
}
var zero T
argsType := reflect.TypeOf(zero)
if argsType == nil || argsType.Kind() != reflect.Ptr {
panic("TypedShortcut[T]: T must be a pointer to a struct, got " + fmt.Sprintf("%T", zero))
}
specs, err := walkArgs(argsType)
if err != nil {
panic("TypedShortcut[T].Mount: " + err.Error())
}
shell := Shortcut{
Service: s.Service,
Command: s.Command,
Description: s.Description,
Risk: s.Risk,
Scopes: s.Scopes,
UserScopes: s.UserScopes,
BotScopes: s.BotScopes,
ConditionalScopes: s.ConditionalScopes,
ConditionalUserScopes: s.ConditionalUserScopes,
ConditionalBotScopes: s.ConditionalBotScopes,
AuthTypes: s.AuthTypes,
HasFormat: s.HasFormat,
Tips: s.Tips,
Hidden: s.Hidden,
PostMount: func(cmd *cobra.Command) {
if err := registerFlags(cmd, specs); err != nil {
panic("TypedShortcut[T] registerFlags: " + err.Error())
}
cmd.SetHelpFunc(buildTypedHelp(specs, s.Examples))
if s.PostMount != nil {
s.PostMount(cmd)
}
},
Validate: func(c context.Context, rt *RuntimeContext) error {
// @file / stdin resolution for flags that declared an `input` tag.
// Runs before bindFlags so the resolved file/stdin content is what
// gets bound into the Args struct.
if err := resolveTypedInputs(rt, specs); err != nil {
return err
}
args := reflect.New(argsType.Elem()).Interface().(T)
argsVal := reflect.ValueOf(args).Elem()
if err := bindFlags(rt.Cmd, argsVal, specs); err != nil {
return err
}
if err := bindBuckets(rt.Cmd, argsVal, specs); err != nil {
return err
}
if err := bindGroups(rt.Cmd, argsVal, specs); err != nil {
return err
}
if err := runNormalize(c, rt, argsVal, specs); err != nil {
return err
}
if err := runValidateValue(rt, argsVal, specs); err != nil {
return err
}
if err := runFrameworkRules(rt.Cmd, argsVal, specs); err != nil {
return err
}
if av, ok := any(args).(ArgsValidator); ok {
if err := av.Validate(c, rt); err != nil {
return err
}
}
rt.SetTypedArgs(args)
if s.Validate != nil {
return s.Validate(c, args, rt)
}
return nil
},
Execute: func(c context.Context, rt *RuntimeContext) error {
if s.Execute == nil {
return &errs.InternalError{
Problem: errs.Problem{
Category: errs.CategoryInternal,
Message: "shortcut " + s.Service + " " + s.Command + " has no Execute handler",
},
}
}
args, _ := rt.TypedArgs().(T)
return s.Execute(c, args, rt)
},
}
if s.DryRun != nil {
shell.DryRun = func(c context.Context, rt *RuntimeContext) *DryRunAPI {
args, _ := rt.TypedArgs().(T)
return s.DryRun(c, args, rt)
}
}
shell.MountWithContext(ctx, parent, f)
}

View File

@@ -1,179 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"slices"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
type stubArgs struct{}
func newTypedFixture() TypedShortcut[*stubArgs] {
return TypedShortcut[*stubArgs]{
Service: "im",
Command: "+demo",
Description: "demo",
AuthTypes: []string{"user"},
Risk: "write",
Scopes: []string{"x"},
}
}
func TestTypedShortcut_Descriptors(t *testing.T) {
ts := newTypedFixture()
if ts.GetService() != "im" {
t.Errorf("GetService=%q", ts.GetService())
}
if ts.GetCommand() != "+demo" {
t.Errorf("GetCommand=%q", ts.GetCommand())
}
if ts.GetDescription() != "demo" {
t.Errorf("GetDescription=%q", ts.GetDescription())
}
if ts.GetRisk() != "write" {
t.Errorf("GetRisk=%q", ts.GetRisk())
}
}
func TestTypedShortcut_SatisfiesMountable(t *testing.T) {
var _ Mountable = newTypedFixture()
}
type adapterArgs struct {
Name string `flag:"name"`
}
// TestMountTyped_RegistersFlags verifies the mountTyped adapter wires the
// binder-registered flag into cobra. Full Validate/Execute integration is
// covered by tests_e2e/shortcuts/ (out of unit-test scope — runShortcut
// needs a fully-initialized Factory with auth/config).
func TestMountTyped_RegistersFlags(t *testing.T) {
root := &cobra.Command{Use: "root"}
ts := TypedShortcut[*adapterArgs]{
Service: "x", Command: "+demo", AuthTypes: []string{"user"},
Risk: "read",
Execute: func(ctx context.Context, args *adapterArgs, rt *RuntimeContext) error {
return nil
},
}
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
sub, _, err := root.Find([]string{"+demo"})
if err != nil {
t.Fatalf("find subcommand: %v", err)
}
if sub.Flag("name") == nil {
t.Error("expected --name flag to be registered via binder")
}
}
func TestMountTyped_HelpFuncInstalled(t *testing.T) {
root := &cobra.Command{Use: "root"}
ts := TypedShortcut[*adapterArgs]{
Service: "x", Command: "+demo", AuthTypes: []string{"user"},
Risk: "read",
Examples: []HelpExample{{Title: "demo", Cmd: "--name alice"}},
Execute: func(ctx context.Context, args *adapterArgs, rt *RuntimeContext) error { return nil },
}
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
sub, _, _ := root.Find([]string{"+demo"})
if sub == nil || sub.HelpFunc() == nil {
t.Fatal("expected typed help func installed on subcommand")
}
}
// TestTypedShortcut_Mount verifies the no-context convenience Mount API still
// works after migration, mirroring legacy Shortcut.Mount so existing tests of
// migrated shortcuts don't need to switch to MountWithContext.
func TestTypedShortcut_Mount(t *testing.T) {
root := &cobra.Command{Use: "root"}
ts := TypedShortcut[*adapterArgs]{
Service: "x", Command: "+demo", AuthTypes: []string{"user"},
Risk: "read",
Execute: func(ctx context.Context, args *adapterArgs, rt *RuntimeContext) error { return nil },
}
ts.Mount(root, &cmdutil.Factory{})
sub, _, err := root.Find([]string{"+demo"})
if err != nil || sub == nil {
t.Fatalf("find subcommand: %v (sub=%v)", err, sub)
}
}
func TestTypedShortcut_GetAuthTypes(t *testing.T) {
ts := TypedShortcut[*stubArgs]{AuthTypes: []string{"user", "bot"}}
if got := ts.GetAuthTypes(); !slices.Equal(got, []string{"user", "bot"}) {
t.Errorf("GetAuthTypes=%v", got)
}
if got := (TypedShortcut[*stubArgs]{}).GetAuthTypes(); got != nil {
t.Errorf("empty GetAuthTypes=%v, want nil", got)
}
}
func TestTypedShortcut_ScopesForIdentity(t *testing.T) {
ts := TypedShortcut[*stubArgs]{
Scopes: []string{"base"},
UserScopes: []string{"u"},
BotScopes: []string{"b"},
}
cases := []struct {
identity string
want []string
}{
{"user", []string{"u"}},
{"bot", []string{"b"}},
{"other", []string{"base"}},
{"", []string{"base"}},
}
for _, c := range cases {
if got := ts.ScopesForIdentity(c.identity); !slices.Equal(got, c.want) {
t.Errorf("ScopesForIdentity(%q)=%v, want %v", c.identity, got, c.want)
}
}
// Falls back to Scopes when the identity-specific list is empty.
fallback := TypedShortcut[*stubArgs]{Scopes: []string{"base"}}
if got := fallback.ScopesForIdentity("user"); !slices.Equal(got, []string{"base"}) {
t.Errorf("fallback user=%v", got)
}
}
func TestTypedShortcut_ConditionalScopesForIdentity(t *testing.T) {
ts := TypedShortcut[*stubArgs]{
ConditionalScopes: []string{"cbase"},
ConditionalUserScopes: []string{"cu"},
ConditionalBotScopes: []string{"cb"},
}
cases := []struct {
identity string
want []string
}{
{"user", []string{"cu"}},
{"bot", []string{"cb"}},
{"other", []string{"cbase"}},
}
for _, c := range cases {
if got := ts.ConditionalScopesForIdentity(c.identity); !slices.Equal(got, c.want) {
t.Errorf("ConditionalScopesForIdentity(%q)=%v, want %v", c.identity, got, c.want)
}
}
}
func TestTypedShortcut_DeclaredScopesForIdentity(t *testing.T) {
// Merges base + conditional, dedupes overlap, drops empty strings.
ts := TypedShortcut[*stubArgs]{
UserScopes: []string{"a", "b", ""},
ConditionalUserScopes: []string{"b", "c"},
}
if got := ts.DeclaredScopesForIdentity("user"); !slices.Equal(got, []string{"a", "b", "c"}) {
t.Errorf("merge+dedupe got %v", got)
}
// Returns nil when nothing is declared on either side.
if got := (TypedShortcut[*stubArgs]{}).DeclaredScopesForIdentity("user"); got != nil {
t.Errorf("empty got %v, want nil", got)
}
}

View File

@@ -125,23 +125,3 @@ func (s *Shortcut) DeclaredScopesForIdentity(identity string) []string {
}
return out
}
// GetService returns the parent cobra command name (e.g. "im"). Trivial
// accessor so *Shortcut satisfies ShortcutDescriptor alongside the existing
// pointer-receiver scope methods.
func (s *Shortcut) GetService() string { return s.Service }
// GetCommand returns the shortcut subcommand name (e.g. "+messages-send").
func (s *Shortcut) GetCommand() string { return s.Command }
// GetDescription returns the short help text.
func (s *Shortcut) GetDescription() string { return s.Description }
// GetAuthTypes returns the supported identities. Defaults to ["user"] is
// applied at mount time, not here, to preserve the existing field semantics.
func (s *Shortcut) GetAuthTypes() []string { return s.AuthTypes }
// GetRisk returns the static risk level ("read" / "write" / "high-risk-write").
// Empty string defaults to "read" by convention; callers must handle that
// case (same convention as the existing cobra annotation logic).
func (s *Shortcut) GetRisk() string { return s.Risk }

View File

@@ -105,32 +105,3 @@ func TestDeclaredScopesForIdentity_ConditionalOnly(t *testing.T) {
t.Errorf("expected conditional-only declared scopes, got %v", got)
}
}
func TestShortcut_DescriptorAccessors(t *testing.T) {
s := &Shortcut{
Service: "im",
Command: "+messages-send",
Description: "Send a message",
AuthTypes: []string{"user", "bot"},
Risk: "write",
}
if s.GetService() != "im" {
t.Errorf("GetService = %q", s.GetService())
}
if s.GetCommand() != "+messages-send" {
t.Errorf("GetCommand = %q", s.GetCommand())
}
if s.GetDescription() != "Send a message" {
t.Errorf("GetDescription = %q", s.GetDescription())
}
if got := s.GetAuthTypes(); len(got) != 2 || got[0] != "user" || got[1] != "bot" {
t.Errorf("GetAuthTypes = %v", got)
}
if s.GetRisk() != "write" {
t.Errorf("GetRisk = %q", s.GetRisk())
}
}
func TestShortcut_SatisfiesShortcutDescriptor(t *testing.T) {
var _ ShortcutDescriptor = &Shortcut{}
}

View File

@@ -53,55 +53,35 @@ func IsShortcutServiceAvailable(service string, brand core.LarkBrand) bool {
return slices.Contains(allowed, brand)
}
// allShortcuts aggregates shortcuts from all domain packages. Each entry is a
// Mountable: both legacy common.Shortcut (via *Shortcut after the descriptor
// accessor methods landed) and the new generic common.TypedShortcut[T] satisfy
// it. The slice element type is ShortcutDescriptor so read-only consumers
// (auth login, scope hint, shortcuts.json generator) can read metadata without
// caring about which concrete implementation backs each entry.
//
// Only legacy shortcuts are registered today; the typed track stays wired
// (ShortcutDescriptor / Mountable dispatch + buildTypedHelp) but currently has
// no domain caller.
var allShortcuts []common.ShortcutDescriptor
// addLegacy boxes a legacy []common.Shortcut into the descriptor slice. We use
// pointer-valued elements because the pointer-receiver scope methods
// (ScopesForIdentity / ConditionalScopesForIdentity / DeclaredScopesForIdentity)
// are required by ShortcutDescriptor — value receivers don't satisfy them.
func addLegacy(list []common.Shortcut) {
for i := range list {
allShortcuts = append(allShortcuts, &list[i])
}
}
// allShortcuts aggregates shortcuts from all domain packages.
var allShortcuts []common.Shortcut
func init() {
addLegacy(apps.Shortcuts())
addLegacy(calendar.Shortcuts())
addLegacy(doc.Shortcuts())
addLegacy(drive.Shortcuts())
addLegacy(im.Shortcuts())
addLegacy(contact_shortcuts.Shortcuts())
addLegacy(sheets.Shortcuts())
addLegacy(base.Shortcuts())
addLegacy(event.Shortcuts())
addLegacy(mail.Shortcuts())
addLegacy(markdown.Shortcuts())
addLegacy(slides.Shortcuts())
addLegacy(minutes.Shortcuts())
addLegacy(task.Shortcuts())
addLegacy(vc.Shortcuts())
addLegacy(whiteboard.Shortcuts())
addLegacy(wiki.Shortcuts())
addLegacy(okr.Shortcuts())
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
allShortcuts = append(allShortcuts, drive.Shortcuts()...)
allShortcuts = append(allShortcuts, im.Shortcuts()...)
allShortcuts = append(allShortcuts, contact_shortcuts.Shortcuts()...)
allShortcuts = append(allShortcuts, sheets.Shortcuts()...)
allShortcuts = append(allShortcuts, base.Shortcuts()...)
allShortcuts = append(allShortcuts, event.Shortcuts()...)
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
allShortcuts = append(allShortcuts, markdown.Shortcuts()...)
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
allShortcuts = append(allShortcuts, task.Shortcuts()...)
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
allShortcuts = append(allShortcuts, okr.Shortcuts()...)
}
// AllShortcuts returns a copy of all registered shortcut descriptors (for
// dump-shortcuts and auth/scope-hint consumers).
// AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts).
//
//go:noinline
func AllShortcuts() []common.ShortcutDescriptor {
return append([]common.ShortcutDescriptor(nil), allShortcuts...)
func AllShortcuts() []common.Shortcut {
return append([]common.Shortcut(nil), allShortcuts...)
}
// RegisterShortcuts registers all +shortcut commands on the program.
@@ -118,15 +98,10 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
}
}
// Group by service. Each entry is a Mountable so MountWithContext works
// uniformly across legacy *Shortcut and TypedShortcut[T].
byService := make(map[string][]common.Mountable)
for _, d := range allShortcuts {
m, ok := d.(common.Mountable)
if !ok {
panic(fmt.Sprintf("shortcut %s/%s missing Mountable", d.GetService(), d.GetCommand()))
}
byService[d.GetService()] = append(byService[d.GetService()], m)
// Group by service
byService := make(map[string][]common.Shortcut)
for _, s := range allShortcuts {
byService[s.Service] = append(byService[s.Service], s)
}
for service, shortcuts := range byService {
@@ -165,8 +140,8 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
doc.ConfigureServiceHelp(svc)
}
for _, m := range shortcuts {
m.MountWithContext(ctx, svc, f)
for _, shortcut := range shortcuts {
shortcut.MountWithContext(ctx, svc, f)
}
if service == "mail" {
mail.InstallOnMail(svc)

View File

@@ -17,7 +17,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -48,16 +47,10 @@ func newRegisterTestProgramWithTipsHelp() *cobra.Command {
}
func TestAllShortcutsScopesNotNil(t *testing.T) {
for _, d := range allShortcuts {
legacy, ok := d.(*common.Shortcut)
if !ok {
// Typed shortcuts enforce scope-presence at their own test layer
// (TypedShortcut[T] integration tests); the descriptor interface
// doesn't expose the raw fields needed for the nil-vs-empty check.
continue
}
if legacy.Scopes == nil && legacy.UserScopes == nil && legacy.BotScopes == nil {
t.Errorf("shortcut %s/%s: Scopes is nil (must be explicitly set, use []string{} if no scopes needed)", legacy.Service, legacy.Command)
for _, s := range allShortcuts {
hasScopes := s.Scopes != nil || s.UserScopes != nil || s.BotScopes != nil
if !hasScopes {
t.Errorf("shortcut %s/%s: Scopes is nil (must be explicitly set, use []string{} if no scopes needed)", s.Service, s.Command)
}
}
}
@@ -69,8 +62,8 @@ func TestAllShortcutsReturnsCopyAndIncludesBase(t *testing.T) {
}
hasBaseGet := false
for _, d := range shortcuts {
if d.GetService() == "base" && d.GetCommand() == "+base-get" {
for _, shortcut := range shortcuts {
if shortcut.Service == "base" && shortcut.Command == "+base-get" {
hasBaseGet = true
break
}
@@ -79,29 +72,9 @@ func TestAllShortcutsReturnsCopyAndIncludesBase(t *testing.T) {
t.Fatal("AllShortcuts does not include base/+base-get")
}
// Returned slice is a defensive copy of the descriptor references;
// appending to it must not affect the canonical registry. (Element
// identity is shared by design — mutation through pointers is the
// caller's responsibility to avoid, same as any interface slice.)
got := AllShortcuts()
got = append(got, &common.Shortcut{Service: "synthetic"})
if len(AllShortcuts()) >= len(got) {
t.Fatal("AllShortcuts should return a copy that callers can append to without affecting the registry")
}
}
func TestAllShortcuts_ReturnsShortcutDescriptors(t *testing.T) {
list := AllShortcuts()
if len(list) == 0 {
t.Fatal("AllShortcuts returned empty slice")
}
for _, d := range list {
if d.GetService() == "" {
t.Errorf("shortcut missing service: %T", d)
}
if d.GetCommand() == "" {
t.Errorf("shortcut %q missing command", d.GetService())
}
shortcuts[0].Service = "mutated"
if AllShortcuts()[0].Service == "mutated" {
t.Fatal("AllShortcuts should return a copy")
}
}
@@ -478,10 +451,10 @@ func TestGenerateShortcutsJSON(t *testing.T) {
}
grouped := make(map[string][]entry)
for _, s := range shortcuts {
verb := strings.TrimPrefix(s.GetCommand(), "+")
grouped[s.GetService()] = append(grouped[s.GetService()], entry{
verb := strings.TrimPrefix(s.Command, "+")
grouped[s.Service] = append(grouped[s.Service], entry{
Verb: verb,
Description: s.GetDescription(),
Description: s.Description,
Scopes: s.DeclaredScopesForIdentity("user"),
})
}

View File

@@ -18,8 +18,10 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli {{service}} +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|----------|------|
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 | Reference |
|----------|------|-----------|
{{shortcut_rows}}
{{/shortcuts}}
{{#actions}}

View File

@@ -96,6 +96,8 @@ lark-cli auth login --domain apps
Shortcut 是对常用操作的高级封装(`lark-cli apps +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-apps-create.md) | 创建妙搭应用name / description / icon-url |

View File

@@ -70,6 +70,8 @@ Calendar (日历)
Shortcut 是对常用操作的高级封装(`lark-cli calendar +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |

View File

@@ -14,6 +14,8 @@ metadata:
**user 身份和 bot 身份是两条完全独立的路径**。先确定当前身份,再按下表选命令:
**CRITICAL — 执行任何命令之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| 想做什么 | user 身份 | bot 身份 |
|---|---|---|
| 按姓名 / 邮箱搜员工拿 open_id | [`+search-user`](references/lark-contact-search-user.md) | 不支持 |

View File

@@ -59,6 +59,8 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |

View File

@@ -260,6 +260,8 @@ lark-cli drive permission.members create \
Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`+search`](references/lark-drive-search.md) | Search Lark docs, Wiki, and spreadsheet files with flat filter flags. Natural-language-friendly: `--edited-since`, `--mine`, `--doc-types`, etc. |

View File

@@ -70,6 +70,8 @@ Item types for feed-layer flags:
Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |

View File

@@ -464,6 +464,8 @@ lark-cli mail user_mailbox.folders create \
Shortcut 是对常用操作的高级封装(`lark-cli mail +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+message`](references/lark-mail-message.md) | Use when reading full content for a single email by message ID. Returns normalized body content plus attachments metadata, including inline images. |

View File

@@ -41,6 +41,8 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli markdown +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-markdown-create.md) | Create a Markdown file in Drive |

View File

@@ -111,6 +111,8 @@ Minutes (妙记) ← minute_token 标识
Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
| -------------------------------------------------- | --------------------------------------------------------------- |
| [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range |

View File

@@ -16,6 +16,8 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|--------------------------------------------------------------|--------------------------|
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |

View File

@@ -123,6 +123,7 @@ lark-cli 命令执行后如果检测到新版本JSON 输出中会包含 `_
## 安全规则
- **文件路径只接受相对路径**`--file`、`--output`、`--output-dir`、`@file` 等路径参数只接受 cwd 下的相对路径,传绝对路径会报 `unsafe file path`。数据输入(`@file`、大 JSON优先用 stdin 传入,避免路径和转义问题。
- **禁止输出密钥**appSecret、accessToken到终端明文。
- **写入/删除操作前必须确认用户意图**。
- 用 `--dry-run` 预览危险请求。

View File

@@ -179,6 +179,8 @@ lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
### Spreadsheet Management
对应参考文档:[spreadsheet-management](references/lark-sheets-spreadsheet-management.md)

View File

@@ -256,6 +256,8 @@ Slides (演示文稿)
Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |

View File

@@ -33,6 +33,8 @@ metadata:
> Task OpenAPI 中用于更新/操作任务的 `guid` 是任务的全局唯一标识GUID不是客户端展示的任务编号例如 `t104121` / `suite_entity_num`)。
> 对于 Feishu 的任务 applink例如 `.../client/todo/task?guid=...`),必须使用 URL query 里的 `guid` 参数作为 task guid。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-task-create.md) | create a task |

View File

@@ -94,6 +94,8 @@ lark-cli vc +notes --meeting-ids "$MID"
Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 类型 | 说明 |
| --------------------------------------------------------------- | -- | -------------------------------------------------------------------------- |
| [`+meeting-join`](references/lark-vc-agent-meeting-join.md) | 写 | Join an in-progress meeting by 9-digit meeting number |

View File

@@ -137,6 +137,8 @@ Meeting (视频会议)
Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-vc-search.md) | Search meeting records (requires at least one filter) |

View File

@@ -33,6 +33,8 @@ metadata:
## Shortcuts
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|---|---|
| [`+query`](references/lark-whiteboard-query.md) | 查询画板,导出为预览图片、代码或原始节点结构 |

View File

@@ -59,6 +59,8 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+move`](references/lark-wiki-move.md) | Move a wiki node, or move a Drive document into Wiki |