fix(command): complete extension runtime contracts

This commit is contained in:
sang-neo03
2026-08-11 22:45:49 +08:00
parent 1a04a5879f
commit 4d0c6ea619
33 changed files with 1319 additions and 295 deletions
+16
View File
@@ -0,0 +1,16 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package affordance exposes the repository's default embedded command guidance.
package affordance
import (
"embed"
"io/fs"
)
//go:embed *.md
var content embed.FS
// DefaultFS returns the immutable default affordance tree rooted at domain files.
func DefaultFS() fs.FS { return content }
+15
View File
@@ -0,0 +1,15 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package affordance
import (
"io/fs"
"testing"
)
func TestDefaultFSContainsDomainGuidance(t *testing.T) {
if _, err := fs.ReadFile(DefaultFS(), "im.md"); err != nil {
t.Fatalf("read im.md: %v", err)
}
}
+10 -2
View File
@@ -509,6 +509,10 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
// Domains with auth_domain children are automatically expanded to include
// their children's scopes.
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
return collectScopesForDomainsWithShortcuts(domains, identity, brand, shortcuts.AllShortcuts())
}
func collectScopesForDomainsWithShortcuts(domains []string, identity string, brand core.LarkBrand, registered []common.Shortcut) []string {
scopeSet := make(map[string]bool)
// 1. API scopes from from_meta projects
@@ -526,7 +530,7 @@ 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() {
for _, sc := range registered {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
@@ -550,13 +554,17 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
// shortcut services), excluding domains that have auth_domain set (they are
// folded into their parent domain).
func allKnownDomains(brand core.LarkBrand) map[string]bool {
return allKnownDomainsWithShortcuts(brand, shortcuts.AllShortcuts())
}
func allKnownDomainsWithShortcuts(brand core.LarkBrand, registered []common.Shortcut) map[string]bool {
domains := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
if !registry.HasAuthDomain(p) {
domains[p] = true
}
}
for _, sc := range shortcuts.AllShortcuts() {
for _, sc := range registered {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
+16
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"io"
"net/http"
"slices"
"sort"
"strings"
"testing"
@@ -304,6 +305,21 @@ func TestGetDomainMetadataIncludesAuthorizableShortcutDomains(t *testing.T) {
}
}
func TestExternalShortcutScopesParticipateInAuthDomainResolution(t *testing.T) {
registered := []common.Shortcut{{
Service: "im", Command: "+business-auth", AuthTypes: []string{"user"},
UserScopes: []string{"im:business.scope:read"},
}}
domains := allKnownDomainsWithShortcuts("", registered)
if !domains["im"] {
t.Fatal("external shortcut domain is missing from auth domains")
}
scopes := collectScopesForDomainsWithShortcuts([]string{"im"}, "user", "", registered)
if !slices.Contains(scopes, "im:business.scope:read") {
t.Fatalf("external shortcut scope is missing: %v", scopes)
}
}
func TestGetDomainMetadataMatchesAllKnownDomains(t *testing.T) {
metadata := getDomainMetadata("zh")
known := allKnownDomains("")
+38 -1
View File
@@ -4,6 +4,7 @@
package cmd
import (
"bytes"
"context"
"errors"
"os"
@@ -47,7 +48,7 @@ func businessCommand(name string, executed *bool) command.Command {
}
func TestWithCommandSetsInIsolatedProcesses(t *testing.T) {
for _, scenario := range []string{"official", "mount", "atomic", "governance"} {
for _, scenario := range []string{"official", "mount", "atomic", "governance", "surface"} {
t.Run(scenario, func(t *testing.T) {
process := exec.Command(os.Args[0], "-test.run=^TestCommandSetSubprocess$", "-test.v")
process.Env = append(os.Environ(), "LARK_CLI_COMMAND_SET_SCENARIO="+scenario)
@@ -134,6 +135,42 @@ func TestCommandSetSubprocess(t *testing.T) {
if executed {
t.Fatal("governance denial reached business Execute")
}
case "surface":
var stdout, stderr bytes.Buffer
root := Build(context.Background(), buildInvocationForTest(t),
WithIO(strings.NewReader(""), &stdout, &stderr),
WithCommandSets(command.Set{
Domain: command.ExtendDomain(command.DomainIm),
Commands: []command.Command{businessCommand("+business-surface", nil)},
}),
WithoutPlugins(), WithoutStrictMode(), WithoutServiceCommands(),
)
root.SetArgs([]string{"__complete", "im", "+"})
if _, err := root.ExecuteC(); err != nil {
t.Fatalf("complete external command: %v\nstderr: %s", err, stderr.String())
}
if !strings.Contains(stdout.String(), "+business-surface") {
t.Fatalf("external command is missing from shell completion: %s", stdout.String())
}
stdout.Reset()
stderr.Reset()
root.SetArgs([]string{"__complete", "schema", "im", "+business-"})
if _, err := root.ExecuteC(); err != nil {
t.Fatalf("complete external schema: %v\nstderr: %s", err, stderr.String())
}
if !strings.Contains(stdout.String(), "+business-surface") {
t.Fatalf("external schema is missing from shell completion: %s", stdout.String())
}
stdout.Reset()
stderr.Reset()
root.SetArgs([]string{"schema", "im", "+business-surface"})
if _, err := root.ExecuteC(); err != nil {
t.Fatalf("schema external command: %v\nstderr: %s", err, stderr.String())
}
if !strings.Contains(stdout.String(), `"name": "im +business-surface"`) ||
!strings.Contains(stdout.String(), `"inputSchema"`) || !strings.Contains(stdout.String(), `"outputSchema"`) {
t.Fatalf("external schema = %s", stdout.String())
}
default:
t.Fatalf("unknown scenario %q", scenario)
}
+88
View File
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"io"
"sort"
"strings"
"github.com/larksuite/cli/errs"
@@ -17,6 +18,8 @@ import (
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/schema"
"github.com/larksuite/cli/shortcuts"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -100,6 +103,7 @@ func completeSchemaPath(
mode := f.ResolveStrictMode(cmd.Context())
catalog := projectSchemaCatalog(registry.SchemaCatalog(), visibility)
completions, noSpace := catalog.Complete(args, toComplete, registry.FilterForStrictMode(mode))
completions = mergeSchemaCompletions(completions, shortcutSchemaCompletions(args, toComplete, visibility))
directive := cobra.ShellCompDirectiveNoFileComp
if noSpace {
directive |= cobra.ShellCompDirectiveNoSpace
@@ -135,6 +139,10 @@ func runSchemaCatalog(
catalog apicatalog.Catalog,
visibility CommandVisibility,
) error {
if contract, ok := resolveShortcutSchema(parts, visibility); ok {
output.PrintJson(out, contract)
return nil
}
// Test the source catalog before presentation projection. A distribution
// that intentionally conceals every generated method still has metadata;
// bare `schema` should render an empty list rather than claim metadata is
@@ -164,6 +172,86 @@ func runSchemaCatalog(
return nil
}
func resolveShortcutSchema(parts []string, visibility CommandVisibility) (any, bool) {
if len(parts) != 2 || !strings.HasPrefix(parts[1], "+") {
return nil, false
}
for _, shortcut := range shortcuts.AllShortcuts() {
if shortcut.Service != parts[0] || shortcut.Command != parts[1] {
continue
}
if visibility != nil && !visibility([]string{shortcut.Service, shortcut.Command}) {
return nil, false
}
return common.ShortcutSchema(shortcut)
}
return nil, false
}
func shortcutSchemaCompletions(args []string, toComplete string, visibility CommandVisibility) []string {
registered := shortcuts.AllShortcuts()
if len(args) == 0 && strings.Contains(toComplete, ".") {
parts := strings.SplitN(toComplete, ".", 2)
return shortcutCommandCompletions(registered, parts[0], parts[1], parts[0]+".", visibility)
}
if len(args) == 0 {
services := make(map[string]struct{})
for _, shortcut := range registered {
if !strings.HasPrefix(shortcut.Service, toComplete) || !shortcutSchemaVisible(shortcut, visibility) {
continue
}
if _, ok := common.ShortcutSchema(shortcut); ok {
services[shortcut.Service] = struct{}{}
}
}
result := make([]string, 0, len(services))
for service := range services {
result = append(result, service)
}
sort.Strings(result)
return result
}
if len(args) == 1 {
return shortcutCommandCompletions(registered, args[0], toComplete, "", visibility)
}
return nil
}
func shortcutCommandCompletions(registered []common.Shortcut, service, prefix, outputPrefix string, visibility CommandVisibility) []string {
var result []string
for _, shortcut := range registered {
if shortcut.Service != service || !strings.HasPrefix(shortcut.Command, prefix) || !shortcutSchemaVisible(shortcut, visibility) {
continue
}
if _, ok := common.ShortcutSchema(shortcut); ok {
result = append(result, outputPrefix+shortcut.Command+"\t"+shortcut.Description)
}
}
sort.Strings(result)
return result
}
func shortcutSchemaVisible(shortcut common.Shortcut, visibility CommandVisibility) bool {
return visibility == nil || visibility([]string{shortcut.Service, shortcut.Command})
}
func mergeSchemaCompletions(groups ...[]string) []string {
seen := make(map[string]struct{})
var result []string
for _, group := range groups {
for _, candidate := range group {
name := strings.SplitN(candidate, "\t", 2)[0]
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
result = append(result, candidate)
}
}
sort.Strings(result)
return result
}
// projectSchemaCatalog produces the metadata view corresponding to one final
// command surface. It lives in cmd/schema so apicatalog remains a policy-free
// navigation module. Resolve, broad listings, and Complete all consume the
+6 -27
View File
@@ -4,37 +4,16 @@
package main
import (
"embed"
"fmt"
"io/fs"
"os"
defaultaffordance "github.com/larksuite/cli/affordance"
"github.com/larksuite/cli/cmd"
defaultskills "github.com/larksuite/cli/skills"
)
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's
// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md).
// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist —
// a new content type is omitted until added to the embed list. The embed must live
// in this root package because go:embed cannot reach up out of a package's dir.
//
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md
var embeddedContentFS embed.FS
// init wires the embedded content into the CLI. It compiles into `go build .` but
// not the single-file preview build (`go build ./main.go`), so that build stays
// self-contained (shipping no embedded content). Assembly failures warn on stderr
// rather than panicking — embedded content is nice-to-have, not load-bearing.
// self-contained (shipping no embedded content). External wrapper distributions
// can import the same default files from the skills and affordance packages.
func init() {
if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil {
fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err)
} else {
cmd.SetEmbeddedSkillContent(sub)
}
if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil {
fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err)
} else {
cmd.SetEmbeddedAffordanceContent(sub)
}
cmd.SetEmbeddedSkillContent(defaultskills.DefaultFS())
cmd.SetEmbeddedAffordanceContent(defaultaffordance.DefaultFS())
}
+18 -8
View File
@@ -118,14 +118,12 @@ func TestCollectPagesUsesHostPolicyAndMetadata(t *testing.T) {
var calls []RequestView
ctx := NewCommandContext(ContextOptions{
Identity: IdentityUser,
CallJSON: func(_ context.Context, request Request) (map[string]any, error) {
calls = append(calls, InspectRequest(request))
response := responses[0]
responses = responses[1:]
return response, nil
},
PaginationOptions: func() (PaginationOptions, error) {
return PaginationOptions{All: true, MaxPages: 10}, nil
CollectPages: func(_ context.Context, request Request, all bool) ([]map[string]any, HostPagination, error) {
if all {
t.Fatal("CollectPages forced full pagination")
}
calls = append(calls, InspectRequest(request), InspectRequest(request.Set("page_token", "next")))
return responses, HostPagination{Complete: true, Pages: 2}, nil
},
})
page, err := CollectPages[contractData](context.Background(), ctx, GET("/open-apis/im/v1/chats"))
@@ -145,6 +143,18 @@ func TestCollectPagesUsesHostPolicyAndMetadata(t *testing.T) {
}
}
func TestPageResultCountsFilteredItems(t *testing.T) {
page := Page[contractData]{
Items: []contractData{{ID: "one"}, {ID: "two"}},
meta: &paginationMeta{Complete: true, Pages: 1, Items: 2},
}
page.Items = page.Items[:1]
result := hostResult(Success(page))
if result.Pagination == nil || result.Pagination.Items != 1 {
t.Fatalf("filtered pagination = %#v", result.Pagination)
}
}
func TestDryRunPreventsRequestsAndScopeChecks(t *testing.T) {
calls := 0
ctx := NewCommandContext(ContextOptions{
@@ -6,7 +6,9 @@ package commandtest_test
import (
"context"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/extension/command"
@@ -39,7 +41,10 @@ func documentGetDefinition() command.Definition[documentGetArgs, documentData] {
},
Execute: func(ctx context.Context, commandContext command.CommandContext, args *documentGetArgs) (command.Result[documentData], error) {
data, err := command.CallJSON[documentData](ctx, commandContext, request(args))
return command.Success(data), err
if err != nil {
return command.Result[documentData]{}, err
}
return command.Success(data), nil
},
},
}
@@ -71,7 +76,10 @@ func chatListDefinition() command.Definition[chatListArgs, command.Page[chatData
},
Execute: func(ctx context.Context, commandContext command.CommandContext, args *chatListArgs) (command.Result[command.Page[chatData]], error) {
page, err := command.CollectPages[chatData](ctx, commandContext, request(args))
return command.Success(page), err
if err != nil {
return command.Result[command.Page[chatData]]{}, err
}
return command.Success(page), nil
},
},
}
@@ -125,7 +133,7 @@ func taskAuditDefinition() command.Definition[taskAuditArgs, taskAuditData] {
Execute: func(ctx context.Context, commandContext command.CommandContext, args *taskAuditArgs) (command.Result[taskAuditData], error) {
tasks, err := command.CollectAllPages[taskRecord](ctx, commandContext, listRequest)
if err != nil {
return command.Success(taskAuditData{}), err
return command.Result[taskAuditData]{}, err
}
data := taskAuditData{Items: make([]taskAuditItem, 0, len(tasks))}
if !args.IncludeOwners {
@@ -135,7 +143,7 @@ func taskAuditDefinition() command.Definition[taskAuditArgs, taskAuditData] {
return command.Success(data), nil
}
if err := command.PreflightScopes(commandContext, "contact:user.base:readonly"); err != nil {
return command.Success(data), err
return command.Result[taskAuditData]{}, err
}
for _, task := range tasks {
owner, ownerErr := command.CallJSON[struct {
@@ -191,17 +199,23 @@ func memberListDefinition() command.Definition[memberListArgs, memberListData] {
},
Execute: func(ctx context.Context, commandContext command.CommandContext, args *memberListArgs) (command.Result[memberListData], error) {
data, err := command.CallJSON[memberListData](ctx, commandContext, command.GET("/open-apis/im/v1/chats/"+args.ChatID))
if err != nil || !args.IncludeMembers {
return command.Success(data), err
if err != nil {
return command.Result[memberListData]{}, err
}
if !args.IncludeMembers {
return command.Success(data), nil
}
if err := command.PreflightScopes(commandContext, "im:chat.members:read"); err != nil {
return command.Success(data), err
return command.Result[memberListData]{}, err
}
members, err := command.CallJSON[struct {
Items []string `json:"items"`
}](ctx, commandContext, command.GET("/open-apis/im/v1/chats/"+args.ChatID+"/members"))
if err != nil {
return command.Result[memberListData]{}, err
}
data.Members = members.Items
return command.Success(data), err
return command.Success(data), nil
},
},
}
@@ -241,6 +255,16 @@ func TestSingleReadAndDryRunUseSameRequest(t *testing.T) {
recorder.AssertScriptConsumed()
}
func TestSingleReadPreservesTypedAPIError(t *testing.T) {
want := command.InvalidResponseErrorf("upstream response is malformed")
recorder := commandtest.New(t, commandtest.Fail(want))
_, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, documentGetDefinition(), &documentGetArgs{DocumentID: "doc_1"})
if !errors.Is(err, want) {
t.Fatalf("single read error = %v", err)
}
recorder.AssertScriptConsumed()
}
func TestListCommandUsesHostPagination(t *testing.T) {
recorder := commandtest.New(t,
commandtest.Respond(map[string]any{
@@ -250,8 +274,8 @@ func TestListCommandUsesHostPagination(t *testing.T) {
"items": []map[string]any{{"chat_id": "chat_2", "name": "two"}}, "has_more": false,
}),
)
recorder.SetPagination(command.PaginationOptions{All: true, MaxPages: 3})
execution, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, chatListDefinition(), &chatListArgs{PageSize: 20})
execution, err := commandtest.RunWithFlags(context.Background(), recorder, command.IdentityUser,
chatListDefinition(), &chatListArgs{PageSize: 20}, "--page-all", "--page-limit=3", "--page-delay=0")
if err != nil {
t.Fatal(err)
}
@@ -265,6 +289,105 @@ func TestListCommandUsesHostPagination(t *testing.T) {
recorder.AssertScriptConsumed()
}
func TestListCommandReadsOnePageByDefault(t *testing.T) {
recorder := commandtest.New(t, commandtest.Respond(map[string]any{
"items": []map[string]any{{"chat_id": "chat_1", "name": "one"}}, "has_more": true, "page_token": "next",
}))
execution, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, chatListDefinition(), &chatListArgs{PageSize: 20})
if err != nil {
t.Fatal(err)
}
if execution.Data.Complete() || execution.Data.Pages() != 1 || execution.Data.NextToken() != "next" {
t.Fatalf("default page complete=%v pages=%d next=%q", execution.Data.Complete(), execution.Data.Pages(), execution.Data.NextToken())
}
if len(recorder.Requests()) != 1 {
t.Fatalf("default requests = %#v", recorder.Requests())
}
recorder.AssertScriptConsumed()
}
func TestListCommandResumesAndStopsAtPageLimit(t *testing.T) {
recorder := commandtest.New(t,
commandtest.Respond(map[string]any{"items": []map[string]any{{"chat_id": "chat_1"}}, "has_more": true, "page_token": "next-1"}),
commandtest.Respond(map[string]any{"items": []map[string]any{{"chat_id": "chat_2"}}, "has_more": true, "page_token": "next-2"}),
)
recorder.SetPagination(command.PaginationOptions{All: true, MaxPages: 2})
page, err := command.CollectPages[chatData](context.Background(), recorder.CommandContext(command.IdentityUser),
command.GET("/open-apis/im/v1/chats").Set("page_token", "resume"))
if err != nil {
t.Fatal(err)
}
if page.Complete() || page.Pages() != 2 || page.NextToken() != "next-2" || len(page.Items) != 2 {
t.Fatalf("limited page complete=%v pages=%d next=%q items=%d", page.Complete(), page.Pages(), page.NextToken(), len(page.Items))
}
requests := recorder.Requests()
if len(requests) != 2 || requests[0].Query["page_token"] != "resume" || requests[1].Query["page_token"] != "next-1" {
t.Fatalf("resume requests = %#v", requests)
}
recorder.AssertScriptConsumed()
}
func TestCollectAllPagesRejectsInvalidCursors(t *testing.T) {
for _, test := range []struct {
name string
responses []commandtest.Response
}{
{name: "missing", responses: []commandtest.Response{
commandtest.Respond(map[string]any{"items": []map[string]any{}, "has_more": true}),
}},
{name: "repeated", responses: []commandtest.Response{
commandtest.Respond(map[string]any{"items": []map[string]any{}, "has_more": true, "page_token": "same"}),
commandtest.Respond(map[string]any{"items": []map[string]any{}, "has_more": true, "page_token": "same"}),
}},
} {
t.Run(test.name, func(t *testing.T) {
recorder := commandtest.New(t, test.responses...)
_, err := command.CollectAllPages[chatData](context.Background(), recorder.CommandContext(command.IdentityUser), command.GET("/open-apis/im/v1/chats"))
if err == nil {
t.Fatal("CollectAllPages() error is nil")
}
recorder.AssertScriptConsumed()
})
}
}
func TestCollectAllPagesHardLimitPreventsFollowingWrite(t *testing.T) {
responses := make([]commandtest.Response, 1000)
for index := range responses {
responses[index] = commandtest.Respond(map[string]any{
"items": []map[string]any{}, "has_more": true, "page_token": fmt.Sprintf("page-%d", index+1),
})
}
type args struct{}
type data struct{}
definition := command.Definition[args, data]{
Metadata: command.CommandMetadata{
Service: "task", Command: "+business-hard-limit", Description: "Test complete read", Risk: command.RiskWrite,
Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}},
},
Hooks: command.Hooks[args, data]{
Execute: func(ctx context.Context, commandContext command.CommandContext, _ *args) (command.Result[data], error) {
if _, err := command.CollectAllPages[taskRecord](ctx, commandContext, command.GET("/open-apis/task/v2/tasks")); err != nil {
return command.Result[data]{}, err
}
if _, err := command.CallJSON[map[string]any](ctx, commandContext, command.POST("/open-apis/task/v2/tasks")); err != nil {
return command.Result[data]{}, err
}
return command.Success(data{}), nil
},
},
}
recorder := commandtest.New(t, responses...)
_, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, definition, &args{})
if err == nil || !strings.Contains(err.Error(), "hard limit") {
t.Fatalf("hard-limit error = %v", err)
}
if requests := recorder.Requests(); len(requests) != 1000 || requests[len(requests)-1].Method != "GET" {
t.Fatalf("requests after incomplete read = %d, last=%#v", len(requests), requests[len(requests)-1])
}
recorder.AssertScriptConsumed()
}
func TestMultiCallCommandReturnsPartialData(t *testing.T) {
wantFailure := command.InvalidResponseErrorf("owner record is unavailable")
recorder := commandtest.New(t,
+153 -14
View File
@@ -10,17 +10,23 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"reflect"
"sync"
"testing"
"time"
"github.com/larksuite/cli/extension/command"
internalpagination "github.com/larksuite/cli/internal/pagination"
"github.com/spf13/pflag"
)
// Response is one scripted OpenAPI response.
type Response struct {
data any
err error
data any
err error
expectedMethod string
expectedPath string
}
// Respond creates a successful scripted response containing an OpenAPI data object.
@@ -53,6 +59,15 @@ func New(testing testing.TB, responses ...Response) *Recorder {
}
}
// ReplyJSON appends an ordered successful response with an expected request method and path.
func (r *Recorder) ReplyJSON(method, path string, data any) *Recorder {
r.testing.Helper()
r.mu.Lock()
r.responses = append(r.responses, Response{data: data, expectedMethod: method, expectedPath: path})
r.mu.Unlock()
return r
}
// CommandContext returns a restricted public command context.
func (r *Recorder) CommandContext(identity command.Identity) command.CommandContext {
return r.commandContext(identity, false)
@@ -65,11 +80,11 @@ func (r *Recorder) DryRunContext(identity command.Identity) command.CommandConte
func (r *Recorder) commandContext(identity command.Identity, dryRun bool) command.CommandContext {
return command.NewCommandContext(command.ContextOptions{
Identity: identity,
DryRun: dryRun,
CallJSON: r.callJSON,
PreflightScopes: r.preflightScopes,
PaginationOptions: r.paginationOptions,
Identity: identity,
DryRun: dryRun,
CallJSON: r.callJSON,
PreflightScopes: r.preflightScopes,
CollectPages: r.collectPages,
})
}
@@ -99,6 +114,9 @@ func Execute[Args any, Data any](ctx context.Context, recorder *Recorder, identi
}
result, err := declaration.Hooks.Execute(ctx, commandContext, args)
if err != nil {
if result.Outcome != "" || result.Pagination != nil {
return execution, command.InternalErrorf("business Execute returned both Result and error").WithCause(err)
}
return execution, err
}
data, ok := result.Data.(Data)
@@ -108,6 +126,39 @@ func Execute[Args any, Data any](ctx context.Context, recorder *Recorder, identi
return Execution[Data]{Data: data, Partial: result.Outcome == "partial"}, nil
}
// RunWithFlags executes a page-returning command with the framework's standard pagination flags.
func RunWithFlags[Args any, Data any](ctx context.Context, recorder *Recorder, identity command.Identity, definition command.Definition[Args, Data], args *Args, flags ...string) (Execution[Data], error) {
if !command.InspectCommand(command.Define(definition)).PageOutput {
return Execution[Data]{}, command.ValidationErrorf("framework pagination flags require a Page output")
}
options, err := parsePaginationFlags(flags)
if err != nil {
return Execution[Data]{}, err
}
restore := recorder.replacePagination(options)
defer restore()
return Execute(ctx, recorder, identity, definition, args)
}
func parsePaginationFlags(arguments []string) (command.PaginationOptions, error) {
flags := pflag.NewFlagSet("commandtest pagination", pflag.ContinueOnError)
flags.SetOutput(io.Discard)
pageAll := flags.Bool("page-all", false, "")
pageLimit := flags.Int("page-limit", 10, "")
pageDelay := flags.Int("page-delay", 200, "")
if err := flags.Parse(arguments); err != nil {
return command.PaginationOptions{}, command.ValidationErrorf("parse framework pagination flags: %v", err).WithCause(err)
}
if flags.NArg() != 0 {
return command.PaginationOptions{}, command.ValidationErrorf("unexpected framework pagination argument %q", flags.Arg(0))
}
return command.PaginationOptions{
All: *pageAll,
MaxPages: *pageLimit,
Delay: time.Duration(*pageDelay) * time.Millisecond,
}, nil
}
// Preview runs Normalize, Validate, and DryRun with an offline test context.
func Preview[Args any, Data any](ctx context.Context, recorder *Recorder, identity command.Identity, definition command.Definition[Args, Data], args *Args) (*command.DryRun, error) {
declaration := command.InspectCommand(command.Define(definition))
@@ -122,9 +173,12 @@ func Preview[Args any, Data any](ctx context.Context, recorder *Recorder, identi
return nil, err
}
}
if declaration.Hooks.DryRun == nil {
if declaration.Hooks.DryRun == nil && declaration.Hooks.DryRunE == nil {
return nil, errors.New("business command has no DryRun hook")
}
if declaration.Hooks.DryRunE != nil {
return declaration.Hooks.DryRunE(ctx, commandContext, args)
}
return declaration.Hooks.DryRun(ctx, commandContext, args), nil
}
@@ -159,6 +213,18 @@ func (r *Recorder) SetPagination(options command.PaginationOptions) {
r.mu.Unlock()
}
func (r *Recorder) replacePagination(options command.PaginationOptions) func() {
r.mu.Lock()
previous := r.pagination
r.pagination = options
r.mu.Unlock()
return func() {
r.mu.Lock()
r.pagination = previous
r.mu.Unlock()
}
}
// SetScopeError makes every subsequent scope preflight return err after recording it.
func (r *Recorder) SetScopeError(err error) {
r.mu.Lock()
@@ -238,6 +304,12 @@ func (r *Recorder) callJSON(ctx context.Context, request command.Request) (map[s
cancel := r.cancel
shouldCancel := r.cancelAfterRequest == requestNumber
r.mu.Unlock()
if response.expectedMethod != "" && response.expectedMethod != view.Method {
return nil, fmt.Errorf("request %d method = %q, expected %q", requestNumber, view.Method, response.expectedMethod)
}
if response.expectedPath != "" && response.expectedPath != view.Path {
return nil, fmt.Errorf("request %d path = %q, expected %q", requestNumber, view.Path, response.expectedPath)
}
if response.err != nil {
return nil, response.err
@@ -252,6 +324,79 @@ func (r *Recorder) callJSON(ctx context.Context, request command.Request) (map[s
return data, nil
}
func (r *Recorder) collectPages(ctx context.Context, request command.Request, all bool) ([]map[string]any, command.HostPagination, error) {
r.mu.Lock()
options := r.pagination
r.mu.Unlock()
if all {
options = command.PaginationOptions{All: true, MaxPages: 1000}
} else if !options.All {
options.MaxPages = 1
}
if options.MaxPages < 1 || options.MaxPages > 1000 {
return nil, command.HostPagination{}, command.ValidationErrorf("pagination page limit must be between 1 and 1000")
}
if options.Delay < 0 || options.Delay > time.Minute {
return nil, command.HostPagination{}, command.ValidationErrorf("pagination delay must be between 0 and 60000 milliseconds")
}
var pages []map[string]any
state, err := internalpagination.Walk(ctx, internalpagination.Options{
InitialToken: requestPageToken(command.InspectRequest(request).Query),
MaxPages: options.MaxPages,
Delay: options.Delay,
Fetch: func(ctx context.Context, _ int, token string) (bool, string, error) {
pageRequest := request
if token != "" {
pageRequest = pageRequest.Set("page_token", token)
}
data, err := r.callJSON(ctx, pageRequest)
if err != nil {
return false, "", err
}
pages = append(pages, data)
hasMore, _ := data["has_more"].(bool)
nextToken, _ := data["page_token"].(string)
if nextToken == "" {
nextToken, _ = data["next_page_token"].(string)
}
return hasMore, nextToken, nil
},
})
pagination := command.HostPagination{Complete: state.Complete, Pages: state.Pages, NextToken: state.NextToken}
if err == nil {
return pages, pagination, nil
}
var cursorErr *internalpagination.CursorError
if errors.As(err, &cursorErr) {
if cursorErr.Kind == internalpagination.CursorMissing {
return pages, pagination, command.InvalidResponseErrorf("pagination page %d reports has_more=true without a page token", cursorErr.Page)
}
return pages, pagination, command.InvalidResponseErrorf("pagination page %d repeated page token %q", cursorErr.Page, cursorErr.Token)
}
var waitErr *internalpagination.WaitError
if errors.As(err, &waitErr) {
return pages, pagination, command.PaginationInterruptedError(waitErr.Err)
}
return pages, pagination, err
}
func requestPageToken(query map[string]any) string {
switch value := query["page_token"].(type) {
case string:
return value
case []string:
if len(value) > 0 {
return value[0]
}
case []any:
if len(value) > 0 {
return fmt.Sprint(value[0])
}
}
return ""
}
func (r *Recorder) preflightScopes(scopes ...string) error {
r.mu.Lock()
defer r.mu.Unlock()
@@ -259,12 +404,6 @@ func (r *Recorder) preflightScopes(scopes ...string) error {
return r.scopeError
}
func (r *Recorder) paginationOptions() (command.PaginationOptions, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.pagination, nil
}
func responseDataObject(value any) (map[string]any, error) {
if value == nil {
return map[string]any{}, nil
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
@@ -60,13 +61,39 @@ func TestRecorderReturnsScriptedFailuresInOrder(t *testing.T) {
recorder.AssertScriptConsumed()
}
func TestRecorderReplyJSONChecksRequestInOrder(t *testing.T) {
recorder := New(t).
ReplyJSON("GET", "/open-apis/im/v1/chats/first", map[string]any{"id": "first"}).
ReplyJSON("GET", "/open-apis/im/v1/chats/second", map[string]any{"id": "second"})
commandContext := recorder.CommandContext(command.IdentityUser)
for _, id := range []string{"first", "second"} {
data, err := command.CallJSON[map[string]any](context.Background(), commandContext, command.GET("/open-apis/im/v1/chats/"+id))
if err != nil {
t.Fatal(err)
}
if data["id"] != id {
t.Fatalf("response = %#v", data)
}
}
recorder.AssertScriptConsumed()
}
func TestRecorderReplyJSONRejectsUnexpectedRequest(t *testing.T) {
recorder := New(t).ReplyJSON("POST", "/open-apis/im/v1/chats", map[string]any{})
_, err := command.CallJSON[map[string]any](context.Background(), recorder.CommandContext(command.IdentityUser), command.GET("/open-apis/im/v1/chats"))
if err == nil || !strings.Contains(err.Error(), "expected") {
t.Fatalf("CallJSON() error = %v", err)
}
recorder.AssertScriptConsumed()
}
func TestRecorderInjectsCancellationIntoPaginationWait(t *testing.T) {
recorder := New(t, Respond(map[string]any{
"items": []map[string]any{{"id": "first"}},
"has_more": true,
"page_token": "next",
}))
recorder.SetPagination(command.PaginationOptions{All: true, MaxPages: 2, Delay: time.Hour})
recorder.SetPagination(command.PaginationOptions{All: true, MaxPages: 2, Delay: time.Minute})
recorder.CancelAfterRequest(1)
ctx := recorder.ExecutionContext(context.Background())
@@ -110,3 +137,68 @@ func TestExecuteRunsPreparationAndReturnsTypedOutcome(t *testing.T) {
t.Fatalf("execution = %#v", execution)
}
}
func TestExecuteRejectsResultAndErrorTogether(t *testing.T) {
type args struct{}
type data struct{}
sentinel := errors.New("execute failed")
definition := command.Definition[args, data]{
Metadata: command.CommandMetadata{
Service: "im", Command: "+test-result-error", Description: "Test result protocol", Risk: command.RiskRead,
Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}},
},
Hooks: command.Hooks[args, data]{
Execute: func(context.Context, command.CommandContext, *args) (command.Result[data], error) {
return command.Success(data{}), sentinel
},
},
}
_, err := Execute(context.Background(), New(t), command.IdentityUser, definition, &args{})
if err == nil || !errors.Is(err, sentinel) || err == sentinel {
t.Fatalf("Execute() error = %v", err)
}
}
func TestPreviewPropagatesDryRunE(t *testing.T) {
type args struct{}
type data struct{}
sentinel := command.ValidationErrorf("dry-run input is invalid")
definition := command.Definition[args, data]{
Metadata: command.CommandMetadata{
Service: "im", Command: "+test-dry-run-error", Description: "Test dry-run error", Risk: command.RiskRead,
Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}},
},
Hooks: command.Hooks[args, data]{
DryRunE: func(context.Context, command.CommandContext, *args) (*command.DryRun, error) {
return nil, sentinel
},
Execute: func(context.Context, command.CommandContext, *args) (command.Result[data], error) {
return command.Success(data{}), nil
},
},
}
_, err := Preview(context.Background(), New(t), command.IdentityUser, definition, &args{})
if !errors.Is(err, sentinel) {
t.Fatalf("Preview() error = %v", err)
}
}
func TestRunWithFlagsRejectsNonPageOutput(t *testing.T) {
type args struct{}
type data struct{}
definition := command.Definition[args, data]{
Metadata: command.CommandMetadata{
Service: "im", Command: "+test-non-page", Description: "Test non-page flags", Risk: command.RiskRead,
Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}},
},
Hooks: command.Hooks[args, data]{
Execute: func(context.Context, command.CommandContext, *args) (command.Result[data], error) {
return command.Success(data{}), nil
},
},
}
_, err := RunWithFlags(context.Background(), New(t), command.IdentityUser, definition, &args{}, "--page-all")
if err == nil {
t.Fatal("RunWithFlags() error is nil")
}
}
+15 -36
View File
@@ -12,11 +12,11 @@ import (
// CommandContext is an opaque, invocation-scoped set of safe host capabilities.
type CommandContext struct {
identity Identity
dryRun bool
callJSON func(context.Context, Request) (map[string]any, error)
preflightScopes func(...string) error
paginationOptions func() (PaginationOptions, error)
identity Identity
dryRun bool
callJSON func(context.Context, Request) (map[string]any, error)
preflightScopes func(...string) error
collectPages func(context.Context, Request, bool) ([]map[string]any, HostPagination, error)
}
// PaginationOptions carries host-owned pagination controls to the public helpers.
@@ -30,21 +30,21 @@ type PaginationOptions struct {
// ContextOptions supplies safe callbacks when a host creates a CommandContext.
// It is intended for the lark-cli host adapter and commandtest.
type ContextOptions struct {
Identity Identity
DryRun bool
CallJSON func(context.Context, Request) (map[string]any, error)
PreflightScopes func(...string) error
PaginationOptions func() (PaginationOptions, error)
Identity Identity
DryRun bool
CallJSON func(context.Context, Request) (map[string]any, error)
PreflightScopes func(...string) error
CollectPages func(context.Context, Request, bool) ([]map[string]any, HostPagination, error)
}
// NewCommandContext creates a restricted context from host callbacks.
func NewCommandContext(options ContextOptions) CommandContext {
return CommandContext{
identity: options.Identity,
dryRun: options.DryRun,
callJSON: options.CallJSON,
preflightScopes: options.PreflightScopes,
paginationOptions: options.PaginationOptions,
identity: options.Identity,
dryRun: options.DryRun,
callJSON: options.CallJSON,
preflightScopes: options.PreflightScopes,
collectPages: options.CollectPages,
}
}
@@ -89,24 +89,3 @@ func PreflightScopes(command CommandContext, scopes ...string) error {
}
return command.preflightScopes(scopes...)
}
func (c CommandContext) pageOptions() (PaginationOptions, error) {
if c.paginationOptions == nil {
return PaginationOptions{MaxPages: 1}, nil
}
return c.paginationOptions()
}
func waitForPage(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return nil
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return PaginationInterruptedError(ctx.Err())
case <-timer.C:
return nil
}
}
+1
View File
@@ -215,6 +215,7 @@ type Hooks[Args any, Data any] struct {
Normalize func(context.Context, CommandContext, *Args) error
Validate func(context.Context, CommandContext, *Args) error
DryRun func(context.Context, CommandContext, *Args) *DryRun
DryRunE func(context.Context, CommandContext, *Args) (*DryRun, error)
Execute func(context.Context, CommandContext, *Args) (Result[Data], error)
Renderers map[string]Renderer[Data]
}
+6
View File
@@ -27,6 +27,7 @@ type HostHooks struct {
Normalize func(context.Context, CommandContext, any) error
Validate func(context.Context, CommandContext, any) error
DryRun func(context.Context, CommandContext, any) *DryRun
DryRunE func(context.Context, CommandContext, any) (*DryRun, error)
Execute func(context.Context, CommandContext, any) (HostResult, error)
Renderers map[string]func(io.Writer, any) error
}
@@ -88,6 +89,11 @@ func newCommand[Args any, Data any](definition Definition[Args, Data]) Command {
return definition.Hooks.DryRun(ctx, command, args.(*Args))
}
}
if definition.Hooks.DryRunE != nil {
host.hooks.DryRunE = func(ctx context.Context, command CommandContext, args any) (*DryRun, error) {
return definition.Hooks.DryRunE(ctx, command, args.(*Args))
}
}
if definition.Hooks.Execute != nil {
host.hooks.Execute = func(ctx context.Context, command CommandContext, args any) (HostResult, error) {
result, err := definition.Hooks.Execute(ctx, command, args.(*Args))
+34 -81
View File
@@ -4,12 +4,11 @@
package command
import (
"bytes"
"context"
"fmt"
"encoding/json"
)
const collectAllPagesLimit = 1000
// Page contains items and host-owned pagination state.
type Page[T any] struct {
Items []T `json:"items" schema:"required;nonnullable" doc:"items returned by the API"`
@@ -36,7 +35,13 @@ func (p Page[T]) Pages() int {
return p.meta.Pages
}
func (p Page[T]) commandPagination() *paginationMeta { return p.meta }
func (p Page[T]) commandPagination() *paginationMeta {
meta := clonePaginationMeta(p.meta)
if meta != nil {
meta.Items = len(p.Items)
}
return meta
}
type paginationMeta struct {
Complete bool
@@ -62,19 +67,12 @@ type pageEnvelope[T any] struct {
// CollectPages fetches one page by default or follows standard pagination flags.
func CollectPages[T any](ctx context.Context, command CommandContext, request Request) (Page[T], error) {
options, err := command.pageOptions()
if err != nil {
return Page[T]{}, err
}
if !options.All {
options.MaxPages = 1
}
return collectPages[T](ctx, command, request, options)
return collectPages[T](ctx, command, request, false)
}
// CollectAllPages fetches until the endpoint is exhausted and ignores CLI paging flags.
func CollectAllPages[T any](ctx context.Context, command CommandContext, request Request) ([]T, error) {
page, err := collectPages[T](ctx, command, request, PaginationOptions{All: true, MaxPages: collectAllPagesLimit})
page, err := collectPages[T](ctx, command, request, true)
if err != nil {
return nil, err
}
@@ -84,81 +82,36 @@ func CollectAllPages[T any](ctx context.Context, command CommandContext, request
return page.Items, nil
}
func collectPages[T any](ctx context.Context, command CommandContext, request Request, options PaginationOptions) (Page[T], error) {
if options.MaxPages < 1 || options.MaxPages > collectAllPagesLimit {
return Page[T]{}, ValidationErrorf("pagination page limit must be between 1 and %d", collectAllPagesLimit)
}
if options.Delay < 0 {
return Page[T]{}, ValidationErrorf("pagination delay must not be negative")
}
func collectPages[T any](ctx context.Context, command CommandContext, request Request, all bool) (Page[T], error) {
result := Page[T]{meta: &paginationMeta{}}
requestView := InspectRequest(request)
token := queryPageToken(requestView.Query)
seen := make(map[string]struct{}, options.MaxPages)
if token != "" {
seen[token] = struct{}{}
if command.collectPages == nil {
return result, InternalErrorf("command host does not provide pagination")
}
for pageNumber := 1; pageNumber <= options.MaxPages; pageNumber++ {
pageRequest := request
if token != "" {
pageRequest = pageRequest.Set("page_token", token)
}
page, err := CallJSON[pageEnvelope[T]](ctx, command, pageRequest)
if err != nil {
result.meta.NextToken = token
return result, err
pages, pagination, err := command.collectPages(ctx, request, all)
result.meta.Complete = pagination.Complete
result.meta.Pages = pagination.Pages
result.meta.NextToken = pagination.NextToken
for pageNumber, data := range pages {
page, decodeErr := decodePageEnvelope[T](data)
if decodeErr != nil {
return result, InvalidResponseErrorf("decode pagination page %d: %v", pageNumber+1, decodeErr).WithCause(decodeErr)
}
result.Items = append(result.Items, page.Items...)
result.meta.Pages++
result.meta.Items = len(result.Items)
nextToken := page.PageToken
if nextToken == "" {
nextToken = page.NextPageToken
}
if !page.HasMore {
result.meta.Complete = true
result.meta.NextToken = ""
return result, nil
}
if nextToken == "" {
return result, InvalidResponseErrorf("pagination page %d reports has_more=true without a page token", pageNumber)
}
if _, duplicate := seen[nextToken]; duplicate {
return result, InvalidResponseErrorf("pagination page %d repeated page token %q", pageNumber, nextToken)
}
result.meta.NextToken = nextToken
if pageNumber == options.MaxPages {
return result, nil
}
seen[nextToken] = struct{}{}
token = nextToken
if err := waitForPage(ctx, options.Delay); err != nil {
return result, err
}
}
return result, InternalErrorf("pagination finished without a terminal state")
result.meta.Items = len(result.Items)
return result, err
}
func queryPageToken(query map[string]any) string {
value, ok := query["page_token"]
if !ok {
return ""
func decodePageEnvelope[T any](data map[string]any) (pageEnvelope[T], error) {
var page pageEnvelope[T]
encoded, err := json.Marshal(data)
if err != nil {
return page, err
}
switch typed := value.(type) {
case string:
return typed
case []string:
if len(typed) > 0 {
return typed[0]
}
case []any:
if len(typed) > 0 {
return fmt.Sprint(typed[0])
}
decoder := json.NewDecoder(bytes.NewReader(encoded))
decoder.UseNumber()
if err := decoder.Decode(&page); err != nil {
return page, err
}
return ""
return page, nil
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"context"
"os"
defaultaffordance "github.com/larksuite/cli/affordance"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/extension/command"
defaultskills "github.com/larksuite/cli/skills"
_ "github.com/larksuite/cli/extension/credential/env"
)
type readArgs struct {
ID string `flag:"id" schema:"required;minLength=1" doc:"resource identifier"`
}
type readData struct {
ID string `json:"id" schema:"required" doc:"resource identifier"`
}
var readCommand = command.Define(command.Definition[readArgs, readData]{
Metadata: command.CommandMetadata{
Service: "im", Command: "+wrapper-read", Description: "Read one wrapper resource", Risk: command.RiskRead,
Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{
command.IdentityUser: {RequiredScopes: []string{"im:chat:read"}},
}},
},
Hooks: command.Hooks[readArgs, readData]{
DryRunE: func(_ context.Context, _ command.CommandContext, args *readArgs) (*command.DryRun, error) {
return command.Preview(command.GET("/open-apis/im/v1/chats/" + args.ID)), nil
},
Execute: func(ctx context.Context, commandContext command.CommandContext, args *readArgs) (command.Result[readData], error) {
data, err := command.CallJSON[readData](ctx, commandContext, command.GET("/open-apis/im/v1/chats/"+args.ID))
if err != nil {
return command.Result[readData]{}, err
}
return command.Success(data), nil
},
},
})
func main() {
cmd.SetEmbeddedSkillContent(defaultskills.DefaultFS())
cmd.SetEmbeddedAffordanceContent(defaultaffordance.DefaultFS())
os.Exit(cmd.ExecuteWithOptions(
cmd.WithCommandSets(command.Set{
Domain: command.ExtendDomain(command.DomainIm),
Commands: []command.Command{readCommand},
}),
cmd.WithoutPlugins(),
cmd.WithoutStrictMode(),
cmd.WithoutServiceCommands(),
))
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package command_test
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestExternalWrapperCommandSurface(t *testing.T) {
packageDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
binary := filepath.Join(t.TempDir(), "business-cli")
build := exec.Command("go", "build", "-o", binary, "./testdata/wrapper")
build.Dir = packageDir
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build wrapper: %v\n%s", err, output)
}
configDir := t.TempDir()
baseEnv := withoutEnvironment(os.Environ(),
"LARKSUITE_CLI_CONFIG_DIR", "LARKSUITE_CLI_APP_ID", "LARKSUITE_CLI_APP_SECRET",
"LARKSUITE_CLI_USER_ACCESS_TOKEN", "LARKSUITE_CLI_PROFILE",
)
run := func(args ...string) string {
t.Helper()
process := exec.Command(binary, args...)
process.Env = append(baseEnv,
"LARKSUITE_CLI_CONFIG_DIR="+configDir,
"LARKSUITE_CLI_APP_ID=wrapper_test_app",
"LARKSUITE_CLI_APP_SECRET=wrapper_test_secret",
"LARKSUITE_CLI_USER_ACCESS_TOKEN=expired_wrapper_token",
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
)
output, err := process.CombinedOutput()
if err != nil {
t.Fatalf("wrapper %v: %v\n%s", args, err, output)
}
return string(output)
}
dryRun := run("im", "+wrapper-read", "--id", "chat_1", "--as", "user", "--dry-run")
if !strings.Contains(dryRun, `"dry_run": true`) || !strings.Contains(dryRun, "/open-apis/im/v1/chats/chat_1") {
t.Fatalf("wrapper dry-run = %s", dryRun)
}
schema := run("schema", "im", "+wrapper-read")
if !strings.Contains(schema, `"name": "im +wrapper-read"`) || !strings.Contains(schema, `"outputSchema"`) {
t.Fatalf("wrapper schema = %s", schema)
}
completion := run("__complete", "im", "+wrap")
if !strings.Contains(completion, "+wrapper-read") {
t.Fatalf("wrapper completion = %s", completion)
}
skills := run("skills", "list")
if !strings.Contains(skills, "lark-doc") {
t.Fatalf("wrapper skills = %s", skills)
}
}
func withoutEnvironment(environment []string, names ...string) []string {
blocked := make(map[string]struct{}, len(names))
for _, name := range names {
blocked[name] = struct{}{}
}
result := make([]string, 0, len(environment))
for _, entry := range environment {
name, _, _ := strings.Cut(entry, "=")
if _, ok := blocked[name]; !ok {
result = append(result, entry)
}
}
return result
}
+15 -25
View File
@@ -57,47 +57,37 @@ You should see `audit` in the plugin list.
That is sufficient for a hook-only plugin such as the audit observer. A
wrapper main does not compile lark-cli's repository-root `content_embed.go`,
so distribution content is a separate, explicit host choice.
so distribution content remains an explicit host choice. The repository
defaults are importable from `github.com/larksuite/cli/skills` and
`github.com/larksuite/cli/affordance`.
### Ship skills and command guidance
If the distribution exposes embedded skills or customizes them with
`EmbeddedSkills`, copy or generate both content trees under the wrapper
package and wire both:
If the distribution exposes the repository's embedded skills or customizes
them with `EmbeddedSkills`, wire the default content before execution:
```go
package main
import (
"embed"
"io/fs"
"os"
"os"
_ "github.com/me/myplugin"
_ "github.com/me/myplugin"
"github.com/larksuite/cli/cmd"
defaultaffordance "github.com/larksuite/cli/affordance"
"github.com/larksuite/cli/cmd"
defaultskills "github.com/larksuite/cli/skills"
)
//go:embed skills affordance
var distributionContent embed.FS
func main() {
skillTree, err := fs.Sub(distributionContent, "skills")
if err != nil {
panic(err)
}
affordanceTree, err := fs.Sub(distributionContent, "affordance")
if err != nil {
panic(err)
}
cmd.SetEmbeddedSkillContent(skillTree)
cmd.SetEmbeddedAffordanceContent(affordanceTree)
os.Exit(cmd.Execute())
cmd.SetEmbeddedSkillContent(defaultskills.DefaultFS())
cmd.SetEmbeddedAffordanceContent(defaultaffordance.DefaultFS())
os.Exit(cmd.Execute())
}
```
`go:embed` only reads files in the package being compiled; it cannot reach
into the replaced `github.com/larksuite/cli` module. Each
Custom distributions may instead copy or generate both content trees under
the wrapper package and wire their own `fs.FS` values. Each
`skills/<name>/` must contain `SKILL.md`. The `affordance/*.md` files are the
structured source for command help and canonical skill references; ship the
ones for the domains your distribution retains. Without
+4 -3
View File
@@ -15,7 +15,8 @@ import "io/fs"
// Allow -> Remove -> Overlay, a same-named skill resolving to Overlay.
// The repository's root binary provides its base from content_embed.go;
// an external wrapper main has no implicit CLI default and must call
// cmd.SetEmbeddedSkillContent before Execute if it relies on that base.
// cmd.SetEmbeddedSkillContent before Execute if it relies on that base. The
// repository default is available from skills.DefaultFS.
//
// Skills are addressed by exact name (a directory carrying SKILL.md,
// e.g. "lark-doc"), not by command path and not by glob — the skill
@@ -61,8 +62,8 @@ type SkillsOverlay struct {
// Base replaces the host-provided base skill tree instead of layering
// over it. nil keeps whatever base the host wired with
// cmd.SetEmbeddedSkillContent; it does not import the repository
// binary's default into an external wrapper main. Every top-level
// cmd.SetEmbeddedSkillContent; it does not select the repository
// binary's default for an external wrapper main. Every top-level
// entry must be a valid skill directory containing SKILL.md. Most
// integrators leave Base nil and use Remove/Overlay so unchanged
// host-provided skills need no copy inside the plugin.
+31 -4
View File
@@ -90,6 +90,9 @@ func validateDomain(domain command.HostDomain, existing map[string]struct{}) err
}
func compileCommand(definition command.HostDefinition) (common.Shortcut, error) {
if definition.Hooks.DryRun != nil && definition.Hooks.DryRunE != nil {
return common.Shortcut{}, fmt.Errorf("Hooks.DryRun and Hooks.DryRunE cannot both be set")
}
metadata := convertMetadata(definition.Metadata)
input, err := convertInput(definition.Input)
if err != nil {
@@ -214,15 +217,29 @@ func convertOutput(output command.OutputDefinition) (common.OutputDefinition, er
}
func convertHooks(hooks command.HostHooks) common.ErasedHooks {
dryRun := adaptDryRunHook(hooks.DryRun)
if hooks.DryRunE != nil {
dryRun = adaptDryRunErrorHook(hooks.DryRunE)
}
return common.ErasedHooks{
Normalize: adaptHook(hooks.Normalize),
Validate: adaptHook(hooks.Validate),
DryRun: adaptDryRunHook(hooks.DryRun),
DryRun: dryRun,
Execute: adaptExecuteHook(hooks.Execute),
Renderers: cloneRenderers(hooks.Renderers),
}
}
func adaptDryRunErrorHook(hook func(context.Context, command.CommandContext, any) (*command.DryRun, error)) func(context.Context, common.CommandContext, any) (*common.DryRunAPI, error) {
return func(ctx context.Context, host common.CommandContext, args any) (*common.DryRunAPI, error) {
preview, err := hook(ctx, publicContext(host), args)
if err != nil {
return nil, err
}
return convertDryRun(preview)
}
}
func adaptHook(hook func(context.Context, command.CommandContext, any) error) func(context.Context, common.CommandContext, any) error {
if hook == nil {
return nil
@@ -279,9 +296,19 @@ func publicContext(host common.CommandContext) command.CommandContext {
return common.DoTypedAPIJSON(ctx, host, view.Method, view.Path, queryParams(view.Query), view.Body)
},
PreflightScopes: host.RequireConditionalScopes,
PaginationOptions: func() (command.PaginationOptions, error) {
options, err := host.PaginationOptions()
return command.PaginationOptions{All: options.All, MaxPages: options.MaxPages, Delay: options.Delay}, err
CollectPages: func(ctx context.Context, request command.Request, all bool) ([]map[string]any, command.HostPagination, error) {
view := command.InspectRequest(request)
if err := command.ValidateRequestView(view); err != nil {
return nil, command.HostPagination{}, err
}
collection, err := common.CollectCommandPages(ctx, host, common.PageRequest{
Method: view.Method, Path: view.Path, Params: view.Query, Body: view.Body,
}, all)
pagination := command.HostPagination{
Complete: collection.Complete, Pages: collection.Pages,
NextToken: collection.NextToken,
}
return collection.Data, pagination, err
},
})
}
+57
View File
@@ -5,6 +5,7 @@ package commandhost
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
@@ -106,6 +107,30 @@ func TestCompileSetsRejectsSystemFlag(t *testing.T) {
}
}
func TestCompileSetsRejectsBothDryRunHooks(t *testing.T) {
definition := command.Define(command.Definition[fixtureArgs, fixtureData]{
Metadata: command.CommandMetadata{
Service: "im", Command: "+external-dry-run-conflict", Description: "Dry-run conflict", Risk: command.RiskRead,
Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}},
},
Hooks: command.Hooks[fixtureArgs, fixtureData]{
DryRun: func(context.Context, command.CommandContext, *fixtureArgs) *command.DryRun {
return command.NewDryRun()
},
DryRunE: func(context.Context, command.CommandContext, *fixtureArgs) (*command.DryRun, error) {
return command.NewDryRun(), nil
},
Execute: func(context.Context, command.CommandContext, *fixtureArgs) (command.Result[fixtureData], error) {
return command.Success(fixtureData{}), nil
},
},
})
_, err := CompileSets([]command.Set{{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{definition}}})
if err == nil || !strings.Contains(err.Error(), "cannot both be set") {
t.Fatalf("CompileSets() error = %v", err)
}
}
func TestCompileSetsAddsPaginationFlags(t *testing.T) {
declaration := command.Define(command.Definition[fixtureArgs, command.Page[fixtureData]]{
Metadata: command.CommandMetadata{
@@ -204,3 +229,35 @@ func TestExternalDryRunUsesOfflineContext(t *testing.T) {
t.Fatalf("dry-run output = %s", stdout.String())
}
}
func TestExternalDryRunEPropagatesError(t *testing.T) {
sentinel := command.ValidationErrorf("dry-run input is invalid")
declaration := command.Define(command.Definition[fixtureArgs, fixtureData]{
Metadata: command.CommandMetadata{
Service: "im", Command: "+external-dry-run-error", Description: "Offline preview error", Risk: command.RiskRead,
Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}},
},
Hooks: command.Hooks[fixtureArgs, fixtureData]{
DryRunE: func(context.Context, command.CommandContext, *fixtureArgs) (*command.DryRun, error) {
return nil, sentinel
},
Execute: func(context.Context, command.CommandContext, *fixtureArgs) (command.Result[fixtureData], error) {
return command.Success(fixtureData{}), nil
},
},
})
compiled, err := CompileSets([]command.Set{{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{declaration}}})
if err != nil {
t.Fatal(err)
}
factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{})
root := &cobra.Command{Use: "lark-cli", SilenceErrors: true, SilenceUsage: true}
service := &cobra.Command{Use: "im"}
root.AddCommand(service)
compiled[0].Mount(service, factory)
root.SetArgs([]string{"im", "+external-dry-run-error", "--id", "chat_1", "--as", "user", "--dry-run"})
_, err = root.ExecuteC()
if !errors.Is(err, sentinel) {
t.Fatalf("dry-run error = %v", err)
}
}
+127
View File
@@ -0,0 +1,127 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package pagination owns the bounded cursor walk shared by built-in and
// externally assembled commands.
package pagination
import (
"context"
"fmt"
"time"
)
// CursorErrorKind identifies an invalid cursor transition returned by an API.
type CursorErrorKind uint8
const (
// CursorMissing means the API reported another page without a cursor.
CursorMissing CursorErrorKind = iota + 1
// CursorRepeated means the API returned a cursor already observed by the walk.
CursorRepeated
)
// CursorError reports an invalid cursor transition.
type CursorError struct {
Kind CursorErrorKind
Page int
Token string
}
func (e *CursorError) Error() string {
switch e.Kind {
case CursorMissing:
return fmt.Sprintf("pagination page %d reports more pages without a page token", e.Page)
case CursorRepeated:
return fmt.Sprintf("pagination page %d repeated page token %q", e.Page, e.Token)
default:
return fmt.Sprintf("pagination page %d returned an invalid cursor", e.Page)
}
}
// WaitError reports cancellation or failure while delaying between pages.
type WaitError struct{ Err error }
func (e *WaitError) Error() string { return e.Err.Error() }
func (e *WaitError) Unwrap() error { return e.Err }
// State describes the completed portion of a cursor walk.
type State struct {
Complete bool
Pages int
NextToken string
}
// Fetch obtains and consumes one page, then returns its cursor state.
type Fetch func(ctx context.Context, pageNumber int, pageToken string) (hasMore bool, nextToken string, err error)
// Options configures one bounded cursor walk.
type Options struct {
InitialToken string
MaxPages int
Delay time.Duration
Fetch Fetch
Wait func(context.Context, time.Duration) error
}
// Walk follows page tokens until exhaustion or MaxPages is reached.
func Walk(ctx context.Context, options Options) (State, error) {
state := State{NextToken: options.InitialToken}
seen := make(map[string]struct{}, options.MaxPages)
if options.InitialToken != "" {
seen[options.InitialToken] = struct{}{}
}
token := options.InitialToken
wait := options.Wait
if wait == nil {
wait = WaitContext
}
for pageNumber := 1; pageNumber <= options.MaxPages; pageNumber++ {
hasMore, nextToken, err := options.Fetch(ctx, pageNumber, token)
if err != nil {
state.NextToken = token
return state, err
}
state.Pages++
if !hasMore {
state.Complete = true
state.NextToken = ""
return state, nil
}
if nextToken == "" {
return state, &CursorError{Kind: CursorMissing, Page: pageNumber}
}
if _, duplicate := seen[nextToken]; duplicate {
return state, &CursorError{Kind: CursorRepeated, Page: pageNumber, Token: nextToken}
}
state.NextToken = nextToken
if pageNumber == options.MaxPages {
return state, nil
}
seen[nextToken] = struct{}{}
token = nextToken
if options.Delay > 0 {
if err := wait(ctx, options.Delay); err != nil {
return state, &WaitError{Err: err}
}
}
}
return state, fmt.Errorf("pagination exhausted its page budget without producing a terminal result")
}
// WaitContext waits for one inter-page delay and observes cancellation.
func WaitContext(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return nil
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
+77
View File
@@ -0,0 +1,77 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package pagination
import (
"context"
"errors"
"reflect"
"testing"
"time"
)
func TestWalkFollowsCursorsToCompletion(t *testing.T) {
var tokens []string
state, err := Walk(context.Background(), Options{
MaxPages: 3,
Fetch: func(_ context.Context, page int, token string) (bool, string, error) {
tokens = append(tokens, token)
if page == 1 {
return true, "next", nil
}
return false, "", nil
},
})
if err != nil {
t.Fatal(err)
}
if !state.Complete || state.Pages != 2 || state.NextToken != "" {
t.Fatalf("state = %#v", state)
}
if !reflect.DeepEqual(tokens, []string{"", "next"}) {
t.Fatalf("tokens = %#v", tokens)
}
}
func TestWalkRejectsInvalidCursorTransitions(t *testing.T) {
for _, test := range []struct {
name string
walk func(context.Context, int, string) (bool, string, error)
kind CursorErrorKind
}{
{name: "missing", kind: CursorMissing, walk: func(context.Context, int, string) (bool, string, error) {
return true, "", nil
}},
{name: "repeated", kind: CursorRepeated, walk: func(context.Context, int, string) (bool, string, error) {
return true, "resume", nil
}},
} {
t.Run(test.name, func(t *testing.T) {
_, err := Walk(context.Background(), Options{InitialToken: "resume", MaxPages: 2, Fetch: test.walk})
var cursorErr *CursorError
if !errors.As(err, &cursorErr) || cursorErr.Kind != test.kind {
t.Fatalf("Walk() error = %T %v", err, err)
}
})
}
}
func TestWalkPreservesResumeTokenWhenWaitIsCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
state, err := Walk(ctx, Options{
MaxPages: 2,
Delay: time.Second,
Fetch: func(context.Context, int, string) (bool, string, error) {
return true, "next", nil
},
})
var waitErr *WaitError
if !errors.As(err, &waitErr) || !errors.Is(err, context.Canceled) {
t.Fatalf("Walk() error = %T %v", err, err)
}
if state.Pages != 1 || state.NextToken != "next" {
t.Fatalf("state = %#v", state)
}
}
+59 -78
View File
@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
internalpagination "github.com/larksuite/cli/internal/pagination"
)
// PageRequest describes one paginated API walk. Pagination controls are not
@@ -58,79 +59,69 @@ func paginateInto[T any](runtime *RuntimeContext, request PageRequest, dst PageA
if err != nil {
return meta, err
}
pageToken := pageTokenParam(request.Params)
seen := make(map[string]struct{})
if pageToken != "" {
seen[pageToken] = struct{}{}
ctx := runtime.Ctx()
if ctx == nil {
ctx = context.Background()
}
// maxPages is always in [1, pageLimitMaximum]. Keeping the bound in the
// loop statement makes finite execution a structural invariant, independent
// of cursor quality and of any future exit-condition changes below.
for pageNumber := 1; pageNumber <= policy.maxPages; pageNumber++ {
params := clonePageParams(request.Params)
if pageToken != "" {
params["page_token"] = pageToken
}
if policy.showProgress {
fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", pageNumber)
}
data, err := runtime.CallAPITyped(request.Method, request.Path, params, request.Body)
if err != nil {
meta.NextToken = pageToken
return meta, err
}
page, err := decodePageData[T](data, pageNumber)
if err != nil {
meta.NextToken = pageToken
return meta, err
}
if err := dst.AddPage(page); err != nil {
meta.NextToken = pageToken
if _, ok := errs.ProblemOf(err); ok {
return meta, err
state, walkErr := internalpagination.Walk(ctx, internalpagination.Options{
InitialToken: pageTokenParam(request.Params),
MaxPages: policy.maxPages,
Delay: policy.pageDelay,
Wait: wait,
Fetch: func(_ context.Context, pageNumber int, pageToken string) (bool, string, error) {
params := clonePageParams(request.Params)
if pageToken != "" {
params["page_token"] = pageToken
}
return meta, errs.NewInternalError(errs.SubtypeUnknown,
"accumulate pagination page %d: %v", pageNumber, err).
WithCause(err)
}
meta.Pages++
hasMore, nextPageToken := PaginationMeta(data)
if !hasMore {
meta.Complete = true
meta.NextToken = ""
return meta, nil
}
if nextPageToken == "" {
return meta, invalidPageCursor("response reports more pages but returned no page token")
}
if _, repeated := seen[nextPageToken]; repeated {
return meta, invalidPageCursor("response repeated page token %q, which would paginate forever", nextPageToken)
}
meta.NextToken = nextPageToken
if pageNumber == policy.maxPages {
return meta, nil
}
seen[nextPageToken] = struct{}{}
pageToken = nextPageToken
if policy.pageDelay > 0 {
ctx := runtime.Ctx()
if ctx == nil {
ctx = context.Background()
if policy.showProgress {
fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", pageNumber)
}
if err := wait(ctx, policy.pageDelay); err != nil {
return meta, paginationWaitError(err)
data, err := runtime.CallAPITyped(request.Method, request.Path, params, request.Body)
if err != nil {
return false, "", err
}
}
page, err := decodePageData[T](data, pageNumber)
if err != nil {
return false, "", err
}
if err := dst.AddPage(page); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return false, "", err
}
return false, "", errs.NewInternalError(errs.SubtypeUnknown,
"accumulate pagination page %d: %v", pageNumber, err).
WithCause(err)
}
hasMore, nextPageToken := PaginationMeta(data)
return hasMore, nextPageToken, nil
},
})
meta.Complete = state.Complete
meta.Pages = state.Pages
meta.NextToken = state.NextToken
if walkErr == nil {
return meta, nil
}
return meta, paginationWalkError(walkErr)
}
return meta, errs.NewInternalError(errs.SubtypeUnknown,
"pagination exhausted its page budget without producing a terminal result")
func paginationWalkError(walkErr error) error {
var cursorErr *internalpagination.CursorError
if errors.As(walkErr, &cursorErr) {
if cursorErr.Kind == internalpagination.CursorMissing {
return invalidPageCursor("response reports more pages but returned no page token")
}
return invalidPageCursor("response repeated page token %q, which would paginate forever", cursorErr.Token)
}
var waitErr *internalpagination.WaitError
if errors.As(walkErr, &waitErr) {
return paginationWaitError(waitErr.Err)
}
if _, ok := errs.ProblemOf(walkErr); ok {
return walkErr
}
return errs.NewInternalError(errs.SubtypeUnknown, "paginate: %v", walkErr).WithCause(walkErr)
}
type paginationPolicy struct {
@@ -174,17 +165,7 @@ func paginationProgressEnabled(runtime *RuntimeContext) bool {
}
func waitPageDelay(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return nil
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
return internalpagination.WaitContext(ctx, delay)
}
func paginationWaitError(err error) error {
+13 -2
View File
@@ -1386,11 +1386,22 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
// handleShortcutDryRun renders a shortcut plan without sending its API requests.
func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error {
if s.DryRun == nil {
if s.DryRun == nil && s.DryRunE == nil {
return ValidationErrorf("--dry-run is not supported for %s %s", s.Service, s.Command).
WithParam("--dry-run")
}
dryResult := s.DryRun(rctx.ctx, rctx)
var (
dryResult *DryRunAPI
err error
)
if s.DryRunE != nil {
dryResult, err = s.DryRunE(rctx.ctx, rctx)
} else {
dryResult = s.DryRun(rctx.ctx, rctx)
}
if err != nil {
return err
}
if dryResult != nil {
// Same data.context contract as the service/api dry-run paths.
dryResult.Context(rctx.Config.AppID, rctx.UserOpenId())
+21
View File
@@ -339,6 +339,27 @@ func TestRunShortcut_DryRunJSONUsesEnvelope(t *testing.T) {
}
}
func TestRunShortcut_DryRunEReturnsTypedError(t *testing.T) {
sentinel := errs.NewValidationError(errs.SubtypeInvalidArgument, "dry-run input is invalid")
s := &Shortcut{
Service: "test", Command: "test-shortcut", AuthTypes: []string{"bot"},
DryRunE: func(context.Context, *RuntimeContext) (*DryRunAPI, error) {
return nil, sentinel
},
Execute: func(context.Context, *RuntimeContext) error {
t.Fatal("Execute should not run in dry-run")
return nil
},
}
f := newTestFactory()
cmd := newTestShortcutCmd(s, f)
cmd.Flags().Set("dry-run", "true")
cmd.Flags().Set("as", "bot")
if err := runShortcut(cmd, f, s, false); !errors.Is(err, sentinel) {
t.Fatalf("runShortcut() error = %v", err)
}
}
func TestRunShortcut_DryRunWithJq(t *testing.T) {
s := &Shortcut{
Service: "test",
+8
View File
@@ -40,6 +40,9 @@ func compileDefinition[Args any, Data any](definition Definition[Args, Data]) (*
if definition.Hooks.Execute == nil {
return nil, fmt.Errorf("Hooks.Execute is required")
}
if definition.Hooks.DryRun != nil && definition.Hooks.DryRunE != nil {
return nil, fmt.Errorf("Hooks.DryRun and Hooks.DryRunE cannot both be set")
}
return compileDefinitionParts(
definition.Metadata,
definition.Input,
@@ -247,6 +250,11 @@ func adaptHooks[Args any, Data any](hooks Hooks[Args, Data]) compiledHooks {
return hooks.DryRun(ctx, cc, args.(*Args)), nil
}
}
if hooks.DryRunE != nil {
adapted.dryRun = func(ctx context.Context, cc CommandContext, args any) (*DryRunAPI, error) {
return hooks.DryRunE(ctx, cc, args.(*Args))
}
}
adapted.execute = func(ctx context.Context, cc CommandContext, args any) (compiledResult, error) {
result, err := hooks.Execute(ctx, cc, args.(*Args))
return compiledResult{data: result.Data, outcome: result.Outcome, meta: result.Meta}, err
+1
View File
@@ -168,6 +168,7 @@ type Hooks[Args any, Data any] struct {
Normalize func(context.Context, CommandContext, *Args) error
Validate func(context.Context, CommandContext, *Args) error
DryRun func(context.Context, CommandContext, *Args) *DryRunAPI
DryRunE func(context.Context, CommandContext, *Args) (*DryRunAPI, error)
Execute func(context.Context, CommandContext, *Args) (Result[Data], error)
Renderers map[string]Renderer[Data]
}
@@ -0,0 +1,76 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"time"
"github.com/larksuite/cli/errs"
internalpagination "github.com/larksuite/cli/internal/pagination"
)
// CommandPageCollection is the host projection used by the public command adapter.
type CommandPageCollection struct {
Data []map[string]any
Complete bool
Pages int
NextToken string
}
// CollectCommandPages uses the shared cursor walker for an externally declared command.
func CollectCommandPages(ctx context.Context, command CommandContext, request PageRequest, all bool) (CommandPageCollection, error) {
policy, err := commandPagePolicy(command, all)
if err != nil {
return CommandPageCollection{}, err
}
collection := CommandPageCollection{}
state, walkErr := internalpagination.Walk(ctx, internalpagination.Options{
InitialToken: pageTokenParam(request.Params),
MaxPages: policy.maxPages,
Delay: policy.pageDelay,
Fetch: func(ctx context.Context, _ int, pageToken string) (bool, string, error) {
params := clonePageParams(request.Params)
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := CallTypedAPI(ctx, command, request.Method, request.Path, params, request.Body)
if err != nil {
return false, "", err
}
collection.Data = append(collection.Data, data)
hasMore, nextToken := PaginationMeta(data)
return hasMore, nextToken, nil
},
})
collection.Complete = state.Complete
collection.Pages = state.Pages
collection.NextToken = state.NextToken
if walkErr != nil {
return collection, paginationWalkError(walkErr)
}
return collection, nil
}
func commandPagePolicy(command CommandContext, all bool) (paginationPolicy, error) {
if all {
return paginationPolicy{maxPages: pageLimitMaximum}, nil
}
options, err := command.PaginationOptions()
if err != nil {
return paginationPolicy{}, err
}
if !options.All {
options.MaxPages = 1
}
if options.MaxPages < 1 || options.MaxPages > pageLimitMaximum {
return paginationPolicy{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"pagination page limit must be between 1 and %d", pageLimitMaximum)
}
if options.Delay < 0 || options.Delay > time.Duration(pageDelayMaximum)*time.Millisecond {
return paginationPolicy{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"pagination delay must be between 0 and %d milliseconds", pageDelayMaximum)
}
return paginationPolicy{maxPages: options.MaxPages, pageDelay: options.Delay}, nil
}
+12
View File
@@ -0,0 +1,12 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
// ShortcutSchema returns the immutable schema contract of a Typed Shortcut.
func ShortcutSchema(shortcut Shortcut) (any, bool) {
if shortcut.typed == nil {
return nil, false
}
return shortcut.typed.contract, true
}
+4 -3
View File
@@ -63,9 +63,10 @@ type Shortcut struct {
// used to satisfy a Cobra Required flag; alternatives such as "A or legacy B"
// are a business constraint and must be validated as such.
Normalize FlagNormalizer
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
DryRunE func(ctx context.Context, runtime *RuntimeContext) (*DryRunAPI, error) // optional error-capable dry-run; takes precedence over DryRun
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
// OnInvoke, when non-nil, runs from the command's cobra PreRunE — before
// cobra validates required flags — so its side effect fires even when the
+16
View File
@@ -0,0 +1,16 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package skills exposes the repository's default embedded skill content.
package skills
import (
"embed"
"io/fs"
)
//go:embed */SKILL.md */references */routes */scenes
var content embed.FS
// DefaultFS returns the immutable default skill tree rooted at skill names.
func DefaultFS() fs.FS { return content }
+17
View File
@@ -0,0 +1,17 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skills
import (
"io/fs"
"testing"
)
func TestDefaultFSContainsSkillAndReference(t *testing.T) {
for _, path := range []string{"lark-doc/SKILL.md", "lark-doc/references/lark-doc-fetch.md"} {
if _, err := fs.ReadFile(DefaultFS(), path); err != nil {
t.Fatalf("read %s: %v", path, err)
}
}
}