Compare commits

..

14 Commits

Author SHA1 Message Date
shanglei
5cd34b1f70 Merge branch 'main' into feat/shortcut-protocol 2026-06-03 13:50:45 +08:00
shanglei
9a53a1f2b8 fix(shortcuts): typed []string named-element binding + nil-Execute mount parity
Two parity edge cases from a follow-up audit:

- []<named string> element types (type ID string; field []ID) passed parse
  validation but panicked at bind via reflect.Convert([]string -> []ID). Build
  the slice element-by-element with SetString (stringsToSlice) so plain
  []string, named slice types and named element types all bind correctly.
- nil Execute: mountTyped now skips mounting (matching legacy
  Shortcut.MountWithContext) instead of mounting a command that errors only at
  invocation, keeping the command tree identical after migration.

Adds binder_namedslice_nilexec_test.go (4 cases).
2026-05-29 15:30:20 +08:00
shanglei
eb6f5aa60a feat(shortcuts): typed []string flags, per-flag hidden, @file help hint
Close the last legacy/typed capability gaps so migration never downgrades:

- []string fields: registerLeaf/bindLeaf gain a Slice case. Default maps to
  cobra StringSlice (comma-separated, repeatable); `split:"none"` opts into
  StringArray (repeatable, no comma split). Non-[]string slices and split on
  non-slice fields error at Mount time. bucketLeafValue handles slices in
  OneOf/group too.
- per-flag hidden: `hidden:"true"` tag → fieldSpec.Hidden → MarkHidden;
  typed help skips hidden flags (parity with legacy Flag.Hidden).
- @file/stdin discoverability: typed help now appends a (supports @file /
  - for stdin) hint for input-tagged flags, matching legacy.

Adds binder_slice_hidden_test.go (11 cases).
2026-05-29 15:15:09 +08:00
shanglei
c4eb18cecc feat(shortcuts): typed @file/stdin input + enum completion/help parity
Bring TypedShortcut[T] to parity with legacy common.Shortcut on two flag
capabilities that were silently missing on the typed path:

- @file / stdin: declare `input:"file,stdin"` on an Args field; the binder
  resolves @path (file) and - (stdin) before binding, recursing into OneOf
  buckets and groups. resolveInputForFlag is extracted from the legacy
  resolveInputFlags so both paths behave identically.
- enum: registerLeaf registers shell completion and typed help renders the
  candidate list, matching legacy. enum/input tags on non-string fields now
  error at Mount time instead of being silently skipped at runtime.

Legacy behavior unchanged. Adds binder_input_enum_test.go (11 cases).
2026-05-29 14:55:40 +08:00
shanglei
a510e07dfc refactor(shortcuts): drop unused argstype.UserOpenIDList
UserOpenIDList ([]string) had zero production callers and cannot be bound
as a typed flag field: the binder only handles string/bool/int scalars, so
a []string field panics at bind time (reflect.Value.Convert: string cannot
convert to []string). Remove the type, its ParseUserOpenIDList helper and
the test instead of special-casing slice binding for a type nobody uses.
2026-05-29 11:51:16 +08:00
shanglei
f83c79825d refactor(shortcuts): retire Maybe[T] — top-level *T pointer covers tri-state
Maybe[T] solved a real problem (distinguishing "user did not provide this
flag" from "user provided the type's zero value, e.g. --notify=false or
--limit 0"), but it added a framework-specific wrapper that every migrator
had to learn. Two independent dogfood migrations both flagged it as the
guide's biggest cognitive bump.

This change retires Maybe[T] and reuses the existing OneOf pointer
convention at the top level: *T now means "optional with tri-state
semantics — nil iff the user did not set the flag". The rule is the same
whether the *T sits inside a OneOf bucket (Chat *ChatID — variant not
selected) or at the top level of an Args struct (Notify *bool — flag not
given), so users learn one concept instead of two.

Code:
- binder.go: bindLeaf now gates pointer allocation behind
  cmd.Flags().Changed(name); previously a *T top-level pointer was always
  allocated, defeating the "nil = not given" semantic. fieldSpec loses the
  IsMaybe field; parseFieldSpec drops Maybe-shape detection; registerFlags
  / bindFlags drop their IsMaybe branches; the bindMaybe helper is deleted.
- protocol.go: Maybe[T] type removed.
- protocol_test.go: TestMaybe_UnsetVsZero removed.
- binder_coverage_test.go: bindMaybe tests replaced with two
  TestBindLeaf_Ptr* tests covering the new "*T top-level pointer" contract
  — nil when absent, non-nil with the user's value (including the tri-state
  case --notify=false) when explicitly set.

Verified: go build ./..., go test ./shortcuts/common/..., gofmt clean, and
`go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./...` vs origin/main
shows zero new dead code.
2026-05-28 19:29:34 +08:00
shanglei
9adc79d0c1 fix(shortcuts): typed help shows REQUIRED + defaults; restore TypedShortcut.Mount
Three fixes surfaced by a dogfood migration of an existing legacy shortcut
to the TypedShortcut framework.

1. Required flags rendered under OPTIONAL
   The typed help renderer had no notion of required vs optional and lumped
   every top-level leaf into a single OPTIONAL section. Add renderRequiredSection
   that lists fields whose `required` tag is set under a REQUIRED: header; the
   updated renderOptionalSection skips those so each leaf appears in exactly
   one section.

2. Default values silently dropped
   Fields tagged `default:"x"` showed no `(default "x")` suffix, unlike cobra's
   legacy default help. Add a formatLeafLine helper that appends `(default "x")`
   whenever fieldSpec.DefaultValue is non-empty; the REQUIRED, OPTIONAL, and
   CHOOSE ONE bucket renderers all reuse it for consistent information density.

3. TypedShortcut.Mount was missing
   Legacy common.Shortcut exposes BOTH .Mount(parent, factory) and
   .MountWithContext. The framework dropped .Mount in 8d8acb82 to satisfy
   deadcode, but a dogfood migration revealed the asymmetry forces every
   migrating shortcut's tests to also switch to MountWithContext. Restore the
   3-line Mount convenience method (delegates to MountWithContext with a
   background context) and add a unit test exercising it directly — the test
   doubles as API documentation and keeps deadcode happy.

Tests added:
- TestTypedShortcut_Mount: verifies the legacy-shaped Mount(parent, factory)
  call still wires the subcommand
- TestTypedHelp_RequiredSectionAndDefaults: asserts REQUIRED comes before
  OPTIONAL, --limit lands in REQUIRED not OPTIONAL, `(default "20")` appears
  for --page-size, and a plain --verbose carries no default suffix

Verified locally: go build ./..., go test ./shortcuts/common/..., gofmt -l,
and `go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./...` vs
origin/main — all pass with zero new dead code.
2026-05-28 17:24:58 +08:00
shanglei
6b4bc0cc64 style(shortcuts): gofmt binder_test.go
CI fast-gate's `gofmt -l .` flagged an alignment issue in contentBucket:
gofmt aligns struct field tags to the longest *named* tag-bearing field
in the group; my hand-written extra spaces in `Text *string   \`flag:"ct"\``
got normalised to a single space.

Behaviour-neutral; tests still pass.
2026-05-28 16:51:17 +08:00
shanglei
b5cd535285 refactor(shortcuts): drop oneof_trigger tag, infer variant attempt from any inner flag
The previous checkOneOf required group / bucket variants inside a OneOf
to mark exactly one inner field with `oneof_trigger:"true"`, so the
framework could identify "the flag that signals the variant was selected".
This forces the Args writer to think about an implementation detail and
produces a misleading shortcut_oneof_missing when a user supplies only a
companion field (e.g. --video-cover without --video) — the user actually
attempted that variant, but the framework couldn't see it.

Replace the explicit trigger with implicit detection: any inner flag of a
group / bucket variant that is Changed counts as attempting that variant.
The follow-up checkGroup catches the partial-fill case and surfaces a
shortcut_group_incomplete pointing at the missing flag, which is strictly
more useful than the prior oneof_missing.

Code changes:
- binder.go: drop fieldSpec.OneOfTrig, drop parseFieldSpec's oneof_trigger
  tag handling, rewrite checkOneOf with the inferred-attempt logic, delete
  the isTrigger helper (now inlined and simplified).
- typed_help.go: renderFlagsInBucket flattens all inner flags to the
  parent's indent (no more "trigger vs companion" visual distinction,
  matching the framework's new semantics).
- binder_test.go: add three behavioural tests covering the new contract:
  (1) companion alone surfaces group_incomplete (not oneof_missing),
  (2) full group passes,
  (3) simple variant + group companion both attempted yields oneof_multiple.

Verified: go build ./..., go test ./shortcuts/common/...,
`go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./...` against HEAD
vs origin/main yields zero new dead code.

Net: -1 tag, -1 field on fieldSpec, -1 helper function, simpler writer
ergonomics, more accurate error messages.
2026-05-28 16:42:40 +08:00
shanglei
098659cc18 feat(shortcuts): bind top-level group sub-struct fields in TypedShortcut
bindFlags and bindBuckets only populated top-level leaves and OneOf
buckets; a top-level group sub-struct (a regular nested struct without
OneOf() marker) had its inner flags registered and group-completeness
checked, but its field values were never written back into the Args
struct — Execute would read empty values from args.Group.X.

Add bindGroups as the top-level counterpart to bindBuckets for IsGroup
fields, and invoke it from mountTyped's Validate closure right after
bindBuckets. Behavior mirrors bindBuckets / bindBucketInner:

- Value-type top-level group: always populated; inner fields receive
  cobra flag values (including defaults).
- Pointer-type top-level group: allocated iff at least one inner flag
  was Changed, so a nil group still signals "user did not engage this
  group" while a non-nil group means "the user opted into it".

Tests cover four cases: value group with explicit flags, value group
with defaults applied, pointer group allocated when an inner flag is
set, pointer group left nil when nothing is set.

Verified: go build ./..., go test ./shortcuts/common/..., and
`go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./...` against
HEAD vs origin/main yields zero new dead code.

This closes the previously-documented limitation that group sub-structs
had to be nested inside an OneOf bucket to actually bind values.
2026-05-28 15:58:54 +08:00
shanglei
8d8acb8252 test(shortcuts): cover TypedShortcut descriptors, drop unused Mount
After reverting the im pilot in ccf654d3 the TypedShortcut framework
has no production caller, so the CI deadcode check flagged five
methods as newly unreachable. Resolve without breaking the framework:

- Remove TypedShortcut.Mount — a convenience wrapper over
  MountWithContext with zero callers (production OR test); not part
  of any interface contract.
- Add focused unit tests for the four ShortcutDescriptor methods
  required by the Mountable contract — GetAuthTypes plus
  ScopesForIdentity / ConditionalScopesForIdentity /
  DeclaredScopesForIdentity, with table-driven coverage of the
  user / bot / fallback / dedupe branches.

Locally verified: go run golang.org/x/tools/cmd/deadcode@v0.31.0
-test ./... against HEAD vs origin/main yields an empty diff.
2026-05-28 10:57:39 +08:00
shanglei
ccf654d3f0 revert(shortcuts): drop im messages-send typed pilot, keep framework
The im +messages-send TypedShortcut pilot was exploratory only; revert it
while retaining the TypedShortcut framework for future migrations.

- shortcuts/im: restored to pre-pilot state (im_messages_send.go back to
  legacy common.Shortcut; deleted protocol.go, protocol_test.go,
  im_messages_send_test.go; shortcuts.go drops TypedShortcuts()).
- shortcuts/register.go: removed addTyped(im.TypedShortcuts()) wiring and
  the now-unused addTyped helper; legacy addLegacy + Mountable dispatch
  retained.
- shortcuts/common, argstype, errs subtypes, cmd/auth adapters: kept.
- framework doc comments: replaced examples referencing the removed pilot
  types (MessageTarget/MessageContent/VideoContent/RawContent) with neutral
  descriptions; noted no typed shortcut is registered today.

Framework now has no production caller. Verified: go build ./... and
go test ./shortcuts/... ./errs/... ./cmd/auth/... ./cmd/ all pass.
2026-05-27 18:16:17 +08:00
shanglei
ad4368ed2a test(shortcuts): strengthen auth-type assertion + cover binder paths
- im_messages_send_test: assert auth-type set membership (reject
  duplicates / missing members) per CodeRabbit review
- binder_coverage_test: cover bindMaybe, bindBuckets / bindBucketInner,
  bucketLeafValue, runNormalize / asString, checkGroup,
  checkEnumAndRequired, and group recursion in runValidateValue — lifts
  patch coverage over the 60% gate
2026-05-27 15:32:58 +08:00
shanglei
a07239b923 feat(shortcuts): introduce TypedShortcut framework + im send pilot
Add strongly-typed shortcut protocol that coexists with the legacy
common.Shortcut. The new common.TypedShortcut[T] is a generic outer
wrapper backed by a reflect-driven binder; both legacy and typed
shortcuts satisfy a new common.Mountable / common.ShortcutDescriptor
interface pair so register.go can dispatch either through the same
pipeline.

Framework (shortcuts/common):
- protocol.go — Mountable / ShortcutDescriptor / OneOfMarker /
  Validatable / Normalizable[T] / ArgsValidator / Maybe[T] /
  HelpExample
- binder.go — reflect Args walk, intra-Args flag-tag uniqueness
  panic, cobra flag registration, bindFlags + bindMaybe, runNormalize
  (via MethodByName dispatch — Normalizable[T] can't be type-asserted
  through a non-generic interface), runValidateValue, runFrameworkRules
  for required / enum / OneOf / group
- typed_shortcut.go — TypedShortcut[T] struct, 8 descriptor methods,
  mountTyped adapter that synthesizes a legacy Shortcut shell and
  reuses runShortcut verbatim (identity / scopes / @file / stdin / jq
  / dry-run / high-risk gate)
- typed_help.go — sectioned --help (CHOOSE ONE / OPTIONAL / EXAMPLES)
  with cmdutil.GetRisk/GetTips passthrough so typed shortcuts keep the
  Risk: and Tips: blocks
- runner.go — typedArgs lifecycle slot on RuntimeContext
- types.go — 5 GetX accessors on *Shortcut so legacy shortcuts
  satisfy ShortcutDescriptor alongside the existing pointer-receiver
  scope methods

Typed primitives (shortcuts/common/argstype):
- ChatID / UserOpenID / UserOpenIDList — prefix-validated identifiers
- SafePath — cwd-relative, rejects absolute paths and ".." segments
- MediaInput — tri-state (URL bypass / img_xxx-file_xxx key bypass /
  SafePath delegation)
- SpreadsheetRef — Normalize extracts shtcn token from feishu URLs

Error contract (errs):
- 3 new Subtype constants: shortcut_oneof_missing /
  shortcut_oneof_multiple / shortcut_group_incomplete. Per-field
  failures (required / enum / typed primitive format) reuse the
  existing SubtypeInvalidArgument so no new error type is introduced.

Registry refactor (shortcuts + cmd):
- AllShortcuts() now returns []common.ShortcutDescriptor; legacy
  shortcuts are boxed as *Shortcut (pointer required for the
  pointer-receiver scope methods), typed shortcuts boxed as Mountable
- Register dispatches via the Mountable interface
- cmd/auth/login, cmd/auth/login_interactive, cmd/error_auth_hint,
  cmd/diagnose_scope_test, shortcuts/register_test (shortcuts.json
  generator) updated to read through GetService / GetCommand /
  GetAuthTypes / GetDescription / DeclaredScopesForIdentity
- shortcutSupportsIdentity helpers accept ShortcutDescriptor

Pilot (shortcuts/im):
- protocol.go — MessageTarget / MessageContent (with seven content
  variants) / VideoContent (paired video + cover) / RawContent
  (--content with explicit msg-type, validates JSON in
  ValidateValue)
- im_messages_send.go — migrated to TypedShortcut[*ImMessagesSendArgs].
  Inline Validate closure is replaced by framework-derived checks
  (OneOf target, OneOf content, VideoContent group, typed-primitive
  formats, RawContent JSON). Helpers (resolveMediaContent,
  wrapMarkdownAsPostForDryRun, normalizeAtMentions, etc.) reused
  verbatim; only the field-access pattern changes from
  runtime.Str("x") to args.X.
- shortcuts.go — new TypedShortcuts() exporter; ImMessagesSend
  removed from the legacy Shortcuts() slice so it is not
  double-mounted
- register.go wires addTyped(im.TypedShortcuts()) into init

Known follow-ups:
- runFrameworkRules and bindFlags do not recurse into OneOf bucket /
  group sub-structs; im messages-send compensates with a local
  bindMessagesSendArgs + validateVideoGroup. Generalizing the binder
  to recurse will let future migrations drop the local shim.
- common.ValidateChatID / common.ValidateUserID become redundant
  once all legacy shortcuts that call them migrate; can be retired
  with the last legacy caller.

Refs: docs/superpowers/specs/2026-05-26-shortcut-protocol-design.md
2026-05-27 11:32:06 +08:00
54 changed files with 3634 additions and 4553 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

14
errs/subtypes_shortcut.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

795
shortcuts/common/binder.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
SlidesCreate,
SlidesCreateSVG,
SlidesMediaUpload,
SlidesReplaceSlide,
}

View File

@@ -121,19 +121,35 @@ var SlidesCreate = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
title := effectiveTitle(runtime.Str("title"))
content := buildPresentationXML(title)
slidesStr := runtime.Str("slides")
presentationID, revisionID, err := createEmptyPresentation(runtime, title)
// Step 1: Create presentation
data, err := runtime.CallAPI(
"POST",
"/open-apis/slides_ai/v1/xml_presentations",
nil,
map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": content,
},
},
)
if err != nil {
return err
}
presentationID := common.GetString(data, "xml_presentation_id")
if presentationID == "" {
return output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id")
}
result := map[string]interface{}{
"xml_presentation_id": presentationID,
"title": title,
}
if revisionID > 0 {
result["revision_id"] = revisionID
if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 {
result["revision_id"] = int(revisionID)
}
// Step 2: Add slides if provided
@@ -182,9 +198,6 @@ var SlidesCreate = common.Shortcut{
if sid := common.GetString(slideData, "slide_id"); sid != "" {
slideIDs = append(slideIDs, sid)
}
if latest := common.GetFloat(slideData, "revision_id"); latest > 0 {
result["revision_id"] = int(latest)
}
}
result["slide_ids"] = slideIDs
@@ -192,7 +205,34 @@ var SlidesCreate = common.Shortcut{
}
}
fillPresentationResult(runtime, presentationID, result)
// Fetch presentation URL via drive meta (best-effort)
if metaData, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": presentationID,
"doc_type": "slides",
},
},
"with_url": true,
},
); err == nil {
metas := common.GetSlice(metaData, "metas")
if len(metas) > 0 {
if meta, ok := metas[0].(map[string]interface{}); ok {
if url := common.GetString(meta, "url"); url != "" {
result["url"] = url
}
}
}
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
result["permission_grant"] = grant
}
runtime.Out(result, nil)
return nil
@@ -219,41 +259,6 @@ func buildPresentationXML(title string) string {
)
}
func createEmptyPresentation(runtime *common.RuntimeContext, title string) (string, int, error) {
data, err := runtime.CallAPI(
"POST",
"/open-apis/slides_ai/v1/xml_presentations",
nil,
map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": buildPresentationXML(title),
},
},
)
if err != nil {
return "", 0, err
}
presentationID := common.GetString(data, "xml_presentation_id")
if presentationID == "" {
return "", 0, output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id")
}
revisionID := 0
if rev := common.GetFloat(data, "revision_id"); rev > 0 {
revisionID = int(rev)
}
return presentationID, revisionID, nil
}
func fillPresentationResult(runtime *common.RuntimeContext, presentationID string, result map[string]interface{}) {
if url, err := common.FetchDriveMetaURL(runtime, presentationID, "slides"); err == nil && url != "" {
result["url"] = url
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
result["permission_grant"] = grant
}
}
// uploadSlidesPlaceholders uploads each unique placeholder path against the
// presentation and returns the path→file_token map. The second return value is
// the number of files successfully uploaded before any error, so callers can

View File

@@ -1,189 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlidesCreateSVG creates a new Lark Slides presentation from one or more
// SVGlide SVG files by adding each page through the existing XML slide route.
var SlidesCreateSVG = common.Shortcut{
Service: "slides",
Command: "+create-svg",
Description: "Create a Lark Slides presentation from SVG",
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{
"slides:presentation:create",
"slides:presentation:write_only",
"docs:document.media:upload",
},
Flags: []common.Flag{
{Name: "title", Desc: "presentation title"},
{
Name: "file",
Type: "string_array",
Required: true,
Desc: "SVG file path; repeat for multiple pages",
},
{Name: "assets", Desc: "optional assets.json path mapping SVG @path placeholders to uploaded file tokens"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateSVGFileInputs(runtime, runtime.StrArray("file")); err != nil {
return err
}
return validateSVGAssetsPath(runtime, runtime.Str("assets"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
title := effectiveTitle(runtime.Str("title"))
filePaths := runtime.StrArray("file")
svgs, err := readSVGFiles(runtime, filePaths)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
classified, err := classifySVGlideSVGPages(filePaths, svgs)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
rewriteResult, uploadPaths, err := dryRunRewriteClassifiedSVGPages(classified, assets)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
pages := rewriteResult.Pages
dry := common.NewDryRunAPI()
total := 1 + len(uploadPaths) + len(pages)
descSuffix := ""
if len(uploadPaths) > 0 {
descSuffix = fmt.Sprintf(" + upload %d image(s)", len(uploadPaths))
}
dry.Desc(fmt.Sprintf("Create presentation from %d SVG page(s)%s", len(pages), descSuffix)).
POST("/open-apis/slides_ai/v1/xml_presentations").
Desc(fmt.Sprintf("[1/%d] Create presentation", total)).
Body(map[string]interface{}{
"xml_presentation": map[string]interface{}{"content": buildPresentationXML(title)},
})
for i, path := range uploadPaths {
appendSlidesUploadDryRun(dry, path, "<xml_presentation_id>", i+2)
}
slideStepStart := 2 + len(uploadPaths)
for i, page := range pages {
content, injectErr := injectSVGTransportAssetMetadata(page.Content, page.Tokens)
if injectErr != nil {
return common.NewDryRunAPI().Set("error", injectErr.Error())
}
dry.POST("/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide").
Desc(fmt.Sprintf("[%d/%d] Add SVG page %d", slideStepStart+i, total, i+1)).
Params(map[string]interface{}{"revision_id": -1}).
Body(buildCreateSVGBody(content))
}
if runtime.IsBot() {
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new presentation.")
}
return dry.Set("title", title)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
title := effectiveTitle(runtime.Str("title"))
filePaths := runtime.StrArray("file")
svgs, err := readSVGFiles(runtime, filePaths)
if err != nil {
return err
}
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
if err != nil {
return err
}
classified, err := classifySVGlideSVGPages(filePaths, svgs)
if err != nil {
return err
}
if hasFallbackPages(classified) {
if err := svgFallbackRasterizer.CheckAvailable(ctx); err != nil {
return err
}
}
renderedFallbacks, err := renderSVGFallbackPages(ctx, classified, svgFallbackRasterizer)
if err != nil {
return err
}
defer cleanupRenderedSVGFallbacks(renderedFallbacks)
presentationID, revisionID, err := createEmptyPresentation(runtime, title)
if err != nil {
return err
}
result := map[string]interface{}{
"xml_presentation_id": presentationID,
"title": title,
}
if revisionID > 0 {
result["revision_id"] = revisionID
}
rewriteResult, err := rewriteClassifiedSVGPages(runtime, presentationID, classified, assets, renderedFallbacks)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error",
"image upload failed: %v (presentation %s was created; %d image(s) uploaded before failure)",
err, presentationID, rewriteResult.ImagesUploaded)
}
if rewriteResult.ImagesUploaded > 0 {
result["images_uploaded"] = rewriteResult.ImagesUploaded
}
if rewriteResult.FallbackPages > 0 {
result["fallback_pages"] = rewriteResult.FallbackPages
}
pages := rewriteResult.Pages
slideURL := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
validate.EncodePathSegment(presentationID),
)
var slideIDs []string
for i, page := range pages {
content, err := injectSVGTransportAssetMetadata(page.Content, page.Tokens)
if err != nil {
return output.Errorf(output.ExitValidation, "validation",
"page %d/%d failed before API call: %v (presentation %s was created; %d slide(s) added; slide_ids=%s)",
i+1, len(pages), err, presentationID, len(slideIDs), strings.Join(slideIDs, ","))
}
slideData, err := runtime.CallAPI(
"POST",
slideURL,
map[string]interface{}{"revision_id": -1},
buildCreateSVGBody(content),
)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error",
"page %d/%d failed: %v%s (presentation %s was created; %d slide(s) added; slide_ids=%s)",
i+1, len(pages), err, formatSVGlideErrorSuffix(err), presentationID, len(slideIDs), strings.Join(slideIDs, ","))
}
if sid := common.GetString(slideData, "slide_id"); sid != "" {
slideIDs = append(slideIDs, sid)
}
if latest := common.GetFloat(slideData, "revision_id"); latest > 0 {
result["revision_id"] = int(latest)
}
}
result["slide_ids"] = slideIDs
result["slides_added"] = len(slideIDs)
fillPresentationResult(runtime, presentationID, result)
runtime.Out(result, nil)
return nil
},
}

View File

@@ -1,649 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"context"
"encoding/json"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
const testSVGlidePage1 = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="80" y="80" width="320" height="180"/></svg>`
const testSVGlidePage2 = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80"><p xmlns="http://www.w3.org/1999/xhtml">second</p></foreignObject></svg>`
func TestSlidesCreateSVGMissingFileFlag(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--title", "missing file",
"--as", "user",
})
if err == nil {
t.Fatal("expected missing --file error")
}
if !strings.Contains(err.Error(), "file") {
t.Fatalf("err = %v, want mention of file", err)
}
}
func TestSlidesCreateSVGFileMissing(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "missing.svg",
"--title", "missing svg",
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for missing SVG")
}
if !strings.Contains(err.Error(), "missing.svg") {
t.Fatalf("err = %v, want mention of missing.svg", err)
}
}
func TestSlidesCreateSVGEmptyFile(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("empty.svg", nil, 0o644); err != nil {
t.Fatalf("write empty.svg: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "empty.svg",
"--title", "empty svg",
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for empty SVG")
}
if !strings.Contains(err.Error(), "empty.svg") || !strings.Contains(err.Error(), "empty") {
t.Fatalf("err = %v, want empty.svg empty-file message", err)
}
}
func TestSlidesCreateSVGExecuteCreatesSlidesInFileOrder(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("page1.svg", []byte(testSVGlidePage1), 0o644); err != nil {
t.Fatalf("write page1.svg: %v", err)
}
if err := os.WriteFile("page2.svg", []byte(testSVGlidePage2), 0o644); err != nil {
t.Fatalf("write page2.svg: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation_id": "pres_svg",
"revision_id": 1,
},
},
})
slideStub1 := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_1", "revision_id": 2}},
}
slideStub2 := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_2", "revision_id": 3}},
}
reg.Register(slideStub1)
reg.Register(slideStub2)
registerBatchQueryStub(reg, "pres_svg", "https://x.feishu.cn/slides/pres_svg")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page1.svg",
"--file", "page2.svg",
"--title", "SVG Deck",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_svg" {
t.Fatalf("xml_presentation_id = %v, want pres_svg", data["xml_presentation_id"])
}
if data["slides_added"] != float64(2) {
t.Fatalf("slides_added = %v, want 2", data["slides_added"])
}
if data["revision_id"] != float64(3) {
t.Fatalf("revision_id = %v, want latest revision 3", data["revision_id"])
}
slideIDs, ok := data["slide_ids"].([]interface{})
if !ok || len(slideIDs) != 2 || slideIDs[0] != "slide_1" || slideIDs[1] != "slide_2" {
t.Fatalf("slide_ids = %v, want [slide_1 slide_2]", data["slide_ids"])
}
assertSlideCreateBodyContains(t, slideStub1, `slide:contract-version="svglide-authoring-contract/v1"`)
assertSlideCreateBodyContains(t, slideStub1, `<rect slide:role="shape" x="80" y="80" width="320" height="180"/>`)
assertSlideCreateBodyContains(t, slideStub2, `slide:contract-version="svglide-authoring-contract/v1"`)
assertSlideCreateBodyContains(t, slideStub2, `<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80">`)
}
func TestSlidesCreateSVGPartialFailureIncludesRecoveryContext(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("page1.svg", []byte(testSVGlidePage1), 0o644); err != nil {
t.Fatalf("write page1.svg: %v", err)
}
if err := os.WriteFile("page2.svg", []byte(testSVGlidePage2), 0o644); err != nil {
t.Fatalf("write page2.svg: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation_id": "pres_svg_partial",
"revision_id": 1,
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg_partial/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_ok", "revision_id": 2}},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg_partial/slide",
Body: map[string]interface{}{
"code": 400,
"msg": "invalid svg",
},
})
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page1.svg",
"--file", "page2.svg",
"--title", "partial svg",
"--as", "user",
})
if err == nil {
t.Fatal("expected slide create failure")
}
errMsg := err.Error()
for _, want := range []string{"pres_svg_partial", "page 2/2", "1 slide(s) added", "slide_ok"} {
if !strings.Contains(errMsg, want) {
t.Fatalf("err = %v, want mention of %q", err, want)
}
}
}
func TestSlidesCreateSVGFailureExtractsSVGlideMarker(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("page.svg", []byte(testSVGlidePage1), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_marker", "revision_id": 1}},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_marker/slide",
Body: map[string]interface{}{
"code": 400,
"msg": `SVGLIDE_ERROR_JSON:{"type":"svg_validation_error","page_index":0,"tag_name":"foreignObject","hint":"Use supported elements"}`,
},
})
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--title", "marker",
"--as", "user",
})
if err == nil {
t.Fatal("expected marker failure")
}
errMsg := err.Error()
for _, want := range []string{"svglide_error=", "svg_validation_error", "foreignObject", "Use supported elements"} {
if !strings.Contains(errMsg, want) {
t.Fatalf("err = %v, want marker field %q", err, want)
}
}
}
func TestSlidesCreateSVGAssetsReplaceImageAndInjectMetadata(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><image slide:role="image" xlink:href='@./hero.png' x="0" y="0" width="320" height="180"/></svg>`
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
if err := os.WriteFile("assets.json", []byte(`{"@./hero.png":"boxcn_asset"}`), 0o644); err != nil {
t.Fatalf("write assets.json: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_asset", "revision_id": 1}},
})
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_asset/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_asset", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_asset", "https://x.feishu.cn/slides/pres_asset")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--assets", "assets.json",
"--title", "assets",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
content := body["slide"].(map[string]interface{})["content"].(string)
if strings.Contains(content, "@./hero.png") || strings.Contains(content, "xlink:href") {
t.Fatalf("content should canonicalize asset placeholder: %s", content)
}
for _, want := range []string{`href="boxcn_asset"`, `<metadata data-svglide-assets="true">`, `<img src="boxcn_asset" />`} {
if !strings.Contains(content, want) {
t.Fatalf("content missing %s: %s", want, content)
}
}
if _, ok := decodeSlidesCreateEnvelope(t, stdout)["images_uploaded"]; ok {
t.Fatalf("--assets token mapping should not upload local images")
}
}
func TestSlidesCreateSVGNestedImageAssetsReplaceAndInjectMetadata(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><g transform="translate(10 20)"><image slide:role="image" xlink:href='@./hero.png' x="0" y="0" width="320" height="180"/></g></svg>`
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
if err := os.WriteFile("assets.json", []byte(`{"@./hero.png":"boxcn_asset"}`), 0o644); err != nil {
t.Fatalf("write assets.json: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_nested_asset", "revision_id": 1}},
})
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_nested_asset/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_nested_asset", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_nested_asset", "https://x.feishu.cn/slides/pres_nested_asset")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--assets", "assets.json",
"--title", "nested assets",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
content := body["slide"].(map[string]interface{})["content"].(string)
for _, want := range []string{
`href="boxcn_asset"`,
`<metadata data-svglide-assets="true">`,
`<img src="boxcn_asset" />`,
`<g transform="translate(10 20)">`,
} {
if !strings.Contains(content, want) {
t.Fatalf("content missing %s: %s", want, content)
}
}
for _, notWant := range []string{`xlink:href`, `@./hero.png`} {
if strings.Contains(content, notWant) {
t.Fatalf("content should not contain %s: %s", notWant, content)
}
}
if _, ok := decodeSlidesCreateEnvelope(t, stdout)["images_uploaded"]; ok {
t.Fatalf("--assets token mapping should not upload local images")
}
}
func TestSlidesCreateSVGUploadsLocalImagesAndInjectsMetadata(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><image slide:role="image" href="@hero.png" x="0" y="0" width="320" height="180"/></svg>`
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
if err := os.WriteFile("hero.png", []byte("png"), 0o644); err != nil {
t.Fatalf("write hero.png: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_upload", "revision_id": 1}},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "boxcn_uploaded"}},
})
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_upload/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_upload", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_upload", "https://x.feishu.cn/slides/pres_upload")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--title", "upload",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["images_uploaded"] != float64(1) {
t.Fatalf("images_uploaded = %v, want 1", data["images_uploaded"])
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
content := body["slide"].(map[string]interface{})["content"].(string)
for _, want := range []string{`href="boxcn_uploaded"`, `<img src="boxcn_uploaded" />`} {
if !strings.Contains(content, want) {
t.Fatalf("content missing %s: %s", want, content)
}
}
}
func TestSlidesCreateSVGFallbackRendersUploadsAndAddsImageOnlySVG(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="80" y="120">render me</text></svg>`
if err := os.WriteFile("fallback.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write fallback.svg: %v", err)
}
fake := &fakeSVGFallbackRasterizer{pngPath: "fallback.png", pngBytes: []byte("png-bytes")}
restore := setTestSVGFallbackRasterizer(fake)
defer restore()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_fallback", "revision_id": 1}},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "boxcn_fallback"}},
}
reg.Register(uploadStub)
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_fallback/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_fallback", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_fallback", "https://x.feishu.cn/slides/pres_fallback")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "fallback.svg",
"--title", "fallback",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(fake.calls) != 1 || fake.calls[0] != "fallback.svg" {
t.Fatalf("rasterizer calls = %v, want [fallback.svg]", fake.calls)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["fallback_pages"] != float64(1) {
t.Fatalf("fallback_pages = %v, want 1", data["fallback_pages"])
}
if data["images_uploaded"] != float64(1) {
t.Fatalf("images_uploaded = %v, want 1", data["images_uploaded"])
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
content := body["slide"].(map[string]interface{})["content"].(string)
for _, want := range []string{
`slide:contract-version="svglide-authoring-contract/v1"`,
`<image slide:role="image" href="boxcn_fallback" x="0" y="0" width="1280" height="720" preserveAspectRatio="none"/>`,
`<metadata data-svglide-assets="true"><img src="boxcn_fallback" /></metadata>`,
} {
if !strings.Contains(content, want) {
t.Fatalf("fallback slide content missing %s: %s", want, content)
}
}
if strings.Contains(content, "<text") {
t.Fatalf("fallback slide content should not contain original text node: %s", content)
}
}
func TestSlidesCreateSVGRejectsUnsafeBeforePresentationCreate(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><script>alert(1)</script></svg>`
if err := os.WriteFile("unsafe.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write unsafe.svg: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "unsafe.svg",
"--title", "unsafe",
"--as", "user",
})
if err == nil {
t.Fatal("expected preflight reject")
}
for _, want := range []string{"disallowed_script", "unsafe.svg"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("err = %v, want %q", err, want)
}
}
}
func TestSlidesCreateSVGRendererUnavailableBeforePresentationCreate(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="80" y="120">needs fallback</text></svg>`
if err := os.WriteFile("fallback.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write fallback.svg: %v", err)
}
fake := &fakeSVGFallbackRasterizer{
availableErr: newSVGlideDiagnosticsError("renderer unavailable", []SVGlideDiagnostic{{
Code: svgDiagRendererUnavailable,
Severity: svgDiagSeverityError,
Path: "fallback.svg",
Message: "renderer missing",
}}),
}
restore := setTestSVGFallbackRasterizer(fake)
defer restore()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "fallback.svg",
"--title", "renderer unavailable",
"--as", "user",
})
if err == nil {
t.Fatal("expected renderer unavailable error")
}
if !strings.Contains(err.Error(), svgDiagRendererUnavailable) {
t.Fatalf("err = %v, want renderer_unavailable", err)
}
if len(fake.calls) != 0 {
t.Fatalf("rasterizer should not render when availability check fails, calls=%v", fake.calls)
}
if fake.checkCalls != 1 {
t.Fatalf("renderer availability checks = %d, want 1", fake.checkCalls)
}
}
func TestSlidesCreateSVGRasterFailureBeforePresentationCreate(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="80" y="120">needs fallback</text></svg>`
if err := os.WriteFile("fallback.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write fallback.svg: %v", err)
}
fake := &fakeSVGFallbackRasterizer{
renderErr: newSVGlideDiagnosticsError("render failed", []SVGlideDiagnostic{{
Code: svgDiagRendererFailed,
Severity: svgDiagSeverityError,
Path: "fallback.svg",
Message: "render failed",
}}),
}
restore := setTestSVGFallbackRasterizer(fake)
defer restore()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "fallback.svg",
"--title", "render failure",
"--as", "user",
})
if err == nil {
t.Fatal("expected raster failure error")
}
if !strings.Contains(err.Error(), svgDiagRendererFailed) {
t.Fatalf("err = %v, want renderer_failed", err)
}
if len(fake.calls) != 1 {
t.Fatalf("rasterizer calls = %v, want one render attempt", fake.calls)
}
}
type fakeSVGFallbackRasterizer struct {
availableErr error
renderErr error
pngPath string
pngBytes []byte
checkCalls int
calls []string
}
func (f *fakeSVGFallbackRasterizer) CheckAvailable(context.Context) error {
f.checkCalls++
return f.availableErr
}
func (f *fakeSVGFallbackRasterizer) Rasterize(_ context.Context, svgPath string) (string, int64, error) {
f.calls = append(f.calls, svgPath)
if f.renderErr != nil {
return "", 0, f.renderErr
}
if f.pngPath == "" {
f.pngPath = "fallback.png"
}
if len(f.pngBytes) == 0 {
f.pngBytes = []byte("png")
}
if err := os.WriteFile(f.pngPath, f.pngBytes, 0o644); err != nil {
return "", 0, err
}
return f.pngPath, int64(len(f.pngBytes)), nil
}
func setTestSVGFallbackRasterizer(r svgRasterizer) func() {
old := svgFallbackRasterizer
svgFallbackRasterizer = r
return func() {
svgFallbackRasterizer = old
}
}
func runSlidesCreateSVGShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "slides"}
SlidesCreateSVG.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func assertSlideCreateBodyContains(t *testing.T, stub *httpmock.Stub, want string) {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v\nraw=%s", err, string(stub.CapturedBody))
}
slide, _ := body["slide"].(map[string]interface{})
content, _ := slide["content"].(string)
if !strings.Contains(content, want) {
t.Fatalf("slide content = %s\nwant to contain %s", content, want)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,513 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestExtractSVGImagePlaceholderPaths(t *testing.T) {
t.Parallel()
svgs := []string{
`<svg><image slide:role="image" href="@./hero.png"/><a href="@./link.png"/></svg>`,
`<svg><image xlink:href='@./hero.png'/><image href = "@./other.png"/></svg>`,
}
got := extractSVGImagePlaceholderPaths(svgs, map[string]string{"@./other.png": "boxcn_other"})
want := []string{"./hero.png"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestRewriteSVGImagePlaceholdersWithTokens(t *testing.T) {
t.Parallel()
in := `<svg><image slide:role="image" href="@./hero.png"/><image xlink:href='@./logo.png'/><image data-href="@./ignored.png"/><a href="@./link.png">link</a><image href="https://example.com/noop.png"/></svg>`
got, tokens := rewriteSVGImagePlaceholdersWithTokens(in, map[string]string{
"./hero.png": "boxcn_hero",
"./logo.png": "boxcn_logo",
})
for _, want := range []string{`href="boxcn_hero"`, `href="boxcn_logo"`} {
if !strings.Contains(got, want) {
t.Fatalf("rewritten SVG missing %s: %s", want, got)
}
}
if strings.Contains(got, "xlink:href") {
t.Fatalf("rewritten SVG must not retain xlink:href: %s", got)
}
if !strings.Contains(got, `<a href="@./link.png">`) {
t.Fatalf("non-image href should be untouched: %s", got)
}
if !strings.Contains(got, `data-href="@./ignored.png"`) {
t.Fatalf("non-href image attribute should be untouched: %s", got)
}
wantTokens := []string{"boxcn_hero", "boxcn_logo"}
if !reflect.DeepEqual(tokens, wantTokens) {
t.Fatalf("tokens = %v, want %v", tokens, wantTokens)
}
}
func TestInjectSVGTransportAssetMetadata(t *testing.T) {
t.Parallel()
in := `<?xml version="1.0"?><!DOCTYPE svg><!-- lead --><svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect/></svg>`
got, err := injectSVGTransportAssetMetadata(in, []string{"boxcn_a", "boxcn_b", "boxcn_a"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
rootIdx := strings.Index(got, "<svg")
metaIdx := strings.Index(got, `<metadata data-svglide-assets="true">`)
if rootIdx < 0 || metaIdx < rootIdx {
t.Fatalf("metadata should be injected inside root <svg>, got: %s", got)
}
if strings.Count(got, `src="boxcn_a"`) != 1 {
t.Fatalf("boxcn_a should be deduped, got: %s", got)
}
if !strings.Contains(got, `src="boxcn_b"`) {
t.Fatalf("boxcn_b missing, got: %s", got)
}
}
func TestInjectSVGTransportAssetMetadataMergesExisting(t *testing.T) {
t.Parallel()
in := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><metadata data-svglide-assets="true"><img src="boxcn_a" /></metadata><image href="boxcn_a"/></svg>`
got, err := injectSVGTransportAssetMetadata(in, []string{"boxcn_a", "boxcn_b"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Count(got, `<metadata data-svglide-assets="true">`) != 1 {
t.Fatalf("should keep a single transport metadata block, got: %s", got)
}
if strings.Count(got, `src="boxcn_a"`) != 1 {
t.Fatalf("boxcn_a should remain deduped, got: %s", got)
}
if !strings.Contains(got, `src="boxcn_b"`) {
t.Fatalf("boxcn_b should be appended, got: %s", got)
}
}
func TestClassifySVGlideSVGPageRoutes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
svg string
wantMode svgClassifyMode
wantCode string
}{
{
name: "native supported shape",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`,
wantMode: svgClassifyNative,
},
{
name: "native supported server line role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><line slide:role="line" x1="0" y1="0" x2="100" y2="60" stroke="#112233"/></svg>`,
wantMode: svgClassifyNative,
},
{
name: "native supported server text role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="text" x="0" y="0" width="300" height="80"><p xmlns="http://www.w3.org/1999/xhtml">SVGlide</p></foreignObject></svg>`,
wantMode: svgClassifyNative,
},
{
name: "marked svg text still falls back",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text slide:role="text" x="20" y="40">render me</text></svg>`,
wantMode: svgClassifyFallback,
wantCode: svgDiagNativeUnsupported,
},
{
name: "wrong contract native rejects",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="svglide-authoring-contract/v0" viewBox="0 0 1280 720"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`,
wantMode: svgClassifyReject,
wantCode: svgDiagContractVersion,
},
{
name: "wrong contract server text role rejects",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="svglide-authoring-contract/v0" viewBox="0 0 1280 720"><foreignObject slide:role="text" x="0" y="0" width="300" height="80"><p xmlns="http://www.w3.org/1999/xhtml">SVGlide</p></foreignObject></svg>`,
wantMode: svgClassifyReject,
wantCode: svgDiagContractVersion,
},
{
name: "unsupported but renderable text falls back",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="20" y="40">render me</text></svg>`,
wantMode: svgClassifyFallback,
wantCode: svgDiagNativeUnsupported,
},
{
name: "wrong contract fallback-only svg still falls back",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="svglide-authoring-contract/v0" viewBox="0 0 1280 720"><text x="20" y="40">render me</text></svg>`,
wantMode: svgClassifyFallback,
wantCode: svgDiagNativeUnsupported,
},
{
name: "table defaults to fallback",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject x="20" y="40" width="400" height="240"><table xmlns="http://www.w3.org/1999/xhtml"><tr><td>a</td></tr></table></foreignObject></svg>`,
wantMode: svgClassifyFallback,
wantCode: svgDiagNativeUnsupported,
},
{
name: "script rejects before create",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><script>alert(1)</script></svg>`,
wantMode: svgClassifyReject,
wantCode: svgDiagDisallowedScript,
},
{
name: "external href rejects before create",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><image href="https://example.com/a.png" x="0" y="0" width="10" height="10"/></svg>`,
wantMode: svgClassifyReject,
wantCode: svgDiagExternalReference,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := classifySVGlideSVGPage(tt.svg, "page.svg", 0)
if got.Mode != tt.wantMode {
t.Fatalf("mode = %s, want %s; diagnostics=%v", got.Mode, tt.wantMode, got.Diagnostics)
}
if tt.wantCode == "" {
return
}
if len(got.Diagnostics) == 0 || got.Diagnostics[0].Code != tt.wantCode {
t.Fatalf("diagnostics = %v, want first code %s", got.Diagnostics, tt.wantCode)
}
})
}
}
func TestBuildSVGFallbackImageOnlyPage(t *testing.T) {
t.Parallel()
source := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="20" y="40">fallback</text></svg>`
got, err := buildSVGFallbackImageOnlyPage(source, "boxcn_full_page")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, want := range []string{
`xmlns:slide="https://slides.bytedance.com/ns"`,
`slide:role="slide"`,
`slide:contract-version="svglide-authoring-contract/v1"`,
`viewBox="0 0 1280 720"`,
`<image slide:role="image" href="boxcn_full_page" x="0" y="0" width="1280" height="720" preserveAspectRatio="none"/>`,
} {
if !strings.Contains(got, want) {
t.Fatalf("image-only SVG missing %s: %s", want, got)
}
}
if err := validateSVGlideSVG(got, "fallback.svg"); err != nil {
t.Fatalf("image-only SVG should be native-valid: %v", err)
}
}
func TestEnsureSVGlideContractRootAttrsInjectsMissingVersion(t *testing.T) {
t.Parallel()
source := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`
got, err := ensureSVGlideContractRootAttrs(source)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(got, `slide:contract-version="svglide-authoring-contract/v1"`) {
t.Fatalf("contract version missing: %s", got)
}
if strings.Contains(got, `slide:contract-version="svglide-authoring-contract/v1" slide:contract-version`) {
t.Fatalf("contract version duplicated: %s", got)
}
}
func TestCommandSVGRasterizerUnavailableDiagnostic(t *testing.T) {
t.Parallel()
r := commandSVGRasterizer{
command: "missing-svglide-renderer",
lookPath: func(string) (string, error) {
return "", os.ErrNotExist
},
}
err := r.CheckAvailable(context.Background())
if err == nil {
t.Fatal("expected renderer unavailable error")
}
diags := svglideDiagnosticsFromError(err)
if len(diags) != 1 || diags[0].Code != svgDiagRendererUnavailable {
t.Fatalf("diagnostics = %v, want renderer_unavailable", diags)
}
}
func TestCommandSVGRasterizerArgvAndOutputSize(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "fake-resvg")
argvFile := filepath.Join(dir, "argv.txt")
if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ARGV_FILE\"\nprintf png > \"$2\"\n"), 0o755); err != nil {
t.Fatalf("write fake renderer: %v", err)
}
in := filepath.Join(dir, "page.svg")
if err := os.WriteFile(in, []byte(`<svg/>`), 0o644); err != nil {
t.Fatalf("write svg: %v", err)
}
r := commandSVGRasterizer{
command: script,
timeout: time.Second,
maxOutputSize: 20,
env: []string{"ARGV_FILE=" + argvFile},
}
out, size, err := r.Rasterize(context.Background(), in)
if err != nil {
t.Fatalf("unexpected rasterize error: %v", err)
}
if size != int64(len("png")) {
t.Fatalf("size = %d, want %d", size, len("png"))
}
if _, err := os.Stat(out); err != nil {
t.Fatalf("output file missing: %v", err)
}
argv, err := os.ReadFile(argvFile)
if err != nil {
t.Fatalf("read argv: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(argv)), "\n")
if len(lines) != 2 || lines[0] != in || lines[1] != out {
t.Fatalf("argv = %q, want input and output path", string(argv))
}
r.maxOutputSize = 2
_, _, err = r.Rasterize(context.Background(), in)
if err == nil {
t.Fatal("expected output-size validation error")
}
diags := svglideDiagnosticsFromError(err)
if len(diags) == 0 || diags[0].Code != svgDiagRasterOutputTooLarge {
t.Fatalf("diagnostics = %v, want raster_output_too_large", diags)
}
}
func TestValidateSVGlideSVGRecursiveChildren(t *testing.T) {
t.Parallel()
tests := []struct {
name string
svg string
wantErr string
}{
{
name: "supported shape rect",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`,
},
{
name: "supported text foreignObject",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
},
{
name: "supported server text foreignObject",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="text" x="0" y="0" width="200" height="80"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
},
{
name: "supported server line role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><line slide:role="line" x1="0" y1="0" x2="100" y2="60"/></svg>`,
},
{
name: "supported image href",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="boxcn_img" x="0" y="0" width="100" height="60"/></svg>`,
},
{
name: "supported image xlink href before rewrite",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" xlink:href="@./hero.png" x="0" y="0" width="100" height="60"/></svg>`,
},
{
name: "supported path commands",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M1e-3 0 L80 0 H120 V40 C120 60 100 80 80 80 Q40 80 20 40 Z" fill="#123456"/></svg>`,
},
{
name: "defs and metadata are ignored",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><defs><rect id="r"/></defs><metadata data-svglide-assets="true"><img src="boxcn_img"/></metadata><circle slide:role="shape" cx="50" cy="50" r="20"/></svg>`,
},
{
name: "group container with role-fixed child",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g fill="#112233" transform="translate(10 20)"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
},
{
name: "nested svg container with role-fixed child",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><svg viewBox="0 0 100 100"><circle slide:role="shape" cx="50" cy="50" r="20"/></svg></svg>`,
},
{
name: "group container ignores its own role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g slide:role="shape"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
},
{
name: "nested svg container ignores its own role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><svg slide:role="shape" viewBox="0 0 100 100"><circle slide:role="shape" cx="50" cy="50" r="20"/></svg></svg>`,
},
{
name: "style and nested defs are ignored",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><style>.primary{fill:#123456}</style><g><defs><linearGradient id="g"><stop offset="0%" stop-color="#fff"/><stop offset="100%" stop-color="#000"/></linearGradient></defs></g><rect slide:role="shape" class="primary" x="0" y="0" width="100" height="60" fill="url(#g)"/></svg>`,
},
{
name: "filter and shadow styles are preserved",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><style>.card{filter:drop-shadow(2px 4px 8px rgba(0,0,0,.2));box-shadow:0 8px 20px rgba(0,0,0,.18)}</style><g><defs><filter id="shadow"><feDropShadow dx="2" dy="3" stdDeviation="5" flood-color="#000" flood-opacity=".25"/></filter></defs></g><rect slide:role="shape" class="card" x="0" y="0" width="100" height="60" filter="url(#shadow)"/></svg>`,
},
{
name: "foreignObject XHTML subtree is not role-validated",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><div xmlns="http://www.w3.org/1999/xhtml"><span>hello</span></div></foreignObject></svg>`,
},
{
name: "foreignObject XHTML br is allowed",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><div xmlns="http://www.w3.org/1999/xhtml">hello<br />world</div></foreignObject></svg>`,
},
{
name: "namespaced root is rejected with precise message",
svg: `<svg:svg xmlns:svg="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg:svg>`,
wantErr: `root element must be non-namespaced <svg>`,
},
{
name: "root child missing role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<rect> must include slide:role="shape", "image", "line", or "text"`,
},
{
name: "group child missing role is rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g><rect x="0" y="0" width="100" height="60"/></g></svg>`,
wantErr: `<rect> must include slide:role="shape", "image", "line", or "text"`,
},
{
name: "unsupported text element remains rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text slide:role="shape" x="0" y="20">bad</text></svg>`,
wantErr: `<text slide:role="shape"> is not supported by SVGlide`,
},
{
name: "rect shape requires geometry",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" height="60"/></svg>`,
wantErr: `<rect slide:role="shape"> missing required attribute "width"`,
},
{
name: "path shape requires d",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" fill="#123456"/></svg>`,
wantErr: `<path slide:role="shape"> missing required attribute "d"`,
},
{
name: "rect rejects percent geometry",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="50%" height="60"/></svg>`,
wantErr: `attribute "width" must be a number or px length`,
},
{
name: "rect rejects calc geometry",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="calc(10px)" y="0" width="100" height="60"/></svg>`,
wantErr: `attribute "x" must be a number or px length`,
},
{
name: "container transform rejects percent argument",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g transform="translate(10% 20)"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
wantErr: `transform translate() argument must be a number or px length`,
},
{
name: "path rejects arc command",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M0 0 A10 10 0 0 1 20 20" fill="#123456"/></svg>`,
wantErr: `unsupported path command or character "A"`,
},
{
name: "path rejects smooth command",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M0 0 S10 10 20 20" fill="#123456"/></svg>`,
wantErr: `unsupported path command or character "S"`,
},
{
name: "plain metadata remains rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><metadata><desc>not transport metadata</desc></metadata></svg>`,
wantErr: `<metadata> must include slide:role="shape", "image", "line", or "text"`,
},
{
name: "foreignObject shape requires text type",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
wantErr: `<foreignObject slide:role="shape"> must include slide:shape-type="text"`,
},
{
name: "line role must be line tag",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="line" x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<rect slide:role="line"> is not supported`,
},
{
name: "text role must be foreignObject tag",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="text" x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<rect slide:role="text"> is not supported`,
},
{
name: "svg text role is not native yet",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text slide:role="text" x="0" y="20">later</text></svg>`,
wantErr: `<text slide:role="text"> is not supported`,
},
{
name: "image role must be image tag",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="image" href="boxcn_img"/></svg>`,
wantErr: `<rect slide:role="image"> is not supported`,
},
{
name: "image requires href",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<image slide:role="image"> must include href`,
},
{
name: "image requires geometry",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="boxcn_img" x="0" y="0" height="60"/></svg>`,
wantErr: `<image slide:role="image"> missing required attribute "width"`,
},
{
name: "image rejects external href",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="https://images.unsplash.com/photo.jpg" x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<image slide:role="image"> must not use external http(s) or data href`,
},
{
name: "unsupported role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="decor"/></svg>`,
wantErr: `unsupported slide:role="decor"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateSVGlideSVG(tt.svg, "page.svg")
if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want to contain %q", err.Error(), tt.wantErr)
}
})
}
}
func TestExtractSVGlideErrorJSON(t *testing.T) {
t.Parallel()
err := errors.New(`api error: SVGLIDE_ERROR_JSON:{"type":"svg_validation_error","page_index":0,"tag_name":"foreignObject","hint":"Use supported elements"}`)
got := extractSVGlideErrorJSON(err)
if got["type"] != "svg_validation_error" {
t.Fatalf("type = %v", got["type"])
}
if got["tag_name"] != "foreignObject" {
t.Fatalf("tag_name = %v", got["tag_name"])
}
suffix := formatSVGlideErrorSuffix(err)
for _, want := range []string{"svglide_error=", "svg_validation_error", "foreignObject"} {
if !strings.Contains(suffix, want) {
t.Fatalf("suffix = %q, want %q", suffix, want)
}
}
}

View File

@@ -15,7 +15,6 @@ metadata:
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|----------|----------|-----------------|
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| AI 生成 SVG 创建 PPT | 复用 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` 规划,生成 SVGlide SVG 后调用 `slides +create-svg` | `lark-slides-create-svg.md``svg-protocol.md` |
| 大幅改写页面 | 先回读现有 XML写入新 plan再替换或重建相关页面 | `xml_presentations.get``+replace-slide``lark-slides-edit-workflows.md` |
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide``lark-slides-replace-slide.md` |
| 读取或分析已有 PPT | 解析 slides/wiki token回读全文或单页 XML保存 `xml_presentation_id``slide_id``revision_id` | `xml_presentations.get``xml_presentation.slide.get` |
@@ -25,19 +24,15 @@ metadata:
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
**CRITICAL — 走 XML 创建/编辑路径时,生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。走 SVG 创建路径(`slides +create-svg`MUST 改读 [svg-protocol.md](references/svg-protocol.md),不要求读取 XML schema。**
**CRITICAL — 生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
**CRITICAL — 走 `slides +create-svg` 时,输入必须是 SVGlide SVGroot `<svg>` 声明 `xmlns:slide` 且 `slide:role="slide"`;可渲染 SVG 元素必须用 `slide:role="shape"` 或 `slide:role="image"` 表达;`g` / 嵌套 `svg` 可作为容器,但容器内实际渲染元素仍必须各自声明 role。CLI 只读取文件、上传/替换图片占位符、注入 transport metadata 和调用现有 `/slide` 路由,不会把普通 SVG 自动补齐成协议 SVG**
**CRITICAL — 高质量 SVG deck 生成时MUST 同时读取 [lark-slides-create-svg.md](references/lark-slides-create-svg.md):复用现有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` 作为设计状态,先做 deck-level density plan再定义布局盒给 `foreignObject` 文本留足安全高度,默认必须使用真实图片资产(本地 `@./path` 或 file token相邻页面要显著换版式调用 API 前必须跑本地 preflight优先使用 [`scripts/svg_preflight.py`](scripts/svg_preflight.py)live 创建后必须 readback 校验。这些是生成技巧,不替代 [svg-protocol.md](references/svg-protocol.md) 的硬协议约束。**
**CRITICAL — 新建演示文稿或大幅改写页面时MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML 或 SVGlide SVG。先创建对应目录规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
**CRITICAL — 新建演示文稿或大幅改写页面时MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免**
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)SVG 创建前的本地 preflight 优先使用 [`scripts/svg_preflight.py`](scripts/svg_preflight.py)**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
**CRITICAL — 创建前自检或失败排障时MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
@@ -82,7 +77,7 @@ lark-cli auth login --domain slides
按需再读:
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)SVG 创建:[`lark-slides-create-svg.md`](references/lark-slides-create-svg.md)、[`svg-protocol.md`](references/svg-protocol.md)
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
- 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
@@ -104,7 +99,7 @@ lark-cli auth login --domain slides
- **背景一致性**:先确定全 deck 的背景策略,默认保持同一明暗基调和底色体系;只有分节、转场或强调页才有意改变背景,并必须通过相同主色、纹理、边栏或 motif 让变化看起来属于同一套设计。无论深浅,都要保证正文、图标和线条对比充足。
- **统一 motif**:选择一个可复用视觉母题贯穿全文,例如粗侧边栏、圆形图标底、半出血图片区、编号节点、卡片左上角色块或大号数字。不要每页换一套装饰语言。
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。展示型、宣传型、产品型和案例型 deck 不能全程纯矢量,必须包含真实图片资产作为封面、半出血主视觉、案例场景、产品截图或材质背景。
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。
可优先考虑这些页面形态:
@@ -128,8 +123,7 @@ lark-cli auth login --domain slides
- 不要所有页面复用同一种标题 + 三 bullets 版式。
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
- 不要使用版权状态不明的图片、logo、截图或素材图片必须来自用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产,并在产物说明或素材清单中记录来源、授权/许可类型、原始 URL 和是否需要署名
- 不要把素材缺失表现为空白图片框;必须先尝试获取或生成可用图片资产。只有用户明确要求纯矢量、网络/权限不可用,或主题确实不适合图片时,才按 `fallback_if_missing` 生成 XML-native 视觉,并在结果中说明。
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
### 创建方式选择
@@ -138,7 +132,6 @@ lark-cli auth login --domain slides
|------|----------|
| 简单 XML1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
| 复杂 XML多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多 | **两步创建**:先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide create` 逐页添加 |
| AI 生成 SVGlide SVG希望减少 shell XML 转义、按文件逐页创建) | `slides +create-svg --file page1.svg --file page2.svg --title "<标题>"` |
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
> [!WARNING]
@@ -167,10 +160,10 @@ Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
Step 3: 按 slide_plan.json 生成 XML 或 SVGlide SVG → 创建
Step 3: 按 slide_plan.json 生成 XML → 创建
- 逐页消费 plankey_message 定主结论layout_type 定几何visual_focus 定主视觉text_density 定文本量
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
- XML 路径按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行SVG 路径按 lark-slides-create-svg.md 和 svg-protocol.md 执行,产物是 `.svg` 文件而不是 Slides XML仍复用同一个 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
Step 4: 审查 & 交付
- 创建完成后,必须用 xml_presentations.get 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
@@ -266,7 +259,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
| [`+create-svg`](references/lark-slides-create-svg.md) | 从一个或多个 SVGlide SVG 文件创建 PPT`--file` 顺序逐页调用现有 `/slide` 路由 |
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
@@ -280,20 +272,19 @@ lark-cli slides <resource> <method> [flags] # 调用 API
## 核心规则
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
2. **创建流程**:简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加AI SVG 路径使用 `slides +create-svg`,不要把 SVG 塞进 `--slides`
2. **创建流程**:简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
3. **`<slide>` 直接子元素只有 `<style>``<data>``<note>`**:文本和图形必须放在 `<data>`
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
5. **保存关键 ID**:后续操作需要 `xml_presentation_id``slide_id``revision_id`
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide``block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
8. **图片只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**XML 路径使用 `<img src="...">`SVG 路径使用 `<image slide:role="image" href="...">`。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传`+create --slides` / `+create-svg``@./path` 占位符自动上传 → 拿 `file_token` 写进图片引用」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src` / `href`。**图片最大 20 MB**slides upload API 不支持分片上传)。
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides``@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
## 权限速查
| 方法 | 所需 scope |
|------|-----------|
| `slides +create` | `slides:presentation:create`, `slides:presentation:write_only`(含 `@` 占位符时还需 `docs:document.media:upload` |
| `slides +create-svg` | `slides:presentation:create`, `slides:presentation:write_only`, `docs:document.media:upload` |
| `slides +media-upload` | `docs:document.media:upload`wiki URL 解析还需 `wiki:node:read` |
| `slides +replace-slide` | `slides:presentation:update`wiki URL 解析还需 `wiki:node:read` |
| `xml_presentations.get` | `slides:presentation:read` |
@@ -302,12 +293,4 @@ lark-cli slides <resource> <method> [flags] # 调用 API
| `xml_presentation.slide.get` | `slides:presentation:read` |
| `xml_presentation.slide.replace` | `slides:presentation:update` |
> **注意**XML 路径如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准SVG 路径以 [svg-protocol.md](references/svg-protocol.md) 为准。
## SVG 排障
`slides +create-svg` 失败时,优先查看错误中是否包含 `svglide_error` 或服务端 `SVGLIDE_ERROR_JSON:` marker。常见修复
- `svg_validation_error`:按 [svg-protocol.md](references/svg-protocol.md) 修正 root `<svg>``xmlns:slide``slide:role` 或不支持元素。
- 图片不显示:确认 `<image>` 使用 canonical `href="file_token"`,不要保留 `xlink:href`;本地图片用 `href="@./image.png"` 让 CLI 上传,或用 `--assets assets.json` 提供 token 映射。
- 有 file token 仍失败:确认 SVG 内存在 transport metadata`<metadata data-svglide-assets="true"><img src="同一个 file_token" /></metadata>``+create-svg` 会自动注入,手写 SVG 时不要删除。
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。

View File

@@ -1,450 +0,0 @@
# slides +create-svg
从一个或多个 SVGlide SVG 文件创建飞书幻灯片:
```bash
lark-cli slides +create-svg \
--as user \
--title "Demo" \
--file page1.svg \
--file page2.svg
```
## 适用场景
- AI 已经能生成符合 [svg-protocol.md](svg-protocol.md) 的 SVGlide SVG。
- 希望按文件逐页创建,避免把大段 XML/SVG 塞进 shell 参数。
- 需要 SVG 内本地图片占位符自动上传并替换为 file token。
不适用:
- 你只有普通 SVG且没有 `slide:role` 协议标记。
- 复杂普通 SVG 不能直接提交;需要把实际可渲染元素标成 SVGlide role。`g` / 嵌套 `svg` 容器可以保留,但不能代替子元素 role。
- 你需要插入到指定页前MVP 只创建新 presentation 并按顺序追加页面。
## Flags
| Flag | 说明 |
|------|------|
| `--title` | presentation 标题,省略时为 `Untitled` |
| `--file` | SVG 文件路径;可重复,页面顺序就是 flag 顺序 |
| `--assets` | 可选 `assets.json`,把 SVG `@path` 映射到已上传 file token |
| `--dry-run` | 展示创建空白 presentation + N 次 `/slide` 调用,不真实创建 |
## 请求链路
CLI 先创建空白 presentation
```http
POST /open-apis/slides_ai/v1/xml_presentations
```
随后对每个 SVG 文件调用现有 slide create 路由:
```http
POST /open-apis/slides_ai/v1/xml_presentations/{xml_presentation_id}/slide?revision_id=-1
```
body
```json
{
"slide": {
"content": "<svg ...>...</svg>"
}
}
```
不会新增 `/svg_slide` 路由,也不会把 `file_meta_map` 当成 CLI 到服务端的契约。
## 图片处理
SVG 内本地图片写成:
```xml
<image slide:role="image" href="@./hero.png" x="0" y="0" width="320" height="180" />
```
`<image>` 可以位于 `g` / 嵌套 `svg` 容器中CLI 会全局扫描 `<image href="@...">``<image xlink:href="@...">` 并替换为 canonical `href="file_token"`
CLI 会:
1. 上传本地图片到新 presentation。
2.`href="@./hero.png"``xlink:href="@./hero.png"` 替换为 canonical `href="file_token"`
3. 注入 transport metadata`<metadata data-svglide-assets="true"><img src="file_token" /></metadata>`
预上传资源可用 `--assets`
```json
{
"@./hero.png": "boxcn..."
}
```
## 生成质量规则
这些规则用于生成阶段主动规避服务端降级、近似和泛化错误。几何数值、path 命令、role/必填属性、图片 href 等基础约束已由 CLI 强校验;版式、美观和文本溢出仍需要生成器或人工复核。
### 与现有规划层对齐
SVG 创建不使用单独的规划目录。新建或大幅改写 SVG deck 时,仍然复用 [planning-layer.md](planning-layer.md) 规定的 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,不要另建 `.lark-slides/svg-plan` 或只保留散落的 `.svg` 文件。
在通用 plan 字段基础上SVG deck 还应补充这些 SVG 专属字段:
```json
{
"output_mode": "svglide-svg",
"canvas": {"width": 960, "height": 540, "viewBox": "0 0 960 540"},
"safe_area": {"x": 48, "y": 40, "width": 864, "height": 460},
"svg_constraints": {
"text_element": "foreignObject slide:role=shape slide:shape-type=text",
"path_commands": "M/L/H/V/C/Q/Z only",
"image_href": "@./path or file token only",
"css": "explicit font-size/font-weight/color/line-height/text-align; no font shorthand"
},
"svg_files": [
{"page": 1, "path": ".lark-slides/plan/<deck-id>/pages/page-001.svg"}
],
"preflight": {
"command": "python3 skills/lark-slides/scripts/svg_preflight.py --input .lark-slides/plan/<deck-id>/pages/page-001.svg",
"status": "pending"
},
"readback_verification": {
"status": "pending",
"checks": ["page_count", "blank_page", "canvas_bounds", "text_overlap", "asset_tokens", "closing_slide"]
}
}
```
模板也复用现有 `template_tool.py search -> summarize -> extract` 路由。模板摘要只用于选择主题、页面流、视觉节奏和布局骨架;生成 SVG 时要把模板结构翻译成 SVG layout boxes / visual recipes不要照搬模板 XML也不要读取完整模板 XML。
### 生成前强约束
以下规则来自实际 SVGlide live 生成、回读和修复经验,生成器必须先满足这些规则,再追求视觉复杂度。
- MUST: 默认使用 Lark Slides 当前回读画布 `960 x 540`,即 root 写成 `width="960" height="540" viewBox="0 0 960 540"`。不要默认用 `1280 x 720`,否则服务端回读后可能整页偏大并裁切。
- MUST: 主体元素使用安全区,建议 `safe = x:48 y:40 w:864 h:460`。除全屏背景外,文本、卡片、图表、标签、节点和图例都必须落在安全区内。
- MUST: 多页 deck 应包含明确的 closing slide。8 页以上讲解/汇报型 deck 不要把 roadmap / next-playbook 当作结束页;最后一页应包含 `closing``summary``Q&A``Thanks` 或下一步联系信息。
- MUST: `foreignObject` 文本样式使用显式 CSS`font-size``font-weight``font-family``color``line-height``text-align`。不要用 `font:` shorthand 表达关键字号和加粗。
- MUST: 提交前和 live 回读后都检查边界和重叠:非背景元素不得越过 `960 x 540`,第 2/3 页等信息密集页必须额外检查 text bbox overlap。
- SHOULD: 如果本地预览使用更大画布,例如 `1280 x 720`,必须在输出给 `slides +create-svg` 前按比例换算为 `960 x 540`,而不是只改 root viewBox。
### 生成器实现约束与 Preflight
生成器必须先把高概率错误拦在本地,再调用 `lark-cli`。不要依赖 live 创建后的人工修补来发现基础问题。
实现约束:
- MUST: SVG 生成 helper 的返回类型保持一致。推荐统一返回 `string`,或统一返回 `string[]` 后在页面末尾 `flat().filter(Boolean).join("\n")`;不要混用 `...items.map(...).join("\n")`,这会把已拼好的 SVG 标签按字符展开,生成非法 XML。
- MUST: 所有组件都从稳定布局盒推导坐标,避免散点手调。文本、标签、图例、曲线端点和卡片内容应有明确的父盒和对齐规则。
- MUST: 生成脚本要先写 deck plan / asset list再写页面不能边补坐标边生成最终 SVG。
- SHOULD: 对高风险页面使用更保守的留白:标题与图表标签至少相隔 24px曲线端点标签不要压在标题/图例区域,卡片内文字与边框至少留 10-14px。
- SHOULD: 把每页的 `safe``titleBox``visualBox``textBox` 等布局盒保存为可检查数据,便于自动计算越界和重叠。
本地 preflight 必须在 `slides +create-svg` 前执行,失败即停:
- `python3 skills/lark-slides/scripts/svg_preflight.py --input page-*.svg` 通过;如果脚本不可用,再退回 `xmllint --noout page-*.svg` 加人工检查。
- root 是 `width="960" height="540" viewBox="0 0 960 540"`
- root / leaf `slide:role` 完整,所有 leaf 有几何必填属性。
- 禁止 `font:` shorthand、http(s) / data URL 图片、未下载的远程图片、空图片框。
- 禁止 unsupported path command`path d` 只含 `M/L/H/V/C/Q/Z`
- 非背景元素不得越界;主体元素应在 safe area 内。
- 文本框做 bbox overlap 近似检查,尤其是目录、痛点、竞品表、案例图表和总结页。
- 图片资产文件存在、大小合理、授权来源清单完整。
创建顺序:
```text
generate deck plan -> generate assets -> generate SVG files
-> local preflight -> lark-cli slides +create-svg --dry-run
-> live create -> xml_presentations get readback
-> readback bbox / text overlap / closing slide checks
```
readback 不能省略。服务端会把 SVGlide 转成 Slides XML文字 bbox、path bounds 和图片 token 可能和本地 SVG 预估不同;本地 preflight 负责拦住确定错误readback 负责发现转换后的版式漂移。
### Deck 级密度规划
生成多页 SVG deck 前,先写 deck-level plan。页面类型只定义叙事职责密度由 `deck_type`、受众、页面目的和节奏共同决定,不要把某个 page type 永久绑定为固定密度。
最小 plan schema
```json
{
"deck_type": "explain | decision | product | brand | technical | education | report",
"audience": "who will read it",
"goal": "what the deck should make the audience understand or decide",
"density_strategy": "how low/medium/high density pages are distributed",
"asset_strategy": "which real images are needed, where they will be used, copyright/license source, and fallback if unavailable",
"visual_rhythm": "how layout, imagery, charts, and text density vary across pages",
"slides": [
{
"page": 1,
"page_type": "cover",
"density": "low",
"density_mode": "visual-dense",
"takeaway": "one sentence the audience should remember",
"evidence": [],
"visual_structure": "full-bleed image with title overlay",
"layout_guardrails": ["large hero title", "no dense body copy"]
}
]
}
```
常用 `page_type`
```text
cover, opener, agenda, section-divider, context, problem, opportunity,
executive-summary, content, data, comparison, process, case-study, demo,
architecture, system, roadmap, timeline, decision, recommendation,
risk, tradeoff, summary, closing, q-and-a, appendix
```
密度规则:
- MUST: 每页都要有明确 `takeaway`,即使是封面、分隔页和结束页。
- MUST: 每个 SVG deck 默认都要包含真实图片资产,不要全程只用矢量 shape 冒充“配图”。展示型、宣传型、产品型、品牌型和案例型 deck 至少包含 3 处图片使用,其中至少 1 页使用全幅或半出血图片主视觉。
- MUST: 高密度页必须有承载信息的视觉结构,例如矩阵、流程、地图、时间线、标注图、案例卡或手绘微图表,不能只有装饰图形。
- SHOULD: 高密度内容页通常包含 3-6 个信息块和若干可读细节,但 executive brief、品牌页、产品视觉页、短汇报可以降低数量只保留强结论、关键证据和视觉锚点。
- SHOULD NOT: 不要让所有高密度页长成同一种“主结论 + 3-6 卡片 + 3 个 callout”模板。
- MUST NOT: 缺少素材或数据时不要编造数字、客户名、logo、排名、引用或真实案例用 qualitative label、relative scale、hypothesis/assumption 标注兜底。
### 结构示例
8-10 页讲解型 deck 可参考这个节奏,但不要把它当成唯一模板;如果 deck 已经包含 roadmap / playbook仍建议再补一页 closing
```text
cover -> opener/context -> agenda/map -> content -> data/comparison
-> process/system breakdown -> case-study/demo -> content/implications
-> summary -> closing
```
5 页决策汇报优先前置结论:
```text
cover -> executive-summary -> options/comparison -> recommendation/risk -> next steps
```
6 页产品/品牌 deck 可以强化视觉叙事:
```text
cover -> value proposition -> user scenario -> feature map/demo
-> proof/roadmap -> closing
```
边界处理:
- 3-5 页短 deck 可以省略 agenda把 summary 并入 closing。
- 15 页以上长 deck 应增加 section-divider 或 recap避免连续高密度阅读疲劳。
- 技术方案要混合 architecture、process、tradeoff、risk不要连续堆文字。
- 教学讲解要前置 context / concept map逐步增加密度。
- 素材不足时用抽象视觉系统、定性矩阵、annotated wireframe、scenario card 兜底,并标明假设。
### 先定义布局盒
不要直接手写散点坐标。每页先定义稳定布局盒,再把文字、图形、图例和图片放进盒内:
```text
page = 960 x 540
safe = x:48 y:40 w:864 h:460
titleBox = x:54 y:52 w:600 h:96
visualBox = x:516 y:176 w:350 h:260
notesGrid = x:54 y:430 w:760 h:48
```
生成后检查:
- 关键元素必须在 safe area 内。
- 同组元素使用同一个父盒推导坐标。
- 图例、标签、指标不能浮在不上不下的位置,必须相对主视觉左/右/下边对齐。
- 如果页面有圆、节点、卡片或框体,内容中心应和外框中心基本一致,不靠手调 `x + 10``y + 10` 维持观感。
- 不要把 1280x720 的坐标直接提交给 `slides +create-svg`。当前服务端回读画布通常是 960x540错误坐标系会表现为每页偏大、右侧卡片裁切、底部标签越界。
### 文本安全余量
`foreignObject` 文本优先使用显式 CSS。为了服务端转换到 SXSD/XML 后保留样式,字号、加粗、颜色、行距和对齐必须写成独立属性;不要把关键样式藏在 `font:` shorthand 或只写在复杂外层 wrapper 上:
```xml
<foreignObject slide:role="shape" slide:shape-type="text" x="54" y="62" width="600" height="42">
<div xmlns="http://www.w3.org/1999/xhtml"
style="margin:0;padding:0;font-size:30px;font-weight:900;font-family:Arial,'Source Han Sans SC';color:#111827;line-height:1.12;text-align:left;letter-spacing:0;">
关键结论:增长来自三件事
</div>
</foreignObject>
```
中文和混排字体要留安全高度:
- subtitle 不小于 64px。
- note / chip 单行文本盒不小于 20px。
- 小型标签文本盒不小于 14px。
- 多行文字要按行高预估高度,再额外留 8-12px。
- 右侧图例或矩阵格里的文字不得贴边,水平 padding 至少 10-14px。
- 服务端支持 `foreignObject` 内的 `<br />`。为了本地预览和标题排版稳定,标题/大段文本优先使用多个块级 `div``p` 控制行高,不要只靠 `<br />` 调整复杂布局。
- 如果需要垂直居中,优先通过更准确的文本框高度、段落行高和 y 坐标解决;布局 wrapper 可以使用,但实际文字节点仍要带显式 `font-size` / `font-weight` / `color`
### 几何与 path 安全线
leaf 几何属性必须写数字或 `px`,不要生成百分比、`em/rem``calc(...)`
```xml
<rect slide:role="shape" x="80" y="96" width="420px" height="240px" />
```
`path d` 只生成 `M/L/H/V/C/Q/Z` 命令。不要生成 `A``S``T` 等命令;需要圆角或弧线时,用 `C` / `Q` 近似,或改用 `circle` / `ellipse` / `rect`
Transform 参数同样使用数字或 `px`。不要写 `translate(10%, 20%)`,先在布局盒里换算成绝对坐标。
### 版式节奏
同一 deck 不能连续复用同一种“暗色网格 + 左文案 + 右卡片 + 底部 chips”。10 页左右的讲解型 deck 至少混用这些结构:
- 封面 / 全幅图片背景页。
- 目录矩阵页或行业地图页。
- 左文右图 / 左图右文双栏页。
- 全幅路线图或时间线页。
- 2x2 / 2x4 总结矩阵页。
- 数据仪表页、流程页、对比页或案例页。
相邻页面至少改变一个主结构维度:主视觉位置、网格列数、图片用法、文本密度或阅读方向。
### 图片使用
默认必须规划和使用图片资产,并规避版权风险。图片必须来自用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产不要使用版权状态不明的图片、logo、截图、新闻配图、竞品官网图或搜索引擎随手抓取的素材。正确流程是先下载或生成到本地再写成本地占位符
```xml
<image slide:role="image" href="@./assets/hero.jpg" x="0" y="0" width="960" height="540" />
```
图片不只用于局部卡片背景,也可以作为整页背景、半出血主视觉、材质纹理、案例示例、产品截图、数据仪表截图或图鉴封面。作为整页背景时,必须叠加半透明遮罩或暗角,保证标题和正文对比度。
图片数量与用法建议:
- MUST: 在 `asset_strategy` 或产物 README 中记录图片来源、授权/许可类型、下载 URL 或生成方式;无法确认授权时不得使用。
- MUST: 5 页以上 deck 至少使用 1 张真实图片8 页以上 deck 至少使用 2 张;宣传/产品/品牌/案例型 deck 至少使用 3 张。
- MUST: 封面优先使用图片或图片+抽象图形混合主视觉,不要只用网格、光效和几何背景。
- MUST: 案例页优先使用行业场景图、产品截图、仪表盘截图或真实质感背景,并叠加数据 callout。
- SHOULD: 同一 deck 中混用全幅背景、半出血图片、卡片图、纹理/材质背景和标注型截图,避免所有图片都只是小卡片背景。
- MUST NOT: 保留空图片框、破图、http(s) 外链或 data URL。素材不可用时要重新获取/生成,或在最终说明中明确为什么退回矢量。
优先使用这些来源,但每张图仍必须检查并记录具体页面上的授权信息:
| Source | 适合用途 | 规则 |
|--------|----------|------|
| Unsplash | 高质量摄影、封面背景、场景图 | 可商用图库;记录图片页 URL 和 license |
| Pexels | 商务、科技、生活类配图 | 可商用图库;记录图片页 URL 和 license |
| Pixabay | 图片、插画、视频、音频 | 可商用图库;避开人物/品牌/商标误导 |
| Openverse | CC / Public Domain 搜索 | 每张图 license 不同;按单图要求署名 |
| Wikimedia Commons | 百科、历史、技术、公共领域素材 | 每张图 license 不同;常见需要署名 |
| The Met Open Access | 艺术品、历史图像、文化视觉 | 仅使用 Open Access / CC0 条目 |
| Smithsonian Open Access | 博物馆、科学、历史、2D/3D 资产 | 仅使用 Open Access / CC0 条目 |
| NASA Image and Video Library | 太空、科技、地球、航天视觉 | 避开 NASA 标识商业背书、人物肖像和第三方权利 |
素材清单建议字段:
```json
{
"local_path": "./assets/hero.jpg",
"source": "Unsplash",
"source_url": "https://...",
"license": "Unsplash License",
"commercial_use": true,
"attribution_required": false,
"notes": "No recognizable trademark or misleading endorsement"
}
```
### 信息密度与图鉴感
短 note 不要占一个很宽胶囊。优先写成“编号/标签 + 主句 + 微解释/数值”:
```text
03 GRID ENERGY 86% | storage demand peaks before grid balancing
```
内容页可以用三种方式提高密度,不要把高密度等同于堆文字:
- `text-dense`: 多解释、多证据、多注释,适合背景分析和概念讲解。
- `chart-dense`: SVG shape 手绘矩阵、流程、时间线、微柱状、雷达、散点、标尺;不要默认依赖 Slides 原生 chart也不要把外部图表截图当成唯一方案。
- `visual-dense`: 高级视觉图案或图片上叠加标注层、数据 callout、局部标签、对比线和图例。
视觉区要补足可读细节,避免只有装饰符号:
- 局部标注、刻度、坐标轴、图例。
- 行业标签、材料纹理、指标卡。
- 路线节点、连接线、层级分区。
- 小型表格、雷达/柱状/散点等微图表。
### 生成后检查
生成脚本或人工复核必须检查:
- 是否已执行本地 preflight且所有 SVG 通过 XML、协议、资产、bbox 和文本重叠检查。
- 是否已执行 `slides +create-svg --dry-run`,确认请求链路是创建 presentation + 按页追加 SVG。
- live 创建后是否已用 `xml_presentations get` 读回,重新检查画布、页数、越界、文本重叠和 closing slide。
- root / leaf role 是否完整。
- 每个 leaf 是否有 [svg-protocol.md](svg-protocol.md) 中列出的几何必填属性。
- 几何属性和 transform 参数是否只使用数字或 `px`
- `path d` 是否只包含 `M/L/H/V/C/Q/Z`
- 文本是否截断、重叠或贴边。
- 内容是否在 safe area 内,关键图例和外框是否对齐。
- 相邻页面是否明显换版式。
- 每页是否有明确 takeaway高密度页的视觉结构是否承载信息而不只是装饰。
- 内容页是否避免了“大标题 + 大图 + 2-3 个短 chip”的低信息布局。
- 自称数据、排名、客户、引用、logo 或案例时,是否有来源;没有来源时是否改为定性或假设表达。
- 图片是否已变成本地 `@./path` 或 file token不能保留 http(s) / data URL。
验证记录建议写回 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json``readback_verification` 字段,并在最终回复中简述:
```text
验证记录:
- PreflightN/N SVG 通过 root/role/geometry/path/image/bbox 检查。
- Dry-run已确认 create presentation + N 次 /slide。
- Readback实际页数 N / 预期 N未发现空白页、破图或缺失 closing slide。
- 版式:检查 safe area、文本重叠、越界和相邻页版式变化。
- 资产:图片均为本地 @path 自动上传或 file token无 http(s)/data URL。
```
## 错误处理
任一页失败时,错误会包含:
- `xml_presentation_id`
- 失败页序号
- 已成功页数
- 已创建的 `slide_ids`
如果服务端 detail 带有 `SVGLIDE_ERROR_JSON:` markerCLI 会提取并在错误中展示 `svglide_error`,用于定位 `type``page_index``tag_name``element_id``role``hint`
失败后不要假设没有创建任何资源。先把恢复状态写回 plan 的 `recovery` 字段:
```json
{
"xml_presentation_id": "slides...",
"failed_page": 3,
"failed_svg_file": ".lark-slides/plan/<deck-id>/pages/page-003.svg",
"successful_slide_ids": ["abc", "def"],
"svglide_error": {"type": "svg_validation_error", "tag_name": "foreignObject"},
"next_action": "fix source SVG and rerun preflight before retry"
}
```
恢复顺序:
1. 本地 preflight 已失败:修对应 SVG 文件,不要调用 live API。
2. live 添加页失败且带 `svglide_error`:按 `type` / `tag_name` / `hint` 收敛 SVG 子集,例如降级复杂 filter、path、CSS 或文本结构。
3. plain XML 在同一路由成功但 SVG 失败:优先确认目标 server lane 是否部署了 SVGlide parser不要盲目重写整套 deck。
4. 已创建 presentation 或部分页面时,默认保留现场并回读确认;是否删除空 presentation 必须单独由用户确认。
### 编辑已创建的 SVG deck
SVG deck 后续编辑走双轨,不承诺 source SVG id 能稳定映射到 readback XML block id
| 修改类型 | 推荐路径 | 说明 |
|----------|----------|------|
| 小改标题、文本、图片或坐标 | `xml_presentation.slide.get` 读回 XML -> 找当前 block_id -> `slides +replace-slide` | 使用转换后的 XML 做块级编辑,页序和 slide_id 不变 |
| 大幅换版式、重画图表、调整视觉系统 | 修改 source SVG -> 重新 preflight -> 重新创建或替换目标页 | 保持 SVG 的视觉表达优势,避免在转换后 XML 上手搓复杂 SVG 结构 |
| 无法定位 block_id 或映射不可信 | 回 source SVG 修改 | 不生成 `edit-map.json`,除非服务端或转换结果能证明 source id 可稳定保留 |
小改前必须重新 `slide.get` 拿最新 block id 和 revision大改后必须更新同一个 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,保持 plan、SVG 文件、创建结果和验证记录一致。

View File

@@ -1,114 +0,0 @@
# SVGlide SVG Protocol
最小模板:
```xml
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960"
height="540"
viewBox="0 0 960 540"
>
<rect slide:role="shape" x="60" y="60" width="240" height="135" fill="#E8EEF8" />
<foreignObject slide:role="shape" slide:shape-type="text" x="90" y="98" width="240" height="60">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:20px;font-weight:700;color:#1F2937;line-height:1.2">SVGlide</div>
</foreignObject>
<image slide:role="image" href="@./hero.png" x="390" y="60" width="240" height="135" />
</svg>
```
## 必须满足
- root 必须是非 namespaced 的 `<svg>`,不能是 `<svg:svg>`
- root 必须声明 `xmlns:slide="https://slides.bytedance.com/ns"`
- root 必须包含 `slide:role="slide"`
- 可渲染元素必须有对应 `slide:role`shape 使用 `slide:role="shape"`,图片使用 `slide:role="image"`
- `<g>` 和嵌套 `<svg>` 可以作为容器,用于继承样式和 transform容器内真正渲染的元素仍必须声明 `slide:role`
- `slide:role="shape"` 目前只支持 `rect``ellipse``circle``line``path``foreignObject`
- 文本使用文本型 shape`<foreignObject slide:role="shape" slide:shape-type="text">...</foreignObject>`
- 图片使用 `<image slide:role="image" href="file_token">`;本地占位符写成 `href="@./image.png"`
- `<defs>``<style>` 会被服务端解析/跳过输出;支持嵌套在 `g` / 嵌套 `svg` 容器中。
- CLI 注入的 transport metadata `<metadata data-svglide-assets="true">` 会被跳过输出但用于传输图片元数据。
## 坐标系与画布
- 当前 `slides +create-svg` 新建的 Lark Slides presentation 回读画布通常是 `960 x 540`。生成 SVG deck 时默认使用 `width="960" height="540" viewBox="0 0 960 540"`,不要默认用 `1280 x 720`
- 服务端不会保证把 `viewBox="0 0 1280 720"` 自动缩放到 `960 x 540`。如果用 1280x720 设计,必须在提交前整体换算到目标画布,或在回读 XML 后验证没有越界。
- 生成时为所有主体元素预留安全区,建议 `x >= 48``y >= 40``right <= 912``bottom <= 500`。全屏背景可以铺满 `0,0,960,540`,但主体文字、图表和卡片仍应留在安全区内。
- 回读 XML 后必须检查主体元素边界:非背景元素的 `topLeftX + width <= 960``topLeftY + height <= 540`。任何页面越界都视为生成失败,需要重排或缩放后重建。
## 几何必填属性
SVGlide leaf shape 必须显式写出服务端建模所需的几何属性,不依赖 SVG 默认值。缺失这些属性通常会被服务端包装成 `shape missing required attribute` 或 generic invalid param。
| Element | Required attributes |
|---------|---------------------|
| `rect slide:role="shape"` | `x`, `y`, `width`, `height` |
| `foreignObject slide:role="shape" slide:shape-type="text"` | `x`, `y`, `width`, `height` |
| `image slide:role="image"` | `href`, `x`, `y`, `width`, `height` |
| `circle slide:role="shape"` | `cx`, `cy`, `r` |
| `ellipse slide:role="shape"` | `cx`, `cy`, `rx`, `ry` |
| `line slide:role="shape"` | `x1`, `y1`, `x2`, `y2` |
| `path slide:role="shape"` | `d` |
这些属性即使取值为 `0` 也要写出来。例如背景图必须写成:
```xml
<image slide:role="image" href="@./background.jpg" x="0" y="0" width="960" height="540" />
```
CLI 会把这些几何属性作为生成质量门禁:值只能是数字或 `px` 长度,例如 `0``1280``320.5``80px`。不要使用 `%``em``rem``calc(...)` 或省略单位后依赖 SVG 默认值。服务端可能会对部分非法几何值降级为 `0` 并给 warning但正式生成应在 CLI 侧提前修正。
## 当前支持的 SVG 子集
- Shape: `rect``ellipse``circle``line``path``foreignObject`
- Container: `g`、嵌套 `svg`
- Definitions: `defs` 内的 `linearGradient``radialGradient``filter/feDropShadow`;支持嵌套 `defs` 和 gradient `href` / `xlink:href` 继承。
- CSS: tag、`.class``#id``.a.b``tag.class` 等简单 selector支持 specificity 和 source order复杂 selector、media query、伪类会被忽略。
- Paint: `fill``stroke``stroke-width``opacity``fill-opacity``stroke-opacity``stroke-dasharray``stroke-linecap``stroke-linejoin`
- Gradient: `stop-color` / `stop-opacity` 可来自属性、inline style 或 CSS`gradientTransform``spreadMethod`、focal 点等复杂能力会被近似或忽略。
- Effects: 支持 `filter="url(#...)"` 指向的 `feDropShadow`、CSS `filter: drop-shadow(...)`、以及首层 `box-shadow`;多层 shadow、spread、inset 会被近似或忽略。
- Transform: `translate``scale``matrix``rotate`transform 参数应写数字或 `px`,复杂 transform 会被近似或忽略。
- Path: 只使用 `M/L/H/V/C/Q/Z`CLI 会拒绝 arc `A`、smooth curve `S/T` 和其他未知命令。
- Text: `foreignObject slide:shape-type="text"` 内支持常见 XHTML 文本标签、`br` 和基础文字样式。
文本样式应使用 parser 友好的显式 CSS 属性,例如 `font-size``font-weight``font-family``color``line-height``text-align``letter-spacing`。不要依赖 `font:` shorthand、复杂 flex 布局或浏览器默认样式来表达关键字号、加粗和行距;这些在转换到 SXSD/XML 时可能降级为默认样式。
## 不支持
- 不要把普通 SVG 直接交给 `+create-svg`CLI 不会自动补齐 SVGlide 协议。
- 不支持缺少 role 的可渲染元素,例如 `<rect .../>`;必须写成 `<rect slide:role="shape" .../>`
- 不要把 `<g>` 当作可渲染 shape`<g>` 只是容器,实际 `rect``path``foreignObject``image` 等子元素仍需各自声明 `slide:role`
- 不支持根级 `<text slide:role="text">`;用 `foreignObject + slide:shape-type="text"`
- 不要在 `<image>` 上保留 `xlink:href`CLI 会统一输出 canonical `href`
- 不要用 http(s) 或 data URL 外链图片;先下载到本地并让 CLI 上传,或用 `--assets` 提供已上传 file token。
- `slides +create-svg` MVP 不支持指定 `beforeSlideBlockID` 插入到某一页前;它创建新 presentation 后按 `--file` 顺序追加。
这些能力依赖 slide server SVGlide parser 新版本。如果 BOE/线上未部署对应 server 分支CLI 放行后仍可能收到服务端 `SVGLIDE_ERROR_JSON` 或 generic invalid param。
## 图片与 Metadata
SVG deck 默认应使用真实图片资产,不要为了规避上传链路而全程用纯矢量 shape 冒充配图。宣传、产品、品牌、案例和视觉展示型 deck 至少应包含封面/半出血主视觉/案例场景/产品截图等图片使用;只有用户明确要求纯矢量,或图片获取、上传链路不可用时,才退回纯矢量方案,并在结果中说明原因。
图片资产还必须规避版权风险:只使用用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产。推荐来源包括 Unsplash、Pexels、Pixabay、Openverse、Wikimedia Commons、The Met Open Access、Smithsonian Open Access 和 NASA Image and Video Library但每张图都必须检查具体 license。不要使用版权状态不明的搜索图片、新闻配图、第三方 logo、竞品官网截图或素材站预览图。生成 deck 时应在素材清单或 README 中记录图片来源、授权/许可类型、下载 URL 或生成方式、是否需要署名;无法确认授权时不得使用该图片。
`slides +create-svg` 会把 `<image href="@./image.png">` 上传为 file token并注入
```xml
<metadata data-svglide-assets="true">
<img src="boxcn..." />
</metadata>
```
metadata 只用于让现有服务端链路生成 `FileMetaMap`。如果使用 `--assets assets.json` 传入预上传 tokenCLI 也会按同样规则替换和注入。
`assets.json` 格式:
```json
{
"@./image.png": "boxcn...",
"./other.png": "boxcn..."
}
```

View File

@@ -1,486 +0,0 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
SLIDE_NS = "https://slides.bytedance.com/ns"
XLINK_NS = "http://www.w3.org/1999/xlink"
SVG_NS = "http://www.w3.org/2000/svg"
CANVAS_WIDTH = 960.0
CANVAS_HEIGHT = 540.0
SAFE_AREA = {"x": 48.0, "y": 40.0, "width": 864.0, "height": 460.0}
NUMBER_RE = re.compile(r"^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?(?:px)?$")
PATH_NUMBER_RE = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?")
FONT_SHORTHAND_RE = re.compile(r"(^|;)\s*font\s*:", re.IGNORECASE)
SUPPORTED_SHAPES = {"rect", "ellipse", "circle", "line", "path", "foreignObject"}
RENDERABLE_TAGS = SUPPORTED_SHAPES | {"image", "text", "polygon", "polyline"}
IGNORED_SUBTREES = {"defs", "style"}
class SvgPreflightError(Exception):
pass
def fail(message: str) -> None:
raise SvgPreflightError(message)
def parse_args(argv: list[str]) -> dict[str, Any]:
inputs: list[str] = []
index = 0
while index < len(argv):
token = argv[index]
if token in {"--input", "-i"}:
if index + 1 >= len(argv):
fail(f"{token} requires a file path")
inputs.append(argv[index + 1])
index += 2
continue
if token.startswith("--"):
fail(f"unexpected argument: {token}")
inputs.append(token)
index += 1
if not inputs:
fail("at least one --input <svg-file> is required")
return {"inputs": inputs}
def local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] if tag.startswith("{") else tag
def get_attr(element: ET.Element, name: str, namespace: str | None = None) -> str | None:
if namespace:
value = element.attrib.get(f"{{{namespace}}}{name}")
if value is not None:
return value
value = element.attrib.get(name)
if value is not None:
return value
for key, candidate in element.attrib.items():
if key.endswith("}" + name):
return candidate
return None
def svg_role(element: ET.Element) -> str | None:
return get_attr(element, "role", SLIDE_NS)
def svg_shape_type(element: ET.Element) -> str | None:
return get_attr(element, "shape-type", SLIDE_NS)
def parse_number(value: str | None) -> float | None:
if value is None:
return None
value = value.strip()
if not NUMBER_RE.match(value):
return None
if value.lower().endswith("px"):
value = value[:-2]
try:
return float(value)
except ValueError:
return None
def parse_required_number(element: ET.Element, name: str) -> float | None:
return parse_number(get_attr(element, name))
def issue(level: str, code: str, message: str, element: ET.Element | None = None, hint: str | None = None) -> dict[str, Any]:
out: dict[str, Any] = {"level": level, "code": code, "message": message}
if element is not None:
elem_id = get_attr(element, "id")
if elem_id:
out["element_id"] = elem_id
out["tag"] = local_name(element.tag)
if hint:
out["hint"] = hint
return out
def parse_viewbox(value: str | None) -> list[float] | None:
if value is None:
return None
parts = [part for part in re.split(r"[\s,]+", value.strip()) if part]
if len(parts) != 4:
return None
try:
return [float(part) for part in parts]
except ValueError:
return None
def is_external_href(value: str | None) -> bool:
if value is None:
return False
lower = value.strip().lower()
return lower.startswith("http://") or lower.startswith("https://") or lower.startswith("data:")
def href_value(element: ET.Element) -> str | None:
return get_attr(element, "href") or get_attr(element, "href", XLINK_NS)
def walk_renderable(root: ET.Element) -> list[ET.Element]:
out: list[ET.Element] = []
def walk(element: ET.Element) -> None:
name = local_name(element.tag)
if name in IGNORED_SUBTREES:
return
if name in RENDERABLE_TAGS or name == "foreignObject" or name == "image":
out.append(element)
for child in list(element):
walk(child)
for child in list(root):
walk(child)
return out
def validate_root(root: ET.Element) -> tuple[list[dict[str, Any]], float, float]:
issues: list[dict[str, Any]] = []
if local_name(root.tag) != "svg":
issues.append(issue("error", "root_not_svg", "root element must be <svg>"))
if svg_role(root) != "slide":
issues.append(issue("error", "missing_root_role", 'root <svg> must include slide:role="slide"', root))
width = parse_number(get_attr(root, "width"))
height = parse_number(get_attr(root, "height"))
viewbox = parse_viewbox(get_attr(root, "viewBox"))
if width != CANVAS_WIDTH or height != CANVAS_HEIGHT:
issues.append(
issue(
"error",
"root_canvas_mismatch",
'root must use width="960" height="540"',
root,
"Scale coordinates to the Lark Slides 960x540 canvas before calling slides +create-svg.",
)
)
if viewbox != [0.0, 0.0, CANVAS_WIDTH, CANVAS_HEIGHT]:
issues.append(
issue(
"error",
"root_viewbox_mismatch",
'root must use viewBox="0 0 960 540"',
root,
"Do not submit a 1280x720 viewBox and rely on server-side scaling.",
)
)
return issues, width or CANVAS_WIDTH, height or CANVAS_HEIGHT
def validate_roles_and_attrs(elements: list[ET.Element]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for element in elements:
name = local_name(element.tag)
role = svg_role(element)
if name == "text":
issues.append(
issue(
"error",
"unsupported_text_element",
'root-level <text> is not supported; use foreignObject slide:role="shape" slide:shape-type="text"',
element,
)
)
continue
if name in {"polygon", "polyline"}:
issues.append(
issue(
"error",
"unsupported_shape_element",
f"<{name}> is not supported by SVGlide MVP",
element,
"Use path with M/L/H/V/C/Q/Z commands, or use rect/line/circle/ellipse.",
)
)
continue
if name not in {"image"} | SUPPORTED_SHAPES:
continue
if role is None:
issues.append(issue("error", "missing_leaf_role", '<%s> must include slide:role="shape" or "image"' % name, element))
continue
if role == "shape":
if name not in SUPPORTED_SHAPES:
issues.append(issue("error", "unsupported_shape_role", f'<{name} slide:role="shape"> is not supported', element))
continue
if name == "foreignObject" and svg_shape_type(element) != "text":
issues.append(
issue(
"error",
"missing_text_shape_type",
'<foreignObject slide:role="shape"> must include slide:shape-type="text"',
element,
)
)
elif role == "image":
if name != "image":
issues.append(issue("error", "unsupported_image_role", f'<{name} slide:role="image"> is not supported', element))
image_href = href_value(element)
if not image_href:
issues.append(issue("error", "missing_image_href", '<image slide:role="image"> must include href', element))
if is_external_href(image_href):
issues.append(
issue(
"error",
"external_image_href",
"<image> must not use http(s) or data href",
element,
'Download or generate the image locally and use href="@./path", or provide a file token.',
)
)
else:
issues.append(issue("error", "unsupported_role", f'unsupported slide:role="{role}"', element))
return issues
def bbox_for_element(element: ET.Element) -> dict[str, float] | None:
name = local_name(element.tag)
if name in {"rect", "foreignObject", "image"}:
x = parse_required_number(element, "x")
y = parse_required_number(element, "y")
width = parse_required_number(element, "width")
height = parse_required_number(element, "height")
if None in {x, y, width, height}:
return None
return {"x": x or 0.0, "y": y or 0.0, "width": width or 0.0, "height": height or 0.0}
if name == "circle":
cx = parse_required_number(element, "cx")
cy = parse_required_number(element, "cy")
r = parse_required_number(element, "r")
if None in {cx, cy, r}:
return None
return {"x": (cx or 0.0) - (r or 0.0), "y": (cy or 0.0) - (r or 0.0), "width": 2 * (r or 0.0), "height": 2 * (r or 0.0)}
if name == "ellipse":
cx = parse_required_number(element, "cx")
cy = parse_required_number(element, "cy")
rx = parse_required_number(element, "rx")
ry = parse_required_number(element, "ry")
if None in {cx, cy, rx, ry}:
return None
return {"x": (cx or 0.0) - (rx or 0.0), "y": (cy or 0.0) - (ry or 0.0), "width": 2 * (rx or 0.0), "height": 2 * (ry or 0.0)}
if name == "line":
x1 = parse_required_number(element, "x1")
y1 = parse_required_number(element, "y1")
x2 = parse_required_number(element, "x2")
y2 = parse_required_number(element, "y2")
if None in {x1, y1, x2, y2}:
return None
min_x = min(x1 or 0.0, x2 or 0.0)
min_y = min(y1 or 0.0, y2 or 0.0)
return {"x": min_x, "y": min_y, "width": abs((x2 or 0.0) - (x1 or 0.0)), "height": abs((y2 or 0.0) - (y1 or 0.0))}
return None
def is_background_bbox(bbox: dict[str, float], canvas_width: float, canvas_height: float) -> bool:
return bbox["x"] <= 0 and bbox["y"] <= 0 and bbox["x"] + bbox["width"] >= canvas_width and bbox["y"] + bbox["height"] >= canvas_height
def bbox_outside(bbox: dict[str, float], rect: dict[str, float]) -> bool:
return (
bbox["x"] < rect["x"]
or bbox["y"] < rect["y"]
or bbox["x"] + bbox["width"] > rect["x"] + rect["width"]
or bbox["y"] + bbox["height"] > rect["y"] + rect["height"]
)
def intersects(left: dict[str, float], right: dict[str, float]) -> bool:
return (
left["x"] < right["x"] + right["width"]
and left["x"] + left["width"] > right["x"]
and left["y"] < right["y"] + right["height"]
and left["y"] + left["height"] > right["y"]
)
def validate_geometry(elements: list[ET.Element], canvas_width: float, canvas_height: float) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
issues: list[dict[str, Any]] = []
text_boxes: list[dict[str, Any]] = []
canvas = {"x": 0.0, "y": 0.0, "width": canvas_width, "height": canvas_height}
for element in elements:
name = local_name(element.tag)
bbox = bbox_for_element(element)
if bbox is None:
continue
if is_background_bbox(bbox, canvas_width, canvas_height):
continue
if bbox_outside(bbox, canvas):
issues.append(
issue(
"error",
"canvas_bounds",
f"<{name}> is outside the 960x540 canvas",
element,
"Non-background elements must fit inside the slide canvas.",
)
)
elif bbox_outside(bbox, SAFE_AREA):
issues.append(
issue(
"warning",
"safe_area",
f"<{name}> is outside the recommended safe area",
element,
"Keep text, labels, cards, legends, and key visuals within x>=48 y>=40 right<=912 bottom<=500 unless it is an intentional full-bleed background.",
)
)
if name == "foreignObject" and svg_role(element) == "shape" and svg_shape_type(element) == "text":
text = "".join(element.itertext()).strip()
if text:
text_boxes.append({"element": element, "bbox": bbox, "text": text})
return issues, text_boxes
def validate_text_overlap(text_boxes: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for left_index, left in enumerate(text_boxes):
for right in text_boxes[left_index + 1 :]:
if intersects(left["bbox"], right["bbox"]):
left_id = get_attr(left["element"], "id") or local_name(left["element"].tag)
right_id = get_attr(right["element"], "id") or local_name(right["element"].tag)
issues.append(
{
"level": "error",
"code": "text_bbox_overlap",
"message": f"text boxes overlap: {left_id} and {right_id}",
"left_element_id": get_attr(left["element"], "id"),
"right_element_id": get_attr(right["element"], "id"),
"hint": "Move text boxes apart, reduce text density, or split the page into clearer layout boxes.",
}
)
return issues
def validate_styles(root: ET.Element) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for element in root.iter():
style = get_attr(element, "style") or ""
if FONT_SHORTHAND_RE.search(style):
issues.append(
issue(
"error",
"font_shorthand",
'style must not use "font:" shorthand',
element,
"Use explicit font-size, font-weight, font-family, color, line-height, and text-align properties.",
)
)
return issues
def validate_paths(elements: list[ET.Element]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for element in elements:
if local_name(element.tag) != "path" or svg_role(element) != "shape":
continue
data = get_attr(element, "d") or ""
without_numbers = PATH_NUMBER_RE.sub("", data)
has_command = False
for char in without_numbers:
if char in ", \t\r\n":
continue
if char in "MLHVZCQmlhvzcq":
has_command = True
continue
issues.append(
issue(
"error",
"unsupported_path_command",
f'unsupported path command or character "{char}"',
element,
"Use only M/L/H/V/C/Q/Z path commands.",
)
)
break
if not has_command:
issues.append(issue("error", "missing_path_command", 'path attribute "d" must include M/L/H/V/C/Q/Z commands', element))
return issues
def lint_svg(svg: str, path: str = "<svg>") -> dict[str, Any]:
result: dict[str, Any] = {"path": path, "issues": []}
try:
root = ET.fromstring(svg)
except ET.ParseError as error:
result["issues"].append(
{
"level": "error",
"code": "xml_not_well_formed",
"message": f"SVG is not well-formed: {error}",
"hint": "Fix tag closure, attribute quotes, namespaces, and XML escaping before calling slides +create-svg.",
}
)
result["summary"] = {"error_count": 1, "warning_count": 0}
return result
root_issues, width, height = validate_root(root)
elements = walk_renderable(root)
role_issues = validate_roles_and_attrs(elements)
geometry_issues, text_boxes = validate_geometry(elements, width, height)
issues = root_issues + role_issues + validate_styles(root) + validate_paths(elements) + geometry_issues + validate_text_overlap(text_boxes)
result["width"] = width
result["height"] = height
result["element_count"] = len(elements)
result["text_box_count"] = len(text_boxes)
result["issues"] = issues
result["summary"] = {
"error_count": sum(1 for item in issues if item["level"] == "error"),
"warning_count": sum(1 for item in issues if item["level"] == "warning"),
}
if not issues:
result.pop("issues")
return result
def lint_files(paths: list[str]) -> dict[str, Any]:
files: list[dict[str, Any]] = []
for path in paths:
svg = Path(path).read_text(encoding="utf-8")
files.append(lint_svg(svg, path))
return {
"summary": {
"file_count": len(files),
"error_count": sum(file["summary"]["error_count"] for file in files),
"warning_count": sum(file["summary"]["warning_count"] for file in files),
},
"files": files,
}
def main(argv: list[str]) -> int:
try:
options = parse_args(argv)
result = lint_files(options["inputs"])
except SvgPreflightError as error:
print(f"svg_preflight: {error}", file=sys.stderr)
return 2
except OSError as error:
print(f"svg_preflight: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2))
return 1 if result["summary"]["error_count"] else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -1,97 +0,0 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import unittest
import svg_preflight
VALID_SVG = """
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960" height="540" viewBox="0 0 960 540">
<rect slide:role="shape" x="0" y="0" width="960" height="540" fill="#f8fafc" />
<foreignObject id="title" slide:role="shape" slide:shape-type="text" x="64" y="56" width="420" height="72">
<div xmlns="http://www.w3.org/1999/xhtml"
style="font-size:32px;font-weight:800;font-family:Arial;color:#111827;line-height:1.15;text-align:left;">
Strategy review
</div>
</foreignObject>
<image id="hero" slide:role="image" href="@./assets/hero.jpg" x="560" y="96" width="320" height="220" />
<path id="trend" slide:role="shape" d="M64 360 L180 330 C260 300 340 340 420 300 Q500 260 580 290" fill="none" stroke="#2563eb" />
</svg>
"""
class SvgPreflightTest(unittest.TestCase):
def test_lint_svg_accepts_valid_svglide(self) -> None:
result = svg_preflight.lint_svg(VALID_SVG)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_svg_reports_canvas_mismatch(self) -> None:
result = svg_preflight.lint_svg(
VALID_SVG.replace('width="960" height="540" viewBox="0 0 960 540"', 'width="1280" height="720" viewBox="0 0 1280 720"')
)
codes = [issue["code"] for issue in result["issues"]]
self.assertIn("root_canvas_mismatch", codes)
self.assertIn("root_viewbox_mismatch", codes)
self.assertEqual(result["summary"]["error_count"], 2)
def test_lint_svg_reports_external_image_and_font_shorthand(self) -> None:
svg = """
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960" height="540" viewBox="0 0 960 540">
<foreignObject id="title" slide:role="shape" slide:shape-type="text" x="64" y="56" width="420" height="72">
<div xmlns="http://www.w3.org/1999/xhtml" style="font: 700 24px Arial;color:#111827;">Title</div>
</foreignObject>
<image id="hero" slide:role="image" href="https://example.com/hero.jpg" x="560" y="96" width="320" height="220" />
</svg>
"""
result = svg_preflight.lint_svg(svg)
codes = [issue["code"] for issue in result["issues"]]
self.assertIn("external_image_href", codes)
self.assertIn("font_shorthand", codes)
def test_lint_svg_reports_canvas_error_and_safe_area_warning(self) -> None:
svg = """
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960" height="540" viewBox="0 0 960 540">
<rect id="badge" slide:role="shape" x="12" y="20" width="80" height="40" />
<rect id="overflow" slide:role="shape" x="920" y="500" width="120" height="80" />
</svg>
"""
result = svg_preflight.lint_svg(svg)
codes = [issue["code"] for issue in result["issues"]]
self.assertIn("safe_area", codes)
self.assertIn("canvas_bounds", codes)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 1)
def test_lint_svg_reports_text_bbox_overlap(self) -> None:
svg = """
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960" height="540" viewBox="0 0 960 540">
<foreignObject id="a" slide:role="shape" slide:shape-type="text" x="80" y="80" width="240" height="80">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:24px;font-weight:700;color:#111;">A</div>
</foreignObject>
<foreignObject id="b" slide:role="shape" slide:shape-type="text" x="120" y="100" width="240" height="80">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:18px;font-weight:400;color:#111;">B</div>
</foreignObject>
</svg>
"""
result = svg_preflight.lint_svg(svg)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["issues"][0]["code"], "text_bbox_overlap")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,25 +1,17 @@
# Slides CLI E2E Coverage
## Metrics
- Denominator: 4 leaf commands
- Covered: 2
- Denominator: 2 leaf commands
- Covered: 1
- Coverage: 50.0%
## Summary
- TestSlides_CreateWorkflowAsUser: proves the user slides workflow through `create presentation with slide as user` and `get created presentation xml as user`; creates a fresh presentation, asserts returned IDs, then reads back the XML content to prove the title and slide body persisted.
- TestSlidesCreateSVGDryRunRequestShape: locks the `slides +create-svg --dry-run` request chain and recursive SVGlide validation, including `g` containers, geometry-required leaves, `px` geometry, nested defs/filter, shadow style preservation, and `foreignObject` XHTML `<br />`.
- TestSlidesCreateSVGDryRunRejectsMissingRequiredAttribute: proves CLI blocks leaf shapes that would otherwise reach the server as `shape missing required attribute`.
- TestSlidesCreateSVGDryRunRejectsInvalidNumericGeometry: proves CLI blocks non-absolute geometry before issuing API calls.
- TestSlidesCreateSVGDryRunRejectsUnsupportedPathCommand: proves CLI blocks unsupported path commands before issuing API calls.
- TestSlidesCreateSVGWorkflowAsUser: opt-in live workflow for `slides +create-svg` (`LARKSUITE_CLI_RUN_SVGLIDE_LIVE=1`); creates a two-page SVG deck as user, asserts returned page count and IDs, then reads the presentation back.
- Blocked area: `slides +media-upload` is still uncovered because it needs a deterministic local image fixture plus XML follow-up proof that is separate from the base create/read workflow.
- Blocked area: `slides +replace-slide` has focused unit coverage but no E2E workflow yet.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | slides +create | shortcut | slides_create_workflow_test.go::TestSlides_CreateWorkflowAsUser/create presentation with slide as user | `--title`; `--slides ["<slide ...>"]` | read back through raw slides API to prove persisted XML |
| ✓ | slides +create-svg | shortcut | slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRequestShape; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsMissingChildRole; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsMissingRequiredAttribute; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsInvalidNumericGeometry; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsUnsupportedPathCommand; slides_create_svg_live_test.go::TestSlidesCreateSVGWorkflowAsUser | repeated `--file`; `--title`; `--dry-run` | dry-run proves existing `/slide` route, `slide.content`, recursive SVGlide validation, server-known hard failures, numeric geometry gates, and path command gates before API call; live is opt-in and depends on the target server lane containing the updated SVGlide parser |
| ✕ | slides +media-upload | shortcut | | none | needs a stable local image fixture plus follow-up slide XML proof |
| ✕ | slides +replace-slide | shortcut | | none | unit-covered shortcut; E2E workflow still pending |

View File

@@ -1,159 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func setSlidesDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
func TestSlidesCreateSVGDryRunRequestShape(t *testing.T) {
setSlidesDryRunEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "page1.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" width="1280" height="720" viewBox="0 0 1280 720"><style>.primary{fill:#123456;stroke:#654321;stroke-width:2px;filter:drop-shadow(2px 4px 8px rgba(0,0,0,.2))}</style><g><defs><filter id="shadow"><feDropShadow dx="2" dy="3" stdDeviation="5" flood-color="#000" flood-opacity=".25"/></filter></defs></g><g transform="translate(20 30)"><rect slide:role="shape" class="primary" x="0" y="0" width="320px" height="180px" filter="url(#shadow)"/></g></svg>`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "page2.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80"><div xmlns="http://www.w3.org/1999/xhtml">two<br />lines</div></foreignObject></svg>`), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "page1.svg",
"--file", "page2.svg",
"--title", "Dry SVG",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/slides_ai/v1/xml_presentations", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "POST", gjson.Get(out, "api.1.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide", gjson.Get(out, "api.1.url").String(), "stdout:\n%s", out)
require.Equal(t, "POST", gjson.Get(out, "api.2.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide", gjson.Get(out, "api.2.url").String(), "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<rect slide:role="shape"`, "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<g transform="translate(20 30)">`, "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<style>.primary`, "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<feDropShadow`, "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.2.body.slide.content").String(), `slide:shape-type="text"`, "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.2.body.slide.content").String(), `<br />`, "stdout:\n%s", out)
}
func TestSlidesCreateSVGDryRunRejectsMissingChildRole(t *testing.T) {
setSlidesDryRunEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect x="80" y="80" width="320" height="180"/></svg>`), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "bad.svg",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `<rect> must include slide:role="shape" or slide:role="image"`)
}
func TestSlidesCreateSVGDryRunRejectsMissingRequiredAttribute(t *testing.T) {
setSlidesDryRunEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="80" y="80" height="180"/></svg>`), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "bad.svg",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `<rect slide:role="shape"> missing required attribute "width"`)
}
func TestSlidesCreateSVGDryRunRejectsInvalidNumericGeometry(t *testing.T) {
setSlidesDryRunEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="80" y="80" width="50%" height="180"/></svg>`), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "bad.svg",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `attribute "width" must be a number or px length`)
}
func TestSlidesCreateSVGDryRunRejectsUnsupportedPathCommand(t *testing.T) {
setSlidesDryRunEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><path slide:role="shape" d="M0 0 A10 10 0 0 1 20 20"/></svg>`), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "bad.svg",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `unsupported path command or character "A"`)
}

View File

@@ -1,115 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestSlidesCreateSVGWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
if os.Getenv("LARKSUITE_CLI_RUN_SVGLIDE_LIVE") != "1" {
t.Skip("set LARKSUITE_CLI_RUN_SVGLIDE_LIVE=1 to run the live SVGlide service contract test")
}
clie2e.SkipWithoutUserToken(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "page1.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" width="1280" height="720" viewBox="0 0 1280 720"><g fill="#E8EEF8" transform="translate(80 80)"><rect slide:role="shape" x="0" y="0" width="360" height="180"/></g></svg>`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "page2.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="shape" slide:shape-type="text" x="120" y="120" width="420" height="100"><p xmlns="http://www.w3.org/1999/xhtml">SVGlide live E2E</p></foreignObject></svg>`), 0o644))
parentT := t
title := "svglide-e2e-" + clie2e.GenerateSuffix()
var presentationID string
t.Run("create SVG deck as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "page1.svg",
"--file", "page2.svg",
"--title", title,
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
if result.ExitCode != 0 {
if created := extractSVGlideFailurePresentationID(result.Stderr); created != "" {
presentationID = created
registerSlidesCleanup(parentT, presentationID)
}
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
presentationID = gjson.Get(result.Stdout, "data.xml_presentation_id").String()
require.NotEmpty(t, presentationID, "stdout:\n%s", result.Stdout)
require.Equal(t, title, gjson.Get(result.Stdout, "data.title").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.slides_added").Int(), "stdout:\n%s", result.Stdout)
require.Len(t, gjson.Get(result.Stdout, "data.slide_ids").Array(), 2, "stdout:\n%s", result.Stdout)
registerSlidesCleanup(parentT, presentationID)
})
t.Run("read back SVG-created presentation as user", func(t *testing.T) {
require.NotEmpty(t, presentationID, "presentation should be created before readback")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/slides_ai/v1/xml_presentations/" + presentationID},
DefaultAs: "user",
Params: map[string]any{"revision_id": -1},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
require.Equal(t, presentationID, gjson.Get(result.Stdout, "data.xml_presentation.presentation_id").String(), "stdout:\n%s", result.Stdout)
content := gjson.Get(result.Stdout, "data.xml_presentation.content").String()
require.Contains(t, content, "<title>"+title+"</title>", "stdout:\n%s", result.Stdout)
})
}
func registerSlidesCleanup(t *testing.T, presentationID string) {
t.Helper()
t.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", presentationID,
"--type", "slides",
"--yes",
},
DefaultAs: "user",
})
clie2e.ReportCleanupFailure(t, "delete presentation "+presentationID, deleteResult, deleteErr)
})
}
func extractSVGlideFailurePresentationID(stderr string) string {
const marker = "presentation "
idx := strings.Index(stderr, marker)
if idx < 0 {
return ""
}
rest := stderr[idx+len(marker):]
end := strings.IndexByte(rest, ' ')
if end < 0 {
return ""
}
return strings.Trim(rest[:end], ".,;:)")
}