Files
larksuite__cli/shortcuts/common/runner.go
sang-neo03 6952d3aa7f feat(extension): add business command extension v1 (#2308)
* feat(shortcuts): import typed shortcut framework from c07b64621

* feat(extension): add public command contract

* feat(extension): compile and register business commands

* feat(cmd): assemble business command sets

* fix(auth): derive login domains from shortcuts

* test(extension): add business command test runtime

* ci: verify generated Go sources

* test(auth): match help and interactive domains

* fix(extension): validate command path segments

* fix(extension): satisfy generator and error guards

* fix(extension): follow generated domain naming contract

* fix(command): complete extension runtime contracts

* test(command): cover public extension surface

* fix(command): remove unused typed runtime APIs

* fix(command): address follow-up review findings

* fix(command): enforce extension v1 contracts

* fix(command): remove unreachable compatibility wrappers

* fix(auth): keep scope-less domains addressable via --domain, matching main

Move the declared-scope filter from allKnownDomains to the interactive
selector only. On main, a scope-less shortcut domain (event) passes
--domain validation and fails later with "no matching scopes found";
the previous unified filter changed that to "unknown domain" and
dropped it from the --help list. The interactive picker still hides
scope-less domains — selecting one can only fail.

* feat(command): add PathSegment for user-provided path values

Business code concatenates IDs into request paths but had no public
escape helper (internal/validate.EncodePathSegment is unreachable from
extension/command). Mirror its url.PathEscape semantics, use it in the
business command examples, and pin the traversal defense: the validator
decodes percent-encoding before the canonical check, so both raw and
escaped dot sequences fail same-origin validation.

* docs(command): add runnable chat-brief distribution example

Mirror the audit-observer precedent: a buildable wrapper main under
examples/ showing WithCommandSets against the real distribution shape
(plugins, strict mode, and service commands stay enabled). Covers the
single-read command (Validate, shared DryRun request, CallJSON,
PathSegment, Tips) and a Page[T] list command whose pagination flags
come from the compiler. The testdata/wrapper fixture stays test-only.

Verified offline: --help renders tag-driven parameters, +chat-brief-list
exposes --page-all/--page-limit/--page-delay, and --dry-run previews the
request with a fake env token and no network access.

* fix(command): normalize page envelopes and bound CollectAllPages

Two pagination contract fixes from the extension design (owner plan §8.3):

Page decoding accepted only a literal "items" array, so endpoints that
spell their list field differently (drive uses files, some responses use
records) walked every page while decoding nothing — CollectAllPages then
returned an empty set marked complete, and downstream writes ran against
it. Each page now normalizes its single top-level array field into
Page.Items; zero or multiple array fields fail closed with a typed
invalid-response error.

CollectAllPages previously reused the user-facing --page-limit maximum
(1000) as its walk bound. A complete-set collection holds every page in
memory before the workflow's writes run, so it now uses the design's
dedicated workflow bound of 100 pages.

* feat(commandhost): note bounded repetition in Page dry-run previews

A Page[T] command's dry-run can only show the first request; fabricating
response-dependent page tokens is forbidden. Append the bounded-repeat
explanation to the previewed request (preserving any business
description), matching the design's dry-run contract.

Also give the example's list command a --page-token resume flag seeded
into the request, documenting the resume convention: the framework owns
--page-all/--page-limit/--page-delay while the starting cursor is a
business-declared input, independent of --page-all.

* docs(command): generate domain constants with English comments

The generator emitted Chinese titles while the rest of the public
extension packages document in English. Switch the generator to the
"en" service title and regenerate. The generator already rejects a
domain missing either locale, so the switch keeps its own guard.

* docs(command): mark the host adapter read surface

The Host* types, InspectCommand, InspectDomain and CloneSets exist for
lark-cli's host adapter, not for business commands, but nothing said so
at the symbols themselves. They cannot move to a subpackage: a Command
holds its declaration unexported, so a sibling package has no way to
reach it, and moving the wire types to internal/ would cycle back
through CommandMetadata and CommandContext.

Also correct HostPagination, which is not adapter-only -- ContextOptions
and commandtest both carry it.

* refactor(command): type CommandMetadata.Service as DomainName

A set already declares its domain through ExtendDomain(DomainIm), yet
every command repeated the same domain as a bare string that only the
host compiler checked. Typing the field points authors at the generated
enum and forces an explicit conversion when the value comes from a
string variable.

This does not make a mistyped literal a compile error -- an untyped
constant still converts to DomainName -- so the mismatch check in
CompileSets stays the actual net. The example and the wrapper fixture
now declare command.DomainIm.

The chat-brief example was also not gofmt-clean, which the CI format
gate would have caught.

* refactor(command): extract the command-set assembly steps

buildInternalWithConfig is an orchestrator, and the business command
sets were compiled inline inside it. Move that step to
resolveShortcutSnapshot so the entry point reads as one call and the
built-in/external merge has a name.

newCommand carried six near-identical blocks that each nil-checked a
hook and wrapped it in the same type assertion. Split them into one
binder per hook shape; Normalize and Validate now share bindArgsHook
since their signatures match. The behaviour is unchanged: an undeclared
hook still erases to nil, and an empty renderer map still yields nil.

* perf(shortcuts): stop re-cloning an already-isolated snapshot

AllShortcuts deep-copies because a Shortcut carries slice fields whose
backing arrays a shallow copy would share: an external distribution
mutating registered[0].Flags[0] would corrupt the process-global list.
That copy is worth its ~165us over 500+ shortcuts.

Paying it four times per startup is not. auth, schema and the mount path
each cloned the snapshot again, but they receive it from
AllShortcutsWithExternal with no third-party code in between, and nothing
in this repository mutates a shortcut element -- mountDeclarative takes a
value receiver and only replaces slice headers. Drop those three copies
and document the boundary on AllShortcuts so the next reader does not
reintroduce them.

Startup drops from four full clones to one. Benchmarks pin the remaining
cost so a regression points at a new clone rather than at growth in the
shortcut set.

* fix(command): align the commandtest page bound and escape wrapper paths

Two review findings, both of which let a business command pass its tests
and then misbehave in production.

The commandtest recorder walked 1000 pages for a complete-set collection
while the host adapter stops at 100, so a command tested against 300
pages of fixtures would fail its first real --page-all run with
PaginationLimitError. The bound now lives in internal/pagination, which
both sides already import, and the hard-limit test scripts itself from
that constant instead of restating 1000 -- the literal was what let the
two drift apart.

The testdata wrapper concatenated args.ID straight into the request path,
contradicting PathSegment's own documented rule and the chat-brief
example. ValidateRequestView does not cover this: "abc/other-users-file"
cleans to itself, so an unescaped separator silently retargets the
request. Since testdata is what an integrator copies first, route both
call sites through one readRequest helper, mirroring chat-brief.

The e2e assertion could not have caught it either -- PathSegment("chat_1")
is "chat_1", so the check passed with or without the call. It now sends
"chat/1" and asserts %2F reaches the wire; removing PathSegment fails it.

* fix(command): deny network to the pre-confirmation hooks and four review findings

Normalize and Validate run before the high-risk confirmation gate, and
both received the full CommandContext, so a high-risk business command
could POST or DELETE from Validate and leave remote side effects behind
before the user was ever asked to confirm. Moving the gate earlier would
contradict the documented hook order and would also make --dry-run
require --yes. The design already forbids this from the other side --
Validate is specified as parameter checking that issues no request -- so
enforce that instead: Normalize and Validate get a context whose CallJSON
and CollectPages refuse, while PreflightScopes stays available. The guard
sits in CommandContext rather than in the wiring, so a future adapter
that wires the callbacks anyway still cannot reach the API. commandtest
mirrors it, otherwise a command would pass its tests and fail only in
production.

Page.Items now starts non-nil. It is declared required;nonnullable, but a
zero-item collection encoded as {"items":null}, which a caller generating
types from the published schema would reject.

NewCmdAuthWithRecovery and NewCmdSchemaWithVisibility are restored as
wrappers. Both were dropped for shortcut-aware variants, and both are
reachable from outside this module: CommandVisibility is an ordinary
exported func type, and *recovery.Projector cannot be named by an outside
caller but can be passed as nil. A signature test now pins them.

The path-traversal fixture said "../../secret", which the deterministic
gate rejects as a generic credential assignment -- the reason CI is
currently red. The filename carries no meaning; it is now "../../outside".

* test(cmd): exercise the retained constructors instead of naming them

The compatibility wrappers restored for outside callers are unreachable
from inside this repository by construction, so the incremental dead-code
gate rejected them. A signature-only assertion did not help: taking a
function value and discarding it leaves the body unreachable, and it
proved nothing about whether the wrapper still builds a working command.

Call each one and assert the command it returns. NewCmdAuthWithRecovery
is called with a nil projector, which is the exact call an outside module
can make and the reason the wrapper has to keep compiling.

Verified with the same deadcode version CI runs: neither function is
reported, and no other function in this branch's files is either.

* docs(command): document the hook contract and pin dry-run note idempotency

Hooks is the first type a business author reads and carried no field
documentation, so the rules lived only in the design doc: which of DryRun
and DryRunE to set, that setting both fails to compile, that Execute owns
the API call and must not write stdout, and that Normalize and Validate
run before the confirmation gate and therefore get no network.

The choice between DryRun and DryRunE is not old-versus-new -- neither is
legacy. It follows from whether building the preview can fail, which is
now what the field docs say.

Also pin the dry-run note as idempotent. convertDryRun writes the
bounded-repeat note into the projection it builds, never back into the
hook's *DryRun, and DryRunAPI.Desc assigns rather than appends, so a hook
that caches and returns the same preview cannot accumulate the note. Both
properties were true and neither was tested.

* refactor(command): settle the dry-run constructor, tips, and domain enum

Three narrowings of the V1 business-command contract, none of which has a
published compatibility surface: extension/command does not exist on main.

Preview and NewDryRun were the same constructor twice -- one empty, one
seeded with requests. Fold them into a variadic NewDryRun. Every existing
NewDryRun() call keeps compiling, and the domain word in the contract is
now spelled one way. The type DryRun already owns that identifier in this
package, so naming the constructor DryRun outright cannot compile.

Drop Metadata.Tips. It was pure passthrough into common.Shortcut.Tips and
nothing in the execution path read it, so business commands lose only the
ability to declare help tips; the repository's own typed shortcuts keep
theirs. The mount test asserted a tip reached the rendered help as proof
that metadata survives the extension -> commandhost -> common.Shortcut ->
help conversion; it now asserts the risk line, which travels the same path.

Hand-write the domain enumeration and delete the generator. Generating
from shortcuts.AllShortcuts silently omitted approval, attendance and
mindnotes: all three are published under `lark-cli --help` and served by
typed and raw API commands, they just own no shortcut. The enum is now
the 23 domains the CLI actually exposes.

Those three would otherwise have been constants that compile and always
fail, because CompileSets derived its mountable domains from the same
shortcut list. It now reads the service registry, and shortcuts/register.go
already creates a domain command group on demand when no built-in occupies
it, so a business command can mount under a shortcut-less domain.

* ci: stop generating extension/command

The domain enumeration is hand-written now and extension/command holds no
go:generate directive, so the path was a no-op that still read as if the
package carried generated files.

* refactor(command): drop DryRunE and let the preview render like a built-in

DryRunE has no counterpart in the shipped CLI: `git show
main:shortcuts/common/types.go` has no such field, it arrived with the
typed-shortcut framework this branch imported, and no shortcut in the
repository sets one. Business commands get the single DryRun hook that
built-in shortcuts have. Validate already runs before it and owns the
error channel, so a preview that cannot be built still fails there with a
typed error -- which is what the repointed tests now assert, end to end
through --dry-run and through commandtest.Preview.

Also stop appending the bounded-repeat note. convertDryRun added "with
--page-all, repeats with the returned page_token until exhaustion or
--page-limit" to every Page[T] preview, so an external command's dry-run
carried a sentence its author never wrote. Built-in paginated shortcuts
say this themselves when they want it (im_chat_members_list.go calls
dry.Desc), and external commands now do the same: the framework renders
the description it was given and nothing else.

The dry-run context keeps refusing requests -- runner.go does the same for
built-ins, so removing that would be the divergence, not the alignment.
Only the word changes: "offline" was our own vocabulary for what the rest
of the CLI calls dry-run.

* refactor(command): drop the partial-failure outcome from the contract

Business commands now return Success only. Partial, OutcomeDefinition,
PartialFailureDefinition and FailedItemDefinition leave the public surface
along with Execution.Partial and the host adapter's receipt conversion.

Result keeps its outcome field. It is no longer a choice -- Success is the
only value -- but it is also how the host tells a returned Result apart
from the zero value that accompanies an error, which is the check
commandtest.Execute makes before reporting "returned both Result and
error". Collapsing it to nothing would delete that signal.

The exemplar commands that returned Partial keep their scenarios: a
best-effort scope failure still marks every item failed and appends the
snapshot, and the multi-call audit still records the owner it could not
resolve. That information lives in the command's own Data (Items[].State
plus Failures), not in the outcome, so the tests assert the same facts and
only the outcome assertion is gone. The deep-copy test moved its nested
JSON exemplar from FailedValues to InputDefault.Value, keeping
cloneJSONValue covered.

shortcuts/common still defines PartialFailure for built-in typed
shortcuts. That is the imported framework, untouched here.

* refactor(shortcuts): walk pages once for built-in and external commands

Commit 4d0c6ea61 added internal/pagination, moved PaginateInto onto it,
and then wrote a second caller for externally declared commands. Both
assembled the same Walk options, cloned the same params, read the same
cursor and mapped the same walk error; only the policy source, the call
path and the accumulator ever differed.

Those three now parameterize one pageWalk. PaginateInto keeps calling
through the RuntimeContext and keeps its per-page progress line; external
commands keep CallTypedAPI, the walker's context and their undecoded
pages, which the public contract needs because it decodes them into its
own Page[T]. Behavior is unchanged on both sides -- the external walk
still leaves Wait nil, which internal/pagination fills with WaitContext,
so --page-delay works exactly as before.

pageWalk is deliberately generic-free so one struct serves both callers;
the typed half of the built-in path moved to addDecodedPage.

* refactor(shortcuts): make the external page walk PaginateInto's twin

CollectCommandPages now differs from PaginateInto only where the context
type forces it. It takes the same PageAccumulator, decodes each page into
T through the same addDecodedPage, returns the same *output.PaginationMeta
and reads the same state out of the same walk, in the same order.

What is left is what the interface cannot supply. An externally declared
command compiles in the business module, so it holds a CommandContext
rather than a *RuntimeContext: the context arrives as a parameter because
the interface carries none, the call goes through CallTypedAPI, and there
is no progress line because deciding to print one needs StderrIsTerminal,
JqExpr and Format, none of which the interface exposes.

The all parameter stays. It is the complete-set policy CollectAllPages
depends on -- collect to exhaustion under the hard page bound instead of
obeying --page-all and --page-limit -- and PaginateInto has no way to
express it, since resolvePaginationPolicy only ever reads flags. Dropping
it would quietly turn a command that must see the whole set into one a
user can truncate with --page-limit 1.

CommandPageCollection is gone with it: pages accumulate in commandhost's
own accumulator, the way every built-in shortcut already accumulates its
own. One consequence of sharing the decode: a page whose response carries
no data object is now an error on this path too, as it always was for
built-ins.

* fix(shortcuts): reject recursive Data and Args types during compilation

shapeForType and compileStructShape called each other without recording the
Go types already being walked, so a self-referential type recursed forever.
The walk ran during command registration and ended in a stack overflow --
a fatal runtime error rather than a panic, so no recover boundary could
contain it and one extension command took the whole CLI down before --help,
schema, or any unrelated command could run. That also broke
CompileErasedDefinition's documented promise to compile without panic.

Thread the struct types open on the current recursion path through both
functions and return a compile error on a repeat visit, pointing at the
explicit Shape escape hatch. Membership is scoped to the path, not the whole
walk, so a type reused as a sibling or at another depth stays legal.

Covers self-reference through a slice and through a pointer, mutual
recursion through two types, the JSON-encoded Args path, and the public
CompileErasedDefinition contract.

* fix(command): share one result protocol between the host and commandtest

commandtest.Execute only checked that Data carried the expected type. Generic
erasure leaves a correctly typed zero Data behind, so a business command that
returned Result{} instead of Success(data) passed the type assertion and the
test reported success. Production rejects the same result, which left
extension authors with green tests and a command that failed on every real
invocation -- exactly the guarantee commandtest exists to provide.

Add ValidateHostResult in extension/command and call it from both
commandtest.Execute and the host adapter's execute hook, so the two surfaces
cannot drift. It rejects an empty or unsupported outcome and mirrors the
pagination receipt checks the host already applies: declared Page output,
pages of at least one, non-negative items, and next-token state consistent
with completeness.

RunWithFlags is covered because it delegates to Execute.

* feat(command): expose reusable download capabilities

* test(command): make the chat-brief example testable and cover its hooks

The example declared both commands as inline Definition literals handed
straight to Define. Define erases the type parameters and returns an opaque
Command that cannot hand its Definition back, while commandtest.Execute takes
the Definition -- so the shape the example demonstrated could not be unit
tested at all. Neither shipped example had a test file, so nobody had walked
the copy-the-example-then-add-a-test path.

Lift both declarations into Definition-returning functions, the shape the
repository's own commandtest suites already use, and keep the compiled
Commands as package vars so main is unchanged. The configuration bodies are
untouched.

Add the tests that shape exists for: the single-read projection and its
Validate rejection, plus the Page[T] contract for default single-page reads
and a --page-all walk through RunWithFlags. All four run offline through the
commandtest recorder.

* test(commandtest): cover the ordered URL download script

Recorder.ReplyURL shipped without a caller, so the incremental dead code
gate flagged it as new unreachable code and blocked the branch. The existing
URL download test uses the unordered RespondFile constructor, which never
exercises the URL assertion ReplyURL exists for.

Mirror the ReplyJSON pair: one test walks two scripted URLs in order and
checks the recorded source URLs, content types, and artifacts; the other
points DownloadURL at an unscripted URL and expects the mismatch error.

* feat(command): accept @file input for external commands

V1 rejected the file value source, leaving external commands with inline
flags and stdin only. A process has one stdin, so a command whose body is
too large or too quoted for the shell -- an XML document update is the case
that surfaced this -- had no second way to receive it, and the caller had to
fall back to shell escaping.

Nothing downstream was missing: resolveInputFlags already resolves @path and
the @@ escape through the invocation's FileIO, help renders the "@file"
affordance, and legacyInputSources maps the source onto the compiled flag.
The gap was the public constant and the host allow-list.

Export SourceFile and let it through compilation. Unknown sources still fail
the same way, which the rewritten host test now pins alongside the compiled
flag actually carrying both extra sources -- silently dropping a declared
source is the regression worth catching.

Give the wrapper fixture a command whose content flag declares all three
sources, and drive it end to end: @file and stdin both reach the request
body, and the help text advertises them.

* revert(content): drop the default content exports from the extension surface

Exporting the repository's embedded skills and affordance trees turned two
content directories into Go packages, because go:embed cannot reach up out of
a package directory and the repository root is package main. That cost two
things: a .go file living inside an authored-content tree, where a future
content type is silently omitted until someone edits the glob, and an embed
directive per tree where the root previously covered both in one.

The need it served does not exist. The distribution driving this work ships
its own skills and does not consume the official set, and a wrapper that does
want them can supply a tree through cmd.SetEmbeddedSkillContent, which is what
extension/platform already documents.

Restore content_embed.go, the SkillsOverlay comments, and the platform README
to their main state, and delete the two exporting packages. The example and
fixture wrappers now ship no embedded content, which is what a wrapper that
does not compile the repository root actually gets; the e2e assertion that
depended on inheriting lark-doc goes with it.

* fix(command): close the review findings on API surface and storage commit

Five findings from the extension-v1 review, each verified by a test that fails
against the previous implementation.

Source compatibility of auth.LoginOptions. The shortcut snapshot was an
unexported field on a struct that appears in the exported runF signature, which
ends positional literals for every caller outside this module. It becomes a
closure capture plus an explicit authLoginRun parameter, and the six domain
helpers collapse into domainResolver methods, removing five xxxWithShortcuts
twins that only tests reached. common.Shortcut.DryRunE goes too: it was a new
exported field with no production caller. The unexported typed field stays, so
Shortcut itself is still not positional-literal compatible -- that is a
deliberate remaining gap, since relocating it would need a global mutable map or
a wider signature change.

One canonical wire projection. queryValues stringified and dropped nils for the
live call while the dry-run preview and the pagination walk forwarded raw values,
so a preview could describe a request the runtime would never send. canonicalQuery
is now the only projection and all three consumers derive from it. Dry-run output
for numeric parameters therefore reads "20" instead of 20, matching what the
query string actually carries.

No-clobber as a storage guarantee. IfExistsFail checked existence, downloaded,
then committed with a rename that replaces unconditionally, so a target created
during the transfer was silently overwritten. The commit step is now an optional
ExclusiveFileIO capability: content lands in a temp file and is published with
Link, which refuses an existing target and never exposes a partial file. A
provider without the capability is refused rather than served a guarantee it
cannot keep.

V1 public surface. Removes NewDomain and its options (host compilation rejected
them), HostDomain.IsNew, reservedRootNames, and the unproducible
ResultMetaDefinition.Count; narrows Hooks.Renderers to a single PrettyRenderer,
since pretty was the only key the compiler accepted; and demotes the generic
authoring layer in shortcuts/common to unexported, as no production code outside
that package used it.

commandtest runs the production compiler. Execute, RunWithFlags and Preview now
share compileForTest, so a wrong tag, Shape or relation fails in the unit test
instead of at CLI startup. Applying this surfaced two long-standing contract
violations in the package's own fixture.

* refactor(command): keep a single authoring contract

* revert(schema): keep the schema command blind to shortcuts

The schema command serves the generated API catalog only, matching main.
Drop the shortcut contract lookup and completion from cmd/schema and
restore the constructor surface. ShortcutSchema stays on the sealed
commandbridge surface, where the host compiler tests assert the contract;
the CLI itself no longer consumes it. The surface scenario and wrapper
e2e pin the boundary from the other side: schema resolution and
completion must not see mounted shortcuts.

---------

Co-authored-by: sang-neo03 <266690410+sang-neo03@users.noreply.github.com>
Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-08-25 20:22:13 +08:00

1587 lines
57 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"slices"
"strings"
"sync"
"github.com/google/uuid"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/fileevent"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// RuntimeContext provides helpers for shortcut execution.
type RuntimeContext struct {
ctx context.Context // from cmd.Context(), propagated through the call chain
Config *core.CliConfig
Cmd *cobra.Command
Format string
JqExpr string // --jq expression; empty = no filter
outputErrOnce sync.Once // guards first-error capture in Out()/OutFormat()
outputErr error // deferred error from jq filtering; written at most once
botOnly bool // set by framework for bot-only shortcuts
resolvedAs core.Identity // effective identity resolved by framework
declaredScopes []string // shortcut-declared scopes for the resolved identity
reportOnce sync.Once // lazily initializes the command-scoped upload report budget
reportBudget *fileevent.Budget // shared by every upload report in this command
Factory *cmdutil.Factory // injected by framework
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
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
inputResolved map[string]bool // flags whose value was replaced by @file / stdin content in resolveInputFlags; see InputResolvedFromSource
offline bool // dry-run context: API and credential-backed scope checks are disabled
}
// ── Identity ──
// As returns the current identity.
// For bot-only shortcuts, always returns AsBot.
// For dual-auth shortcuts, uses the resolved identity (respects default-as config).
func (ctx *RuntimeContext) As() core.Identity {
if ctx.botOnly {
return core.AsBot
}
if ctx.resolvedAs.IsBot() {
return core.AsBot
}
if ctx.resolvedAs != "" {
return ctx.resolvedAs
}
return core.AsUser
}
// PresentError renders a typed producer error for this command tree before a
// shortcut copies its fields into a result payload.
func (ctx *RuntimeContext) PresentError(err error) error {
if ctx == nil {
return err
}
return ctx.Factory.PresentError(err, cmdutil.ErrorPresentationOptions{
Identity: ctx.As(),
DeclaredScopes: func() []string {
return slices.Clone(ctx.declaredScopes)
},
})
}
// IsBot returns true if current identity is bot.
func (ctx *RuntimeContext) IsBot() bool {
return ctx.As().IsBot()
}
// Command returns the shortcut command name as cobra knows it (e.g.
// "+pivot-create"). Used by per-service helpers (e.g. sheets schema
// validation) that key off the shortcut identity.
func (ctx *RuntimeContext) Command() string {
if ctx.Cmd == nil {
return ""
}
return ctx.Cmd.Name()
}
// CommandPath returns the full command path as Cobra knows it. Consumers that
// expose command identity externally can normalize the binary name as needed.
func (ctx *RuntimeContext) CommandPath() string {
if ctx == nil || ctx.Cmd == nil {
return ""
}
return ctx.Cmd.CommandPath()
}
// FileEventBudget returns the reporting budget shared by every upload within
// this command invocation.
func (ctx *RuntimeContext) FileEventBudget() *fileevent.Budget {
if ctx == nil {
return nil
}
ctx.reportOnce.Do(func() {
ctx.reportBudget = fileevent.NewBudget()
})
return ctx.reportBudget
}
// UserOpenId returns the current user's open_id from config.
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
// Lang returns the user's preference as a canonical locale, or "" if unset or
// unrecognized; callers choose their own fallback.
func (ctx *RuntimeContext) Lang() i18n.Lang {
lang, _ := i18n.Parse(string(ctx.Config.Lang))
return lang
}
// BotInfo holds bot identity metadata fetched lazily from /bot/v3/info.
type BotInfo struct {
OpenID string
AppName string
}
// BotInfo returns the bot's open_id and display name, fetched lazily from /bot/v3/info.
// Unlike UserOpenId() (which reads from config), this requires a network call and may fail.
// Thread-safe via sync.OnceValues; the API is called at most once per RuntimeContext.
func (ctx *RuntimeContext) BotInfo() (*BotInfo, error) {
if ctx.offline {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "BotInfo is unavailable during dry-run")
}
if ctx.botInfoFunc == nil {
return nil, fmt.Errorf("BotInfo not available (runtime context not fully initialized)")
}
return ctx.botInfoFunc()
}
// fetchBotInfo calls /bot/v3/info using bot identity and parses the response.
func (ctx *RuntimeContext) fetchBotInfo() (*BotInfo, error) {
if !ctx.Config.CanBot() {
return nil, fmt.Errorf("fetch bot info: bot identity is not available in current credential context")
}
resp, err := ctx.DoAPIAsBot(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/bot/v3/info",
})
if err != nil {
return nil, fmt.Errorf("fetch bot info: %w", err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("fetch bot info: HTTP %d", resp.StatusCode)
}
// /open-apis/bot/v3/info returns `{code, msg, bot: {...}}` — the bot
// payload is under "bot", not "data" as the newer Lark API convention.
var envelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
OpenID string `json:"open_id"`
AppName string `json:"app_name"`
} `json:"bot"`
}
if err := json.Unmarshal(resp.RawBody, &envelope); err != nil {
return nil, fmt.Errorf("fetch bot info: unmarshal: %w", err)
}
if envelope.Code != 0 {
return nil, fmt.Errorf("fetch bot info: [%d] %s", envelope.Code, envelope.Msg)
}
if envelope.Data.OpenID == "" {
return nil, fmt.Errorf("fetch bot info: open_id is empty")
}
return &BotInfo{OpenID: envelope.Data.OpenID, AppName: envelope.Data.AppName}, nil
}
// Ctx returns the context.Context propagated from cmd.Context().
func (ctx *RuntimeContext) Ctx() context.Context { return ctx.ctx }
// getAPIClient returns the cached APIClient, creating it on first use.
// Thread-safe via sync.OnceValues (initialized in newRuntimeContext).
// Falls back to direct construction for test contexts that bypass newRuntimeContext.
func (ctx *RuntimeContext) getAPIClient() (*client.APIClient, error) {
if ctx.offline {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "OpenAPI requests are unavailable during dry-run")
}
if ctx.apiClientFunc != nil {
return ctx.apiClientFunc()
}
return ctx.Factory.NewAPIClientWithConfig(ctx.Config)
}
// AccessToken returns a valid access token for the current identity.
// For user: returns user access token (with auto-refresh).
// For bot: returns tenant access token.
func (ctx *RuntimeContext) AccessToken() (string, error) {
result, err := ctx.resolveAccessToken()
if err != nil {
return "", err
}
return result.Token, nil
}
// resolveAccessToken retains the request-scoped origin metadata needed by
// direct HTTP paths such as MCP. Callers that only need the token value should
// use AccessToken.
func (ctx *RuntimeContext) resolveAccessToken() (*credential.TokenResult, error) {
result, err := ctx.Factory.Credential.ResolveToken(ctx.ctx, credential.NewTokenSpec(ctx.As(), ctx.Config.AppID))
if err != nil {
// ResolveToken classifies its own failures (config/api); pass those
// through so a typed lower-layer error is not flattened to token_invalid.
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewAuthenticationError(errs.SubtypeTokenInvalid, "failed to get access token: %s", err).WithCause(err)
}
if result == nil || result.Token == "" {
return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "no access token available for %s", ctx.As())
}
return result, nil
}
// LarkSDK returns the eagerly-initialized Lark SDK client.
func (ctx *RuntimeContext) LarkSDK() *lark.Client {
return ctx.larkSDK
}
// EnsureScopes runs the same pre-flight scope check used by the framework
// before Validate, but on a caller-supplied set of scopes. Use it from a
// shortcut's Validate to enforce conditional scope requirements that depend
// on flag values (e.g. --delete-remote needing space:document:delete) so a
// destructive operation never starts on a token that can't finish it.
//
// Behavior matches checkShortcutScopes: when no token is available or the
// resolver doesn't expose scope metadata, this is a silent no-op — the
// downstream API call still surfaces missing_scope at runtime.
func (ctx *RuntimeContext) EnsureScopes(scopes []string) error {
if ctx.offline {
return nil
}
return checkShortcutScopes(ctx.Factory, ctx.ctx, ctx.As(), ctx.Config, scopes)
}
// ── Flag accessors ──
// Str returns a string flag value.
func (ctx *RuntimeContext) Str(name string) string {
v, _ := ctx.Cmd.Flags().GetString(name)
return v
}
// Bool returns a bool flag value.
func (ctx *RuntimeContext) Bool(name string) bool {
v, _ := ctx.Cmd.Flags().GetBool(name)
return v
}
// Int returns an int flag value.
func (ctx *RuntimeContext) Int(name string) int {
v, _ := ctx.Cmd.Flags().GetInt(name)
return v
}
// Float64 returns a float64 flag value (non-integer numbers).
func (ctx *RuntimeContext) Float64(name string) float64 {
v, _ := ctx.Cmd.Flags().GetFloat64(name)
return v
}
// IntArray returns an int-array flag value (repeated flag, also supports CSV splitting).
func (ctx *RuntimeContext) IntArray(name string) []int {
v, _ := ctx.Cmd.Flags().GetIntSlice(name)
return v
}
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
func (ctx *RuntimeContext) StrArray(name string) []string {
v, _ := ctx.Cmd.Flags().GetStringArray(name)
return v
}
// StrSlice returns a string-slice flag value (supports CSV splitting and repeated flags).
func (ctx *RuntimeContext) StrSlice(name string) []string {
v, _ := ctx.Cmd.Flags().GetStringSlice(name)
return v
}
// Changed reports whether parsing or compatibility normalization populated the
// named flag, as opposed to the flag carrying only its default value. During a
// Normalize hook, check legacy and canonical spellings before SetCanonical to
// distinguish which spelling the caller supplied.
func (ctx *RuntimeContext) Changed(name string) bool {
f := ctx.Cmd.Flags().Lookup(name)
if f == nil {
return false
}
return f.Changed
}
// ── API helpers ──
// CallAPITyped calls the Lark API using the current identity (ctx.As()) via
// the SDK request path (buildRequest → APIClient.DoAPI → DoSDKRequest) and
// returns the "data" object, classifying failures into typed errs.* errors via
// errclass.BuildAPIError.
//
// A transport / auth error from the client boundary is already typed and passes
// through unchanged; a non-zero API response code is classified into a typed
// error carrying subtype / code / log_id.
//
// It lifts x-tt-logid from the response header (which the body-only parse drops)
// so log_id surfaces on the typed error even when the server returns it only in
// the header.
func (ctx *RuntimeContext) CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
ac, err := ctx.getAPIClient()
if err != nil {
return nil, typedOrInternal(err)
}
resp, err := ac.DoAPI(ctx.ctx, ctx.buildRequest(method, url, params, data))
if err != nil {
return nil, typedOrInternal(err)
}
return ctx.ClassifyAPIResponse(resp)
}
// ClassifyAPIResponse turns a raw *larkcore.ApiResp into the "data" object or a
// typed errs.* error. It is the shared response classifier for typed API paths
// — used by CallAPITyped and by callers that drive the request themselves
// (e.g. file upload via DoAPI). It:
//
// 1. parses the JSON body; an unparseable body on an HTTP error status (a
// gateway 5xx text/html page, an empty body, a missing Content-Type) is
// classified by status — 5xx → retryable network/server_error, 404 →
// not_found, other 4xx → api error — not a misleading invalid-response
// internal error;
// 2. rejects a top-level non-object JSON ([], null, scalar) as an
// invalid-response internal error — never a silent success ack;
// 3. lifts x-tt-logid from the response header onto the typed error so log_id
// surfaces even when the body omits it;
// 4. classifies a non-zero API code via errclass.BuildAPIError, and treats any
// HTTP error status that parsed to code==0 as a status error.
//
// The success "data" object is returned untouched. On a non-zero API code the
// data is returned alongside the typed error, since the response can still
// carry fields a caller needs on failure (e.g. the file_token an overwrite
// returned, for token-stability handling).
func (ctx *RuntimeContext) ClassifyAPIResponse(resp *larkcore.ApiResp) (map[string]interface{}, error) {
return ClassifyAPIResponseWith(resp, ctx.APIClassifyContext())
}
// ClassifyAPIResponseWith is the RuntimeContext-free form of
// ClassifyAPIResponse for callers that drive the request outside a running
// shortcut (e.g. a cobra command holding only a factory) and supply their own
// classification context.
func ClassifyAPIResponseWith(resp *larkcore.ApiResp, cc errclass.ClassifyContext) (map[string]interface{}, error) {
logID, _ := logIDFromHeader(resp)["log_id"].(string)
result, parseErr := client.ParseJSONResponse(resp)
if parseErr != nil {
if resp.StatusCode >= 400 {
return nil, httpStatusError(resp.StatusCode, resp.RawBody, logID)
}
return nil, client.WrapJSONResponseParseError(parseErr, resp.RawBody)
}
resultMap, ok := result.(map[string]interface{})
if !ok {
e := errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a non-object JSON response")
if logID != "" {
e = e.WithLogID(logID)
}
return nil, e
}
if logID != "" {
if _, present := resultMap["log_id"]; !present {
resultMap["log_id"] = logID
}
}
out, _ := resultMap["data"].(map[string]interface{})
if apiErr := errclass.BuildAPIError(resultMap, cc); apiErr != nil {
return out, apiErr
}
if resp.StatusCode >= 400 {
return out, httpStatusError(resp.StatusCode, resp.RawBody, logID)
}
return out, nil
}
// httpStatusError classifies an HTTP error status whose body is not a usable
// API envelope: 5xx → retryable network/server_error, 404 → not_found, other
// 4xx → api error. The x-tt-logid (when present) is attached for diagnosis.
func httpStatusError(status int, rawBody []byte, logID string) error {
body := TruncateStr(strings.TrimSpace(string(rawBody)), 500)
if status >= 500 {
e := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP %d: %s", status, body).WithCode(status).WithRetryable()
if logID != "" {
e = e.WithLogID(logID)
}
return e
}
subtype := errs.SubtypeUnknown
if status == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
e := errs.NewAPIError(subtype, "HTTP %d: %s", status, body).WithCode(status)
if logID != "" {
e = e.WithLogID(logID)
}
return e
}
// typedOrInternal passes an already-typed errs.* error through unchanged and
// lifts a still-untyped one to a typed internal error, so CallAPITyped never
// returns a bare/legacy error.
func typedOrInternal(err error) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.WrapInternal(err)
}
// APIClassifyContext builds the errclass.ClassifyContext for the running command
// from the runtime config and resolved identity.
func (ctx *RuntimeContext) APIClassifyContext() errclass.ClassifyContext {
larkCmd := ""
if ctx.Cmd != nil {
larkCmd = strings.TrimPrefix(ctx.Cmd.CommandPath(), "lark ")
}
return errclass.ClassifyContext{
Brand: string(ctx.Config.Brand),
AppID: ctx.Config.AppID,
Identity: string(ctx.As()),
LarkCmd: larkCmd,
}
}
// RawAPI uses an internal HTTP wrapper with limited control over request/response.
// Prefer DoAPI for new code — it calls the Lark SDK directly and supports file upload/download options.
//
// RawAPI calls the Lark API using the current identity (ctx.As()) and returns raw result for manual error handling.
func (ctx *RuntimeContext) RawAPI(method, url string, params map[string]interface{}, data interface{}) (interface{}, error) {
return ctx.callRaw(method, url, params, data)
}
// PaginateAll fetches all pages and returns a single merged result.
func (ctx *RuntimeContext) PaginateAll(method, url string, params map[string]interface{}, data interface{}, opts client.PaginationOptions) (interface{}, error) {
ac, err := ctx.getAPIClient()
if err != nil {
return nil, err
}
req := ctx.buildRequest(method, url, params, data)
return ac.PaginateAll(ctx.ctx, req, opts)
}
// StreamPages fetches all pages and streams each page's items via onItems.
// Returns the last result (for error checking) and whether any list items were found.
func (ctx *RuntimeContext) StreamPages(method, url string, params map[string]interface{}, data interface{}, onItems func([]interface{}), opts client.PaginationOptions) (interface{}, bool, error) {
ac, err := ctx.getAPIClient()
if err != nil {
return nil, false, err
}
req := ctx.buildRequest(method, url, params, data)
return ac.StreamPages(ctx.ctx, req, func(items []interface{}) error {
onItems(items)
return nil
}, opts)
}
// buildRequest converts shortcut inputs into the raw request consumed by APIClient.
func (ctx *RuntimeContext) buildRequest(method, url string, params map[string]interface{}, data interface{}) client.RawApiRequest {
req := client.RawApiRequest{
Method: method,
URL: url,
Params: params,
Data: data,
As: ctx.As(),
}
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
req.ExtraOpts = append(req.ExtraOpts, optFn)
}
return req
}
// callRaw executes a shortcut request and returns the unprojected API response.
func (ctx *RuntimeContext) callRaw(method, url string, params map[string]interface{}, data interface{}) (interface{}, error) {
ac, err := ctx.getAPIClient()
if err != nil {
return nil, err
}
return ac.CallAPI(ctx.ctx, ctx.buildRequest(method, url, params, data))
}
// DoAPI executes a raw Lark SDK request with automatic auth handling.
// Unlike CallAPI which parses JSON and extracts the "data" field, DoAPI returns
// the raw *larkcore.ApiResp — suitable for file downloads (WithFileDownload)
// and uploads (WithFileUpload).
//
// Auth resolution is delegated to APIClient.DoSDKRequest to avoid duplicating
// the identity → token logic across the generic and shortcut API paths.
func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
return ctx.DoAPIWithContext(ctx.ctx, req, opts...)
}
// DoAPIWithContext executes a raw Lark SDK request using callCtx for request
// cancellation and deadlines while preserving the shortcut's resolved identity.
// A nil callCtx falls back to the RuntimeContext's command-wide context.
func (ctx *RuntimeContext) DoAPIWithContext(callCtx context.Context, req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
if callCtx == nil {
callCtx = ctx.ctx
}
ac, err := ctx.getAPIClient()
if err != nil {
return nil, err
}
if optFn := cmdutil.ShortcutHeaderOpts(callCtx); optFn != nil {
opts = append(opts, optFn)
}
return ac.DoSDKRequest(callCtx, req, ctx.As(), opts...)
}
// DoAPIAsBot executes a raw Lark SDK request using bot identity (tenant access token),
// regardless of the current --as flag. Use this for APIs that must always be called
// with TAT even when the surrounding shortcut runs as user.
func (ctx *RuntimeContext) DoAPIAsBot(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
ac, err := ctx.getAPIClient()
if err != nil {
return nil, err
}
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
opts = append(opts, optFn)
}
return ac.DoSDKRequest(ctx.ctx, req, core.AsBot, opts...)
}
// DoAPIStream executes a streaming HTTP request via APIClient.DoStream.
// Unlike DoAPI (which buffers the full body via the SDK), DoAPIStream returns
// a live *http.Response whose Body is an io.Reader for streaming consumption.
// HTTP errors (status >= 400) are handled internally by DoStream.
func (ctx *RuntimeContext) DoAPIStream(callCtx context.Context, req *larkcore.ApiReq, opts ...client.Option) (*http.Response, error) {
ac, err := ctx.getAPIClient()
if err != nil {
return nil, err
}
base := []client.Option{
client.WithHeaders(cmdutil.BaseSecurityHeaders()),
}
if h := cmdutil.ShortcutHeaders(ctx.ctx); h != nil {
base = append(base, client.WithHeaders(h))
}
return ac.DoStream(callCtx, req, ctx.As(), append(base, opts...)...)
}
// DoAPIJSONTyped issues a larkcore.ApiReq request, parses the JSON response,
// and classifies failures into typed errs.* errors via ClassifyAPIResponse,
// which lifts MissingScopes / ConsoleURL / Identity onto the typed error at the
// source and merges the response log id into the returned data. A transport /
// auth error from the client boundary is already typed and passes through
// unchanged; a non-zero API code is classified with subtype / code / log_id.
func (ctx *RuntimeContext) DoAPIJSONTyped(method, apiPath string, query larkcore.QueryParams, body any) (map[string]any, error) {
req := &larkcore.ApiReq{
HttpMethod: method,
ApiPath: apiPath,
QueryParams: query,
}
if body != nil {
req.Body = body
}
resp, err := ctx.DoAPI(req)
if err != nil {
return nil, typedOrInternal(err)
}
return ctx.ClassifyAPIResponse(resp)
}
// logIDFromHeader extracts x-tt-logid from response headers and returns it as a detail map.
// Returns nil if the header is absent.
func logIDFromHeader(resp *larkcore.ApiResp) map[string]any {
if resp == nil {
return nil
}
logID := resp.Header.Get("x-tt-logid")
if logID == "" {
return nil
}
return map[string]any{"log_id": logID}
}
// ── IO access ──
// IO returns the IOStreams from the Factory.
func (ctx *RuntimeContext) IO() *cmdutil.IOStreams {
return ctx.Factory.IOStreams
}
// StartSpinner shows a braille spinner with elapsed time on stderr for a slow
// operation, until the returned stop() runs. It is a no-op unless stderr is an
// interactive terminal, so pipes / CI / captured output emit nothing and stdout
// (JSON/pretty) is never polluted — hence it is shown in JSON mode too. Call
// stop() before printing the result; stop() is safe to call multiple times
// (e.g. `defer stop()` plus an explicit call on the success path).
func (ctx *RuntimeContext) StartSpinner(label string) func() {
io := ctx.IO()
if io == nil {
return func() {}
}
return output.StartSpinner(io.ErrOut, io.StderrIsTerminal, label)
}
// FileIO resolves the FileIO using the current execution context.
// Falls back to the globally registered provider when Factory or its
// FileIOProvider is nil (e.g. in lightweight test helpers).
func (ctx *RuntimeContext) FileIO() fileio.FileIO {
if ctx != nil && ctx.Factory != nil {
if fio := ctx.Factory.ResolveFileIO(ctx.ctx); fio != nil {
return fio
}
}
if p := fileio.GetProvider(); p != nil {
c := context.Background()
if ctx != nil {
c = ctx.ctx
}
return p.ResolveFileIO(c)
}
return nil
}
// ResolveSavePath resolves a relative path to a validated absolute path via
// FileIO.ResolvePath. It returns an error if no FileIO provider is registered
// or if the path fails validation (e.g. traversal, symlink escape).
func (ctx *RuntimeContext) ResolveSavePath(path string) (string, error) {
fio := ctx.FileIO()
if fio == nil {
return "", fmt.Errorf("no file I/O provider registered")
}
resolved, err := fio.ResolvePath(path)
if err != nil {
return "", fmt.Errorf("resolve save path: %w", err)
}
if resolved == "" {
return "", fmt.Errorf("resolve save path: empty result for %q", path)
}
return resolved, nil
}
// WrapOpenError matches a FileIO.Open/Stat error and wraps it with the
// caller-provided message prefix.
func WrapOpenError(err error, pathMsg, readMsg string) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return fmt.Errorf("%s: %w", pathMsg, err)
}
return fmt.Errorf("%s: %w", readMsg, err)
}
// WrapInputStatErrorTyped wraps a FileIO.Stat/Open error for input file
// validation, returning a typed validation error with the appropriate message:
// - Path validation failures → "unsafe file path: ..."
// - Other errors → readMsg prefix (default "cannot read file")
//
// Pass an optional readMsg to override the non-path-validation message prefix.
func WrapInputStatErrorTyped(err error, readMsg ...string) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).
WithCause(err)
}
msg := "cannot read file"
if len(readMsg) > 0 && readMsg[0] != "" {
msg = readMsg[0]
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).
WithCause(err)
}
// WrapSaveErrorTyped maps a FileIO.Save error to typed validation/internal errors.
// Non-path failures always emit the canonical "internal" wire type; call sites
// migrating from a custom category (e.g. "io", "api_error") change their
// envelope's type field.
func WrapSaveErrorTyped(err error) error {
return WrapSaveErrorTypedForFlag(err, "")
}
// WrapSaveErrorTypedForFlag is the parameter-aware form used by commands whose
// output path is a user-facing flag. Keeping the flag at the call site avoids
// attributing download/save errors from unrelated commands to --output-path.
func WrapSaveErrorTypedForFlag(err error, param string) error {
if err == nil {
return nil
}
if _, ok := errs.ProblemOf(err); ok {
return err
}
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
verr := errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err)
if param != "" {
verr = verr.WithParam(param)
}
return verr.WithCause(err)
case errors.As(err, &me):
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).
WithCause(err)
default:
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).
WithCause(err)
}
}
// ValidatePath checks that path is a valid relative input path within the
// working directory by delegating to FileIO.Stat. Returns nil if the path is
// valid or does not exist yet; returns an error only for illegal paths
// (absolute, traversal, symlink escape, control chars).
//
// NOTE: This validates input (read) paths via SafeInputPath semantics inside
// the FileIO implementation. For output (write) path validation, use
// ResolveSavePath instead.
func (ctx *RuntimeContext) ValidatePath(path string) error {
fio := ctx.FileIO()
if fio == nil {
return fmt.Errorf("no file I/O provider registered")
}
if _, err := fio.Stat(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// ── Output helpers ──
// newEmitter creates an output emitter configured for the active runtime.
func (ctx *RuntimeContext) newEmitter() *output.Emitter {
streams := ctx.IO()
return output.NewEmitter(output.EmitterConfig{
Out: streams.Out,
ErrOut: streams.ErrOut,
CommandPath: ctx.Cmd.CommandPath(),
Identity: string(ctx.As()),
ColorEnabled: streams.OutIsTerminal,
NoticeProvider: output.GetNotice,
})
}
// handleEmitterError records output failures without corrupting the command data stream.
func (ctx *RuntimeContext) handleEmitterError(err error) {
if err == nil {
return
}
var cs *errs.ContentSafetyError
if ctx.JqExpr != "" && !errors.As(err, &cs) {
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
}
ctx.outputErrOnce.Do(func() { ctx.outputErr = err })
}
// OutputError returns the first deferred output failure captured by Out,
// OutRaw, or OutFormat. Commands that create local artifacts can use it to
// roll those artifacts back before returning the final command error.
func (ctx *RuntimeContext) OutputError() error {
return ctx.outputErr
}
// wrapLegacyPrettyRenderer adapts a writer-only renderer to the current output contract.
func wrapLegacyPrettyRenderer(prettyFn func(w io.Writer)) output.PrettyRenderer {
if prettyFn == nil {
return nil
}
return func(w io.Writer, _ bool) error {
prettyFn(w)
return nil
}
}
// Out prints a success JSON envelope to stdout.
func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
Format: "",
Raw: false,
JQ: ctx.JqExpr,
Meta: meta,
}))
}
// OutRaw prints a success JSON envelope to stdout with HTML escaping disabled.
// Use this instead of Out when the data contains XML/HTML content (e.g. document bodies)
// that should be preserved as-is in JSON output.
func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
Format: "",
Raw: true,
JQ: ctx.JqExpr,
Meta: meta,
}))
}
// OutPartialFailure writes an ok:false multi-status result envelope to stdout
// and returns the partial-failure exit signal. Use it for batch operations
// where some items failed but the per-item outcomes are the primary output:
// the full result (summary + per-item statuses) stays machine-readable on
// stdout, the process exits non-zero, and nothing is written to stderr.
//
// It is the typed alternative to `Out(...)` + `output.ErrBare(...)` — the
// envelope's ok field honestly reports failure instead of a misleading
// ok:true, and the exit signal is distinct from ErrBare (the
// stdout-carries-the-answer silent-exit signal).
func (ctx *RuntimeContext) OutPartialFailure(data interface{}, meta *output.Meta) error {
ctx.handleEmitterError(ctx.newEmitter().PartialFailure(data, output.EmitOptions{
Format: "",
Raw: false,
JQ: ctx.JqExpr,
Meta: meta,
}))
if ctx.outputErr != nil {
return ctx.outputErr
}
return output.PartialFailure(output.ExitAPI)
}
// OutFormat prints output based on --format flag.
// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue.
// When JqExpr is set, envelope filtering takes precedence over format.
// The Emitter handles content safety scanning for every format.
func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
Format: ctx.Format,
Raw: false,
JQ: ctx.JqExpr,
Meta: meta,
Pretty: wrapLegacyPrettyRenderer(prettyFn),
}))
}
// OutFormatRaw is like OutFormat but with HTML escaping disabled in JSON output.
// Use this when the data contains XML/HTML content that should be preserved as-is.
func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
Format: ctx.Format,
Raw: true,
JQ: ctx.JqExpr,
Meta: meta,
Pretty: wrapLegacyPrettyRenderer(prettyFn),
}))
}
// ── Scope pre-check ──
// checkScopePrereqs performs a fast local check: does the token
// contain all scopes declared by the shortcut? Returns the missing ones.
// If scope data is unavailable, returns nil (let the API call handle it).
func checkScopePrereqs(f *cmdutil.Factory, ctx context.Context, appID string, identity core.Identity, required []string) ([]string, error) {
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(identity, appID))
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
return nil, nil
}
if result == nil || result.Scopes == "" {
return nil, nil
}
return auth.MissingScopes(result.Scopes, required), nil
}
// enhancePermissionError enriches a permission / auth error with the
// shortcut's declared required scopes so the user knows exactly what to do.
//
// Detection is typed: an error qualifies when it (or any error in its Unwrap
// chain) is *errs.PermissionError. The previous implementation scanned the
// upstream message text for keywords like "permission" / "scope" /
// "unauthorized", which was brittle to canonical-message rewrites; routing on
// the typed shape decouples this helper from the wording.
func enhancePermissionError(err error, requiredScopes []string) error {
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
return err
}
permErr.WithMissingScopes(requiredScopes...)
if permErr.Identity == "" {
permErr.WithIdentity(string(core.AsUser))
}
// Discard any generic classifier hint now that this shortcut has supplied
// the authoritative scope facts. The root presenter will rebuild recovery
// from those facts for its own command surface.
permErr.Hint = ""
return err
}
// ── Mounting ──
// Mount registers the shortcut on a parent command.
func (s Shortcut) Mount(parent *cobra.Command, f *cmdutil.Factory) {
s.MountWithContext(context.Background(), parent, f)
}
// MountWithContext registers a shortcut while preserving the caller-provided context.
func (s Shortcut) MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
if s.Execute != nil || s.typed != nil {
s.mountDeclarative(ctx, parent, f)
}
}
// mountDeclarative builds and registers the Cobra command described by a Shortcut.
func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
shortcut := s
if shortcut.typed != nil {
if err := validateTypedFlagMountPlan(shortcut.typed, shortcut.PrintFlagSchema != nil, typedRisk(shortcut.Risk)); err != nil {
panic(fmt.Sprintf("typed shortcut %s %s: %v", shortcut.Service, shortcut.Command, err))
}
}
if len(shortcut.AuthTypes) == 0 {
shortcut.AuthTypes = []string{"user"}
}
botOnly := len(shortcut.AuthTypes) == 1 && shortcut.AuthTypes[0] == "bot"
cmd := &cobra.Command{
Use: shortcut.Command,
Short: shortcut.Description,
Hidden: shortcut.Hidden,
Args: rejectPositionalArgs(),
RunE: func(cmd *cobra.Command, _ []string) error {
if handled, err := runShortcutFlagSchema(cmd, f, &shortcut); handled {
return err
}
if shortcut.typed != nil {
return runTypedMountedShortcut(cmd, f, &shortcut, botOnly)
}
return runShortcut(cmd, f, &shortcut, botOnly)
},
}
if shortcut.PrintFlagSchema != nil || shortcut.OnInvoke != nil {
onInvoke := shortcut.OnInvoke
relaxRequiredForSchema := shortcut.PrintFlagSchema != nil
// PreRunE runs before cobra's ValidateRequiredFlags. Two opt-in uses:
// - OnInvoke: fire a side effect (e.g. a deprecation notice) that must
// surface even when the call later fails on a missing required flag.
// - --print-schema: pure local introspection; relax the required-flag
// gate so callers don't fill in unrelated flags just to ask for a
// schema (clearing the annotation here is the supported opt-out).
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
if onInvoke != nil {
onInvoke()
}
if relaxRequiredForSchema {
if want, _ := c.Flags().GetBool("print-schema"); want {
c.Flags().VisitAll(func(fl *pflag.Flag) {
// Schema inspection is a local metadata operation. Bypass both
// individual required flags and Cobra groups so business inputs
// are never needed merely to inspect a complex field.
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
delete(fl.Annotations, "cobra_annotation_one_required")
delete(fl.Annotations, "cobra_annotation_mutually_exclusive")
delete(fl.Annotations, "cobra_annotation_required_if_others_set")
})
}
}
return nil
}
}
cmdmeta.SetSource(cmd, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(cmd, shortcut.Service, shortcut.Command)
cmdmeta.SetDeclaredScopes(cmd, map[string][]string{
"user": shortcut.DeclaredScopesForIdentity("user"),
"bot": shortcut.DeclaredScopesForIdentity("bot"),
})
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
cmdutil.SetTips(cmd, shortcut.Tips)
cmdutil.SetRisk(cmd, shortcut.Risk)
parent.AddCommand(cmd)
if shortcut.PostMount != nil {
var typedSnapshot typedMountSnapshot
if shortcut.typed != nil {
typedSnapshot = captureTypedMountSnapshot(cmd)
}
shortcut.PostMount(cmd)
if shortcut.typed != nil {
if err := validateTypedPostMount(cmd, typedSnapshot); err != nil {
panic(fmt.Sprintf("typed shortcut %s %s: %v", shortcut.Service, shortcut.Command, err))
}
}
}
installFlagAliases(cmd, shortcut.Flags)
if shortcut.typed != nil {
installTypedAnnotations(cmd, shortcut.typed)
installTypedHelp(cmd, shortcut.typed)
}
}
func runTypedMountedShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
dryRun, _ := cmd.Flags().GetBool("dry-run")
if dryRun {
as, err := resolveShortcutIdentity(cmd, f, s)
if err != nil {
return err
}
config, err := f.Config()
if err != nil {
return err
}
rctx := newDryRunRuntimeContext(cmd, f, s, config, as, botOnly)
if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil {
return err
}
return runTypedShortcut(f, rctx, s)
}
as, err := resolveShortcutIdentity(cmd, f, s)
if err != nil {
return err
}
config, err := f.Config()
if err != nil {
return err
}
if err := checkShortcutScopes(f, cmd.Context(), as, config, s.ScopesForIdentity(string(as))); err != nil {
return err
}
rctx, err := newRuntimeContext(cmd, f, s, config, as, botOnly)
if err != nil {
return err
}
if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil {
return err
}
return runTypedShortcut(f, rctx, s)
}
func installTypedAnnotations(cmd *cobra.Command, command *compiledCommand) {
for _, field := range command.fields {
if field.cli.Deprecated != "" {
_ = cmd.Flags().MarkDeprecated(field.name, field.cli.Deprecated)
}
for _, alias := range field.cli.Aliases {
if alias.Mode != typedAliasIndependent || !alias.Deprecated {
continue
}
_ = cmd.Flags().MarkDeprecated(alias.Name, "use --"+field.name+" instead")
}
}
for _, relation := range command.relations {
if relation.stage != typedStageSourcePreRun || relation.presence != typedPresenceExplicit {
continue
}
names := make([]string, 0, len(relation.fields))
for _, index := range relation.fields {
names = append(names, command.fields[index].name)
}
switch relation.kind {
case typedRelationExactlyOne:
cmd.MarkFlagsOneRequired(names...)
cmd.MarkFlagsMutuallyExclusive(names...)
case typedRelationAtLeastOne:
cmd.MarkFlagsOneRequired(names...)
case typedRelationCoOccur:
cmd.MarkFlagsRequiredTogether(names...)
case typedRelationConflicts:
cmd.MarkFlagsMutuallyExclusive(names...)
}
}
}
func runShortcutFlagSchema(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (bool, error) {
if s.PrintFlagSchema == nil {
return false, nil
}
want, _ := cmd.Flags().GetBool("print-schema")
if !want {
return false, nil
}
flagName, _ := cmd.Flags().GetString("flag-name")
out, err := s.PrintFlagSchema(strings.TrimSpace(flagName))
if err != nil {
// PrintFlagSchema implementations may return bare errors; wrap those so
// this agent-facing local introspection path stays machine-readable.
if !errs.IsTyped(err) {
err = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
}
return true, err
}
if len(out) > 0 {
fmt.Fprintln(f.IOStreams.Out, string(out))
}
return true, nil
}
// runShortcut is the execution pipeline for a declarative shortcut.
// Each step is a clear phase: identity → config → scopes → runtime →
// canonical validation → execute.
func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
as, err := resolveShortcutIdentity(cmd, f, s)
if err != nil {
return err
}
config, err := f.Config()
if err != nil {
return err
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, as, config, false)
if err := checkShortcutScopes(f, cmd.Context(), as, config, s.ScopesForIdentity(string(as))); err != nil {
return err
}
rctx, err := newRuntimeContext(cmd, f, s, config, as, botOnly)
if err != nil {
return err
}
if s.Normalize != nil {
// Normalize is opt-in and consumes resolved values. Shortcuts without a
// normalizer retain the established enum-before-input execution order.
if err := resolveInputFlags(rctx, s.Flags); err != nil {
return attributeAliasValidationError(rctx, err)
}
flagContext := rctx.FlagContext()
if err := s.Normalize(rctx.ctx, flagContext); err != nil {
return attributeAliasValidationError(rctx, err)
}
}
if err := validateEnumFlags(rctx, s.Flags); err != nil {
return attributeAliasValidationError(rctx, err)
}
if s.Normalize == nil {
if err := resolveInputFlags(rctx, s.Flags); err != nil {
return attributeAliasValidationError(rctx, err)
}
}
if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil {
return err
}
if s.Validate != nil {
if err := s.Validate(rctx.ctx, rctx); err != nil {
return attributeAliasValidationError(rctx, err)
}
}
if rctx.Bool("dry-run") {
return handleShortcutDryRun(f, rctx, s)
}
if s.Risk == "high-risk-write" && !rctx.Bool("yes") {
return cmdutil.RequireConfirmation(s.Service + " " + s.Command)
}
if err := s.Execute(rctx.ctx, rctx); err != nil {
return attributeAliasValidationError(rctx, err)
}
return rctx.outputErr
}
// resolveShortcutIdentity selects the effective identity for a shortcut invocation.
func resolveShortcutIdentity(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (core.Identity, error) {
// Step 1: determine identity (--as > default-as > auto-detect).
asFlag, _ := cmd.Flags().GetString("as")
as := f.ResolveAs(cmd.Context(), cmd, core.Identity(asFlag))
if err := f.CheckStrictMode(cmd.Context(), as); err != nil {
return "", err
}
// Step 2: check if this shortcut supports the resolved identity.
if err := f.CheckIdentity(as, s.AuthTypes); err != nil {
return "", err
}
return as, nil
}
// checkShortcutScopes verifies that the selected identity satisfies the shortcut scope contract.
func checkShortcutScopes(f *cmdutil.Factory, ctx context.Context, as core.Identity, config *core.CliConfig, scopes []string) error {
if len(scopes) == 0 {
return nil
}
missing, err := checkScopePrereqs(f, ctx, config.AppID, as, scopes)
if err != nil {
return err
}
if len(missing) == 0 {
return nil
}
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scope(s): %s", strings.Join(missing, ", ")).
WithIdentity(string(as)).
WithMissingScopes(missing...)
}
// newRuntimeContext assembles the dependencies and resolved identity for one shortcut execution.
func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) (*RuntimeContext, error) {
rctx := newRuntimeContextBase(cmd, f, s, config, as, botOnly)
rctx.apiClientFunc = sync.OnceValues(func() (*client.APIClient, error) {
return f.NewAPIClientWithConfig(config)
})
rctx.botInfoFunc = sync.OnceValues(rctx.fetchBotInfo)
sdk, err := f.LarkClient()
if err != nil {
return nil, err
}
rctx.larkSDK = sdk
return rctx, nil
}
func newDryRunRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) *RuntimeContext {
rctx := newRuntimeContextBase(cmd, f, s, config, as, botOnly)
rctx.offline = true
return rctx
}
func newRuntimeContextBase(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) *RuntimeContext {
ctx := cmd.Context()
ctx = cmdutil.ContextWithShortcut(ctx, s.Service+":"+s.Command, uuid.New().String())
rctx := &RuntimeContext{
ctx: ctx,
Config: config,
Cmd: cmd,
botOnly: botOnly,
resolvedAs: as,
Factory: f,
}
rctx.declaredScopes = s.DeclaredScopesForIdentity(string(rctx.As()))
applyJSONShorthand(cmd, s)
rctx.Format = rctx.Str("format")
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
return rctx
}
// StripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a
// file or stdin. A BOM that survives into a CSV cell corrupts the first value
// (e.g. "\ufeffNorth", which then makes a MAXIFS/lookup miss it), and a BOM at the
// head of a JSON payload makes json.Unmarshal fail with "invalid character 'ï'".
// Some editors and exporters add it silently. Only a leading BOM is removed; interior
// occurrences are left untouched.
func StripUTF8BOM(s string) string {
return strings.TrimPrefix(s, "\uFEFF")
}
// InputResolvedFromSource reports whether the named flag's value was loaded
// from an external source (@file or stdin `-`) by resolveInputFlags, as
// opposed to typed inline on the command line. Domain guards that apply
// shape heuristics to inline values ("this looks like a file path — did you
// forget the @?") must skip resolved values: their content was already read
// from the right place and may legitimately look like anything, including a
// path. Without this bit such a guard re-rejects correct @file / stdin
// invocations, because by the time Validate runs both arrive as plain text.
func (ctx *RuntimeContext) InputResolvedFromSource(name string) bool {
return ctx.inputResolved[name]
}
func (ctx *RuntimeContext) markInputResolved(name string) {
if ctx.inputResolved == nil {
ctx.inputResolved = map[string]bool{}
}
ctx.inputResolved[name] = true
}
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
for _, fl := range flags {
if len(fl.Input) == 0 {
continue
}
raw, err := rctx.Cmd.Flags().GetString(fl.Name)
if err != nil {
return ValidationErrorf("--%s: Input is only supported for string flags", fl.Name).
WithParam("--" + fl.Name)
}
if raw == "" {
continue
}
// stdin: -
if raw == "-" {
if !slices.Contains(fl.Input, Stdin) {
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
WithParam("--" + fl.Name)
}
// A process has a single stdin, so we reject a second Input flag
// trying to use `-` after the first one has already consumed it.
if rctx.stdinConsumed {
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
WithParam("--"+fl.Name).
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others inline or as @file with a relative path under the current directory (e.g. --%s @./payload.json)", fl.Name)
}
rctx.stdinConsumed = true
data, err := io.ReadAll(rctx.IO().In)
if err != nil {
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
WithParam("--" + fl.Name).
WithCause(err)
}
// strip a leading UTF-8 BOM so it can't corrupt the first CSV
// cell or break JSON parsing downstream.
rctx.Cmd.Flags().Set(fl.Name, StripUTF8BOM(string(data)))
rctx.markInputResolved(fl.Name)
continue
}
// 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(fl.Input, File) {
return ValidationErrorf("--%s does not support file input (@path)", fl.Name).
WithParam("--" + fl.Name)
}
path := strings.TrimSpace(raw[1:])
if path == "" {
return ValidationErrorf("--%s: file path cannot be empty after @", fl.Name).
WithParam("--" + fl.Name)
}
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil {
verr := ValidationErrorf("--%s: %v", fl.Name, err).
WithParam("--" + fl.Name).
WithCause(err)
if slices.Contains(fl.Input, Stdin) {
// Rejected @file paths are usually absolute (temp files under
// /tmp). Steer toward stdin rather than cd / copying the file
// into the project tree.
verr = verr.WithHint("this flag also reads stdin: pipe the file contents into this command and pass --%s -", fl.Name)
}
return verr
}
// strip a leading UTF-8 BOM so it
// can't corrupt the first CSV cell or break JSON parsing downstream.
rctx.Cmd.Flags().Set(fl.Name, StripUTF8BOM(string(data)))
rctx.markInputResolved(fl.Name)
continue
}
}
return nil
}
// validateEnumFlags rejects shortcut flag values outside their declared enum choices.
func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
for _, fl := range flags {
if len(fl.Enum) == 0 {
continue
}
val := rctx.Str(fl.Name)
if val == "" {
continue
}
valid := false
for _, allowed := range fl.Enum {
if val == allowed {
valid = true
break
}
}
if !valid {
return ValidationErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", ")).
WithParam("--" + fl.Name)
}
}
return nil
}
// handleShortcutDryRun renders a shortcut plan without sending its API requests.
func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error {
if s.DryRun == nil {
return ValidationErrorf("--dry-run is not supported for %s %s", s.Service, s.Command).
WithParam("--dry-run")
}
dryResult := s.DryRun(rctx.ctx, rctx)
if dryResult != nil {
// Same data.context contract as the service/api dry-run paths.
dryResult.Context(rctx.Config.AppID, rctx.UserOpenId())
}
return cmdutil.WriteDryRun(dryResult, cmdutil.DryRunOutputOptions{
Format: rctx.Format,
JqExpr: rctx.JqExpr,
CommandPath: rctx.Cmd.CommandPath(),
Identity: rctx.As(),
Out: f.IOStreams.Out,
ErrOut: f.IOStreams.ErrOut,
})
}
// rejectPositionalArgs returns a cobra.PositionalArgs that rejects any
// positional arguments. It returns a plain cobra usage error; the root
// handler classifies it into the typed validation envelope (exit 2), the
// same path as other cobra usage failures.
func rejectPositionalArgs() cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return nil
}
return fmt.Errorf("positional arguments are not supported (got %q); pass values via flags", args)
}
}
// registerShortcutFlags adds the shortcut's declared flags to its Cobra command.
func registerShortcutFlags(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
registerShortcutFlagsWithContext(context.Background(), cmd, f, s)
}
// shortcutDeclaresJSONFlag reports whether the shortcut itself declares a flag
// named "json" in its Flags list (custom semantics, e.g. event +subscribe's
// pretty-print switch or base +record-search's request-body payload).
// Framework-injected flags never appear in s.Flags, so this cleanly separates
// "self-declared json" from "injected shorthand".
func shortcutDeclaresJSONFlag(s *Shortcut) bool {
for _, fl := range s.Flags {
if fl.Name == "json" {
return true
}
}
return false
}
// shortcutFormatSupportsJSON reports whether the command's format flag accepts
// "json": a self-declared format supports it only when its Enum lists "json";
// a framework-injected default format (no format entry in s.Flags) always does.
func shortcutFormatSupportsJSON(s *Shortcut) bool {
for _, fl := range s.Flags {
if fl.Name == "format" {
return slices.Contains(fl.Enum, "json")
}
}
return true // framework-injected: json (default) | pretty | table | ndjson | csv
}
// ensureJSONShorthand registers --json as a shorthand for --format json when:
// 1. the command has a format flag (self-declared or framework-injected), AND
// 2. that format supports "json" (see shortcutFormatSupportsJSON), AND
// 3. no flag named "json" is registered yet — pflag panics on duplicate
// registration, and commands that declare their own --json (event
// +subscribe, base +record-search/-get) keep their custom semantics.
func ensureJSONShorthand(cmd *cobra.Command, s *Shortcut) {
// A shortcut that declares its own "json" flag defines custom semantics
// (e.g. pretty-print switch, request-body payload) — never a shorthand.
if shortcutDeclaresJSONFlag(s) {
return
}
if cmd.Flags().Lookup("format") == nil {
return
}
if !shortcutFormatSupportsJSON(s) {
return
}
// Safety net: pflag panics on duplicate registration.
if cmd.Flags().Lookup("json") != nil {
return
}
cmd.Flags().Bool("json", false, "shorthand for --format json")
}
// applyJSONShorthand folds the injected --json shorthand into the format flag
// itself, before rctx.Format caches it — so both the cached value (OutFormat,
// ValidateJqFlags, dry-run) and later runtime.Str("format") reads observe
// "json". An explicitly passed --format always wins over the shorthand (the
// shorthand only fills in when the user did not choose a format). Shortcuts
// that declare their own "json" flag keep its custom semantics untouched.
func applyJSONShorthand(cmd *cobra.Command, s *Shortcut) {
if shortcutDeclaresJSONFlag(s) {
return
}
if cmd.Flags().Lookup("json") == nil || cmd.Flags().Changed("format") {
return
}
if set, _ := cmd.Flags().GetBool("json"); set {
_ = cmd.Flags().Set("format", "json")
}
}
// registerShortcutFlagsWithContext registers shortcut flags using the supplied command context.
func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
for _, fl := range s.Flags {
desc := fl.Desc
if len(fl.Enum) > 0 {
desc += " (" + strings.Join(fl.Enum, "|") + ")"
}
if len(fl.Input) > 0 {
hints := make([]string, 0, 2)
if slices.Contains(fl.Input, File) {
hints = append(hints, "@file")
}
if slices.Contains(fl.Input, Stdin) {
// "- reads stdin" intentionally avoids implying each flag has
// its own stdin: a process has a single stdin, so at most one
// flag per call may use "-" (the rest must use @file). The old
// per-flag "- for stdin" wording led AI agents to write
// `--a - <x --b - <y`, where the second `<` silently clobbers
// the first and `--a` reads the wrong payload.
hints = append(hints, "- reads stdin (one flag per call; use @file for others)")
}
desc += " (supports " + strings.Join(hints, ", ") + ")"
}
switch fl.Type {
case "bool":
def := fl.Default == "true"
cmd.Flags().Bool(fl.Name, def, desc)
case "int":
var d int
fmt.Sscanf(fl.Default, "%d", &d)
cmd.Flags().Int(fl.Name, d, desc)
case "float64":
var d float64
fmt.Sscanf(fl.Default, "%g", &d)
cmd.Flags().Float64(fl.Name, d, desc)
case "int_array":
var values []int
if fl.Default != "" {
_ = json.Unmarshal([]byte(fl.Default), &values)
}
cmd.Flags().IntSlice(fl.Name, values, desc)
case "string_array":
var values []string
if fl.Default != "" {
_ = json.Unmarshal([]byte(fl.Default), &values)
}
cmd.Flags().StringArray(fl.Name, values, desc)
case "string_slice":
var values []string
if fl.Default != "" {
_ = json.Unmarshal([]byte(fl.Default), &values)
}
cmd.Flags().StringSlice(fl.Name, values, desc)
default:
cmd.Flags().String(fl.Name, fl.Default, desc)
}
if fl.Hidden {
_ = cmd.Flags().MarkHidden(fl.Name)
}
if fl.Required {
cmd.MarkFlagRequired(fl.Name)
}
if len(fl.Enum) > 0 {
vals := fl.Enum
cmdutil.RegisterFlagCompletion(cmd, fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return vals, cobra.ShellCompDirectiveNoFileComp
})
}
}
cmd.Flags().Bool("dry-run", false, "print request without executing")
if cmd.Flags().Lookup("format") == nil {
cmd.Flags().String("format", "json", "output format: json (default) | pretty | table | ndjson | csv")
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
}
ensureJSONShorthand(cmd, s)
if s.Risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}
if s.PrintFlagSchema != nil {
// Guard against a shortcut that already declares these reserved
// introspection flags: pflag panics on a duplicate registration.
// Mirrors the Lookup guard on --format above.
if cmd.Flags().Lookup("print-schema") == nil {
cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing")
}
if cmd.Flags().Lookup("flag-name") == nil {
cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema")
}
}
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
}