chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// SchemaOptions holds all inputs for the schema command.
|
||||
type SchemaOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
|
||||
// Args are the positional path segments, in either the dotted single-arg
|
||||
// form ("im.messages.reply") or the space-separated form ("im messages
|
||||
// reply"); apicatalog.ParsePath normalizes both.
|
||||
Args []string
|
||||
}
|
||||
|
||||
// NewCmdSchema creates the schema command. If runF is non-nil it is called instead of schemaRun (test hook).
|
||||
func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Command {
|
||||
opts := &SchemaOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema [path | service resource method]",
|
||||
Short: "View API method parameters, types, and scopes",
|
||||
Args: cobra.MaximumNArgs(8),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Args = append([]string(nil), args...)
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return schemaRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
// Tolerated for agent compatibility; ignored — schema only emits the JSON
|
||||
// envelope, and its output is identity-independent (strict-mode filtering
|
||||
// comes from ResolveStrictMode, never from --as).
|
||||
cmd.Flags().String("format", "json", "")
|
||||
cmd.Flags().Bool("json", true, "")
|
||||
cmd.Flags().String("as", "", "")
|
||||
_ = cmd.Flags().MarkHidden("format")
|
||||
_ = cmd.Flags().MarkHidden("json")
|
||||
_ = cmd.Flags().MarkHidden("as")
|
||||
|
||||
cmd.ValidArgsFunction = completeSchemaPath(f)
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
|
||||
// It uses the same source as schema execution so completion candidates match
|
||||
// what `schema` can resolve.
|
||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if noSpace {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
}
|
||||
}
|
||||
|
||||
func schemaRun(opts *SchemaOptions) error {
|
||||
out := opts.Factory.IOStreams.Out
|
||||
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
|
||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||
}
|
||||
|
||||
// runSchema resolves the path through the schema catalog and renders the
|
||||
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||
// output shape — a single resolved method renders as one envelope object,
|
||||
// anything broader as an array — and maps resolve failures to hints.
|
||||
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
catalog := registry.SchemaCatalog()
|
||||
if len(catalog.Services()) == 0 {
|
||||
// No embedded metadata and the runtime fallback is empty too: offline
|
||||
// with a cold cache, remote meta off, or an unwritable cache dir.
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
|
||||
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
|
||||
}
|
||||
target, err := catalog.Resolve(parts)
|
||||
if err != nil {
|
||||
return resolveError(err)
|
||||
}
|
||||
refs := catalog.MethodRefs(target, registry.FilterForStrictMode(mode))
|
||||
if target.Kind == apicatalog.TargetMethod {
|
||||
if len(refs) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"Method %s not available in current identity mode", target.Method.SchemaPath()).
|
||||
WithHint("strict mode hides methods the active account identity cannot call; it is shown for an identity (user or bot) that has the required access token")
|
||||
}
|
||||
output.PrintJson(out, schema.EnvelopeOf(refs[0]))
|
||||
return nil
|
||||
}
|
||||
output.PrintJson(out, schema.Envelopes(refs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveError maps a catalog *ResolveError to a typed *errs.ValidationError
|
||||
// (CategoryValidation drives the exit code; Hint promotes to the envelope),
|
||||
// preserving the historical message + hint text.
|
||||
func resolveError(err error) error {
|
||||
var re *apicatalog.ResolveError
|
||||
if !errors.As(err, &re) {
|
||||
return err
|
||||
}
|
||||
switch re.Kind {
|
||||
case apicatalog.ErrService:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown service: %s", re.Subject).
|
||||
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
|
||||
case apicatalog.ErrResource:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown resource: %s", re.Subject).
|
||||
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
|
||||
case apicatalog.ErrMethod:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown method: %s", re.Subject).
|
||||
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
|
||||
case apicatalog.ErrPath:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown path: %s", re.Subject).
|
||||
WithHint("Method %q exists but the trailing segments %q do not resolve", re.Method, re.Trailing)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestSchemaCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *SchemaOptions
|
||||
cmd := NewCmdSchema(f, func(opts *SchemaOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"calendar.events.list"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(gotOpts.Args) != 1 || gotOpts.Args[0] != "calendar.events.list" {
|
||||
t.Errorf("expected args [calendar.events.list], got %v", gotOpts.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_OutputFlagsAcceptedForCompat(t *testing.T) {
|
||||
// Agents are habituated to --format/--json/--as from api/service commands.
|
||||
// schema must accept them without erroring and always emit the JSON envelope —
|
||||
// its output is structured JSON and identity-independent, so the values have
|
||||
// no effect.
|
||||
argSets := [][]string{
|
||||
{"--format", "json"},
|
||||
{"--format", "pretty"},
|
||||
{"--format", "table"}, // no table rendering for a nested schema -> JSON
|
||||
{"--format", "csv"},
|
||||
{"--json"},
|
||||
{"--json", "--format", "ndjson"},
|
||||
{"--as", "user"},
|
||||
{"--as", "bot"},
|
||||
{"--as", "user", "--json"},
|
||||
}
|
||||
for _, extra := range argSets {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs(append([]string{"im.images.create"}, extra...))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("args %v should be accepted, got error: %v", extra, err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("args %v: output is not a JSON envelope: %v\n%s", extra, err, stdout.String())
|
||||
}
|
||||
if env["name"] != "im images create" {
|
||||
t.Errorf("args %v: expected the im images create envelope, got name=%v", extra, env["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := strings.TrimSpace(stdout.String())
|
||||
if !strings.HasPrefix(out, "[") {
|
||||
head := out
|
||||
if len(head) > 80 {
|
||||
head = head[:80]
|
||||
}
|
||||
t.Errorf("expected JSON array root, first 80 chars:\n%s", head)
|
||||
}
|
||||
var envs []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &envs); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
if len(envs) < 193 {
|
||||
t.Errorf("envelopes count = %d, want >= 193", len(envs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env["name"] != "im images create" {
|
||||
t.Errorf("name = %v, want \"im images create\"", env["name"])
|
||||
}
|
||||
for _, key := range []string{"description", "inputSchema", "outputSchema", "_meta"} {
|
||||
if _, ok := env[key]; !ok {
|
||||
t.Errorf("missing top-level key: %s", key)
|
||||
}
|
||||
}
|
||||
meta, _ := env["_meta"].(map[string]interface{})
|
||||
if meta["envelope_version"] != "1.0" {
|
||||
t.Errorf("envelope_version = %v, want \"1.0\"", meta["envelope_version"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_SpaceSeparatedPath_EqualsDotted(t *testing.T) {
|
||||
f1, out1, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd1 := NewCmdSchema(f1, nil)
|
||||
cmd1.SetArgs([]string{"im", "images", "create"})
|
||||
if err := cmd1.Execute(); err != nil {
|
||||
t.Fatalf("space form failed: %v", err)
|
||||
}
|
||||
|
||||
f2, out2, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd2 := NewCmdSchema(f2, nil)
|
||||
cmd2.SetArgs([]string{"im.images.create"})
|
||||
if err := cmd2.Execute(); err != nil {
|
||||
t.Fatalf("dotted form failed: %v", err)
|
||||
}
|
||||
|
||||
if out1.String() != out2.String() {
|
||||
t.Errorf("space and dotted forms produced different output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_ServiceListIsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var envs []map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envs); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(envs) == 0 {
|
||||
t.Fatal("expected non-empty array for service im")
|
||||
}
|
||||
for _, e := range envs {
|
||||
name, _ := e["name"].(string)
|
||||
if !strings.HasPrefix(name, "im ") {
|
||||
t.Errorf("envelope name %q does not start with \"im \"", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_HighRiskYesInjection(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.messages.delete"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
is, _ := env["inputSchema"].(map[string]interface{})
|
||||
props, _ := is["properties"].(map[string]interface{})
|
||||
if _, ok := props["yes"]; !ok {
|
||||
t.Errorf("inputSchema.properties.yes missing for high-risk-write command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.reactions.list"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
is, _ := env["inputSchema"].(map[string]interface{})
|
||||
props, _ := is["properties"].(map[string]interface{})
|
||||
if _, ok := props["yes"]; ok {
|
||||
t.Errorf("yes property should not appear for risk=read command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_UnknownService(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"nonexistent_service"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown service")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Unknown service") {
|
||||
t.Errorf("expected 'Unknown service' error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "Available:") {
|
||||
t.Errorf("expected hint listing available services, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaCmd_UnknownMethod_TypedValidation pins the typed envelope for the
|
||||
// JSON-mode unknown-method path: *errs.ValidationError with
|
||||
// subtype invalid_argument and a hint listing the available methods.
|
||||
func TestSchemaCmd_UnknownMethod_TypedValidation(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"calendar.events.nonexistent_method"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown method")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Unknown method") {
|
||||
t.Errorf("expected 'Unknown method' error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "Available:") {
|
||||
t.Errorf("expected hint listing available methods, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// Completion candidate generation (dotted + space forms, strict-mode filtering,
|
||||
// dotted-resource handling) now lives in internal/apicatalog and is covered by
|
||||
// apicatalog's TestComplete. cmd/schema only adapts catalog.Complete to cobra.
|
||||
Reference in New Issue
Block a user