Skip to content

Commit 6d67e5b

Browse files
committed
feat(shortcuts): introduce TypedShortcut framework + im send pilot
Add strongly-typed shortcut protocol that coexists with the legacy common.Shortcut. The new common.TypedShortcut[T] is a generic outer wrapper backed by a reflect-driven binder; both legacy and typed shortcuts satisfy a new common.Mountable / common.ShortcutDescriptor interface pair so register.go can dispatch either through the same pipeline. Framework (shortcuts/common): - protocol.go — Mountable / ShortcutDescriptor / OneOfMarker / Validatable / Normalizable[T] / ArgsValidator / Maybe[T] / HelpExample - binder.go — reflect Args walk, intra-Args flag-tag uniqueness panic, cobra flag registration, bindFlags + bindMaybe, runNormalize (via MethodByName dispatch — Normalizable[T] can't be type-asserted through a non-generic interface), runValidateValue, runFrameworkRules for required / enum / OneOf / group - typed_shortcut.go — TypedShortcut[T] struct, 8 descriptor methods, mountTyped adapter that synthesizes a legacy Shortcut shell and reuses runShortcut verbatim (identity / scopes / @file / stdin / jq / dry-run / high-risk gate) - typed_help.go — sectioned --help (CHOOSE ONE / OPTIONAL / EXAMPLES) with cmdutil.GetRisk/GetTips passthrough so typed shortcuts keep the Risk: and Tips: blocks - runner.go — typedArgs lifecycle slot on RuntimeContext - types.go — 5 GetX accessors on *Shortcut so legacy shortcuts satisfy ShortcutDescriptor alongside the existing pointer-receiver scope methods Typed primitives (shortcuts/common/argstype): - ChatID / UserOpenID / UserOpenIDList — prefix-validated identifiers - SafePath — cwd-relative, rejects absolute paths and ".." segments - MediaInput — tri-state (URL bypass / img_xxx-file_xxx key bypass / SafePath delegation) - SpreadsheetRef — Normalize extracts shtcn token from feishu URLs Error contract (errs): - 3 new Subtype constants: shortcut_oneof_missing / shortcut_oneof_multiple / shortcut_group_incomplete. Per-field failures (required / enum / typed primitive format) reuse the existing SubtypeInvalidArgument so no new error type is introduced. Registry refactor (shortcuts + cmd): - AllShortcuts() now returns []common.ShortcutDescriptor; legacy shortcuts are boxed as *Shortcut (pointer required for the pointer-receiver scope methods), typed shortcuts boxed as Mountable - Register dispatches via the Mountable interface - cmd/auth/login, cmd/auth/login_interactive, cmd/error_auth_hint, cmd/diagnose_scope_test, shortcuts/register_test (shortcuts.json generator) updated to read through GetService / GetCommand / GetAuthTypes / GetDescription / DeclaredScopesForIdentity - shortcutSupportsIdentity helpers accept ShortcutDescriptor Pilot (shortcuts/im): - protocol.go — MessageTarget / MessageContent (with seven content variants) / VideoContent (paired video + cover) / RawContent (--content with explicit msg-type, validates JSON in ValidateValue) - im_messages_send.go — migrated to TypedShortcut[*ImMessagesSendArgs]. Inline Validate closure is replaced by framework-derived checks (OneOf target, OneOf content, VideoContent group, typed-primitive formats, RawContent JSON). Helpers (resolveMediaContent, wrapMarkdownAsPostForDryRun, normalizeAtMentions, etc.) reused verbatim; only the field-access pattern changes from runtime.Str("x") to args.X. - shortcuts.go — new TypedShortcuts() exporter; ImMessagesSend removed from the legacy Shortcuts() slice so it is not double-mounted - register.go wires addTyped(im.TypedShortcuts()) into init Known follow-ups: - runFrameworkRules and bindFlags do not recurse into OneOf bucket / group sub-structs; im messages-send compensates with a local bindMessagesSendArgs + validateVideoGroup. Generalizing the binder to recurse will let future migrations drop the local shim. - common.ValidateChatID / common.ValidateUserID become redundant once all legacy shortcuts that call them migrate; can be retired with the last legacy caller. Refs: docs/superpowers/specs/2026-05-26-shortcut-protocol-design.md
1 parent ee9d090 commit 6d67e5b

42 files changed

Lines changed: 3060 additions & 240 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/auth/login.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -518,10 +518,10 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
518518

519519
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
520520
for _, sc := range shortcuts.AllShortcuts() {
521-
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
521+
if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
522522
continue
523523
}
524-
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
524+
if domainSet[sc.GetService()] && shortcutSupportsIdentity(sc, identity) {
525525
for _, s := range sc.DeclaredScopesForIdentity(identity) {
526526
scopeSet[s] = true
527527
}
@@ -548,11 +548,11 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
548548
}
549549
}
550550
for _, sc := range shortcuts.AllShortcuts() {
551-
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
551+
if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
552552
continue
553553
}
554-
if !registry.HasAuthDomain(sc.Service) {
555-
domains[sc.Service] = true
554+
if !registry.HasAuthDomain(sc.GetService()) {
555+
domains[sc.GetService()] = true
556556
}
557557
}
558558
return domains
@@ -571,8 +571,8 @@ func sortedKnownDomains(brand core.LarkBrand) []string {
571571

572572
// shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot").
573573
// Empty AuthTypes defaults to ["user"].
574-
func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
575-
authTypes := sc.AuthTypes
574+
func shortcutSupportsIdentity(sc common.ShortcutDescriptor, identity string) bool {
575+
authTypes := sc.GetAuthTypes()
576576
if len(authTypes) == 0 {
577577
authTypes = []string{"user"}
578578
}

cmd/auth/login_interactive.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,13 @@ func getDomainMetadata(lang string) []domainMeta {
6363
shortcutOnlySet[n] = true
6464
}
6565
for _, sc := range shortcuts.AllShortcuts() {
66-
if !seen[sc.Service] {
67-
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
68-
dm := buildDomainMeta(sc.Service, lang)
66+
svc := sc.GetService()
67+
if !seen[svc] {
68+
if shortcutOnlySet[svc] && !registry.HasAuthDomain(svc) {
69+
dm := buildDomainMeta(svc, lang)
6970
domains = append(domains, dm)
7071
}
71-
seen[sc.Service] = true
72+
seen[svc] = true
7273
}
7374
}
7475

cmd/auth/login_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ func TestNormalizeScopeInput(t *testing.T) {
9898

9999
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
100100
// Empty AuthTypes defaults to ["user"]
101-
sc := common.Shortcut{AuthTypes: nil}
101+
sc := &common.Shortcut{AuthTypes: nil}
102102
if !shortcutSupportsIdentity(sc, "user") {
103103
t.Error("expected default to support 'user'")
104104
}
@@ -108,7 +108,7 @@ func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
108108
}
109109

110110
func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
111-
sc := common.Shortcut{AuthTypes: []string{"user", "bot"}}
111+
sc := &common.Shortcut{AuthTypes: []string{"user", "bot"}}
112112
if !shortcutSupportsIdentity(sc, "user") {
113113
t.Error("expected to support 'user'")
114114
}
@@ -121,7 +121,7 @@ func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
121121
}
122122

123123
func TestShortcutSupportsIdentity_BotOnly(t *testing.T) {
124-
sc := common.Shortcut{AuthTypes: []string{"bot"}}
124+
sc := &common.Shortcut{AuthTypes: []string{"bot"}}
125125
if shortcutSupportsIdentity(sc, "user") {
126126
t.Error("expected bot-only to NOT support 'user'")
127127
}

cmd/diagnose_scope_test.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ func diagAllKnownDomains() []string {
4747
seen[p] = true
4848
}
4949
for _, s := range shortcuts.AllShortcuts() {
50-
if s.Service != "" {
51-
seen[s.Service] = true
50+
if s.GetService() != "" {
51+
seen[s.GetService()] = true
5252
}
5353
}
5454
result := make([]string, 0, len(seen))
@@ -94,17 +94,17 @@ func diagBuild(domains []string) diagOutput {
9494
}
9595

9696
for _, sc := range allSC {
97-
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
97+
if sc.GetService() != domain || !diagShortcutSupportsIdentity(sc, identity) {
9898
continue
9999
}
100100
for _, scope := range sc.DeclaredScopesForIdentity(identity) {
101-
k := methodKey{domain, "shortcut", sc.Command, scope}
101+
k := methodKey{domain, "shortcut", sc.GetCommand(), scope}
102102
if e, ok := merged[k]; ok {
103103
e.Identity = appendUniq(e.Identity, identity)
104104
} else {
105105
merged[k] = &diagMethodEntry{
106106
Domain: domain, Type: "shortcut",
107-
Method: sc.Command,
107+
Method: sc.GetCommand(),
108108
Scope: scope, Identity: []string{identity},
109109
}
110110
}
@@ -148,11 +148,12 @@ func diagBuild(domains []string) diagOutput {
148148
return diagOutput{Methods: methods, Scopes: scopes}
149149
}
150150

151-
func diagShortcutSupportsIdentity(sc *shortcutTypes.Shortcut, identity string) bool {
152-
if len(sc.AuthTypes) == 0 {
151+
func diagShortcutSupportsIdentity(sc shortcutTypes.ShortcutDescriptor, identity string) bool {
152+
authTypes := sc.GetAuthTypes()
153+
if len(authTypes) == 0 {
153154
return identity == "user"
154155
}
155-
for _, a := range sc.AuthTypes {
156+
for _, a := range authTypes {
156157
if a == identity {
157158
return true
158159
}

cmd/error_auth_hint.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
111111

112112
service := cmd.Parent().Name()
113113
for _, sc := range shortcuts.AllShortcuts() {
114-
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
114+
if sc.GetService() != service || sc.GetCommand() != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
115115
continue
116116
}
117117
scopes := sc.DeclaredScopesForIdentity(identity)
@@ -200,8 +200,8 @@ func interfaceStrings(values []interface{}) []string {
200200

201201
// shortcutSupportsIdentity reports whether a shortcut supports the requested
202202
// identity, applying the default user-only behavior when AuthTypes is empty.
203-
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
204-
authTypes := sc.AuthTypes
203+
func shortcutSupportsIdentity(sc shortcutcommon.ShortcutDescriptor, identity string) bool {
204+
authTypes := sc.GetAuthTypes()
205205
if len(authTypes) == 0 {
206206
authTypes = []string{string(core.AsUser)}
207207
}

errs/subtypes_shortcut.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package errs
5+
6+
// Subtypes raised by the typed shortcut protocol (shortcuts/common). Only
7+
// cross-field semantic failures need their own subtype here; per-field
8+
// failures (required missing / enum invalid / typed-primitive format) reuse
9+
// SubtypeInvalidArgument.
10+
const (
11+
SubtypeShortcutOneOfMissing Subtype = "shortcut_oneof_missing"
12+
SubtypeShortcutOneOfMultiple Subtype = "shortcut_oneof_multiple"
13+
SubtypeShortcutGroupIncomplete Subtype = "shortcut_group_incomplete"
14+
)

errs/subtypes_shortcut_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package errs
5+
6+
import "testing"
7+
8+
func TestShortcutSubtypes_Values(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
got Subtype
12+
want string
13+
}{
14+
{"OneOfMissing", SubtypeShortcutOneOfMissing, "shortcut_oneof_missing"},
15+
{"OneOfMultiple", SubtypeShortcutOneOfMultiple, "shortcut_oneof_multiple"},
16+
{"GroupIncomplete", SubtypeShortcutGroupIncomplete, "shortcut_group_incomplete"},
17+
}
18+
for _, tt := range tests {
19+
t.Run(tt.name, func(t *testing.T) {
20+
if string(tt.got) != tt.want {
21+
t.Errorf("got %q, want %q", string(tt.got), tt.want)
22+
}
23+
})
24+
}
25+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package argstype
5+
6+
import (
7+
"strings"
8+
9+
"github.com/larksuite/cli/errs"
10+
"github.com/larksuite/cli/shortcuts/common"
11+
)
12+
13+
// ChatID is a typed Lark chat identifier with the "oc_" prefix.
14+
// Satisfies common.Validatable.
15+
type ChatID string
16+
17+
// ValidateValue checks the oc_ prefix and trims whitespace. Empty values are
18+
// rejected here even though required-ness is enforced by the binder; this
19+
// keeps the type safe to call as a standalone validator.
20+
func (id ChatID) ValidateValue(_ *common.RuntimeContext, flagName string) error {
21+
s := strings.TrimSpace(string(id))
22+
if s == "" {
23+
return &errs.ValidationError{
24+
Problem: errs.Problem{
25+
Category: errs.CategoryValidation,
26+
Subtype: errs.SubtypeInvalidArgument,
27+
Message: "chat ID is required",
28+
Hint: "pass --chat-id oc_xxx",
29+
},
30+
Param: flagName,
31+
}
32+
}
33+
if !strings.HasPrefix(s, "oc_") {
34+
return &errs.ValidationError{
35+
Problem: errs.Problem{
36+
Category: errs.CategoryValidation,
37+
Subtype: errs.SubtypeInvalidArgument,
38+
Message: "invalid chat ID format: expected oc_xxx",
39+
},
40+
Param: flagName,
41+
}
42+
}
43+
return nil
44+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package argstype
5+
6+
import (
7+
"errors"
8+
"testing"
9+
10+
"github.com/larksuite/cli/errs"
11+
)
12+
13+
func TestChatID_ValidatePass(t *testing.T) {
14+
id := ChatID("oc_abc123")
15+
if err := id.ValidateValue(nil, "chat-id"); err != nil {
16+
t.Errorf("oc_ prefix should pass, got: %v", err)
17+
}
18+
}
19+
20+
func TestChatID_ValidateReject(t *testing.T) {
21+
tests := []struct {
22+
name string
23+
v string
24+
}{
25+
{"empty", ""},
26+
{"wrong prefix", "ou_abc"},
27+
{"random", "abc123"},
28+
}
29+
for _, tt := range tests {
30+
t.Run(tt.name, func(t *testing.T) {
31+
err := ChatID(tt.v).ValidateValue(nil, "chat-id")
32+
if err == nil {
33+
t.Fatal("expected error, got nil")
34+
}
35+
var ve *errs.ValidationError
36+
if !errors.As(err, &ve) {
37+
t.Fatalf("expected *errs.ValidationError, got %T", err)
38+
}
39+
if ve.Subtype != errs.SubtypeInvalidArgument {
40+
t.Errorf("Subtype = %q, want invalid_argument", ve.Subtype)
41+
}
42+
if ve.Param != "chat-id" {
43+
t.Errorf("Param = %q, want chat-id", ve.Param)
44+
}
45+
})
46+
}
47+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package argstype
5+
6+
import (
7+
"strings"
8+
9+
"github.com/larksuite/cli/shortcuts/common"
10+
)
11+
12+
// MediaInput is the tri-state media-field value used by im image/file/video/
13+
// audio flags: URL, "img_xxx"/"file_xxx" key, or cwd-relative local path.
14+
// URL and key forms bypass path safety checks; local paths go through the
15+
// same SafePath rules. Does not emit absolute paths in hints (log safety).
16+
type MediaInput string
17+
18+
// IsURL reports whether the value looks like an http(s) URL.
19+
func (m MediaInput) IsURL() bool {
20+
s := string(m)
21+
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
22+
}
23+
24+
// IsMediaKey reports whether the value is an already-uploaded media key.
25+
func (m MediaInput) IsMediaKey() bool {
26+
s := string(m)
27+
return strings.HasPrefix(s, "img_") || strings.HasPrefix(s, "file_")
28+
}
29+
30+
func (m MediaInput) ValidateValue(rt *common.RuntimeContext, flagName string) error {
31+
if string(m) == "" {
32+
return nil
33+
}
34+
if m.IsURL() || m.IsMediaKey() {
35+
return nil
36+
}
37+
return SafePath(m).ValidateValue(rt, flagName)
38+
}

0 commit comments

Comments
 (0)