Compare commits

...

198 Commits

Author SHA1 Message Date
wenzhuozhen
020151f01b docs(spec): add sheet-history-revert spec (history list / revert / revert-status)
3 new lark-sheets shortcuts via existing ToolsCall framework:
- +history-list, +history-revert, +history-revert-status
- async revert: sheet/data RecoverHistory → RecoverMsg(MQ) → agg consumer
  assigns memberId by scene (lark-cli=11 / doubao=10)
- scene threaded via ctx baggage; no thrift changes (codegen all-no)
- spans larksuite/cli, ee/sheet-skill-spec, ee/sheet-facade-agg, sheet/data

Force-added under docs/ (repo-wide gitignored) so ccm-harness drift
detection has the git-tracked SSOT.
2026-06-26 10:53:04 +08:00
wenzhuozhen
3a85ef389d chore: inject ccm-harness skill routing v2 to CLAUDE.md/AGENTS.md
Auto-injected by ccm-harness inject-routing-banner.sh.
- Claude Code 读 CLAUDE.md, Codex 读 AGENTS.md, 内容一致
- 如已建 AGENTS.md symlink, 只 inject CLAUDE.md 即可

Source template: $CCM_HARNESS_SRC/templates/claude-md-routing.md
2026-06-23 20:58:20 +08:00
zhengzhijiej-tech
68f867d6a5 Merge pull request #1519 from larksuite/feat/sheets-wiki-url
feat(sheets): resolve wiki URLs to the backing spreadsheet for --url
2026-06-23 11:06:36 +08:00
zhengzhijie
78f7fba89e fix(sheets): match --url path segment via url.Parse, not substring
parseSpreadsheetRef classified /wiki/ with strings.Index over the whole URL, so a /sheets/ link whose query or fragment merely contained /wiki/ (e.g. .../sheets/sht?from=/wiki/x) was hijacked into a get_node call. Now parse the URL and match /sheets/, /spreadsheets/, /wiki/ only as a path prefix, mirroring slides parsePresentationRef which already fixed this class. Drop the substring helpers. Also align wiki resolution with slides: CallAPITyped (typed error + log_id) and classify an incomplete get_node response as InternalError instead of a --url validation error. Add regression tests for query/fragment /wiki/ and incomplete node.
2026-06-22 19:13:38 +08:00
zhengzhijie
06241666a0 docs(sheets): note --url accepts wiki URLs (synced from spec) 2026-06-22 19:13:07 +08:00
zhengzhijie
a35cc26131 feat(sheets): resolve wiki URLs to the backing spreadsheet for --url
Sheets shortcuts only accepted /sheets/ and /spreadsheets/ URLs via --url.
A /wiki/<node_token> URL was rejected with "must be a spreadsheet URL"
because the wiki node_token is not a spreadsheet token: resolving it to the
backing spreadsheet needs a wiki get_node call, which Validate/DryRun (kept
network-free) must not make.

Mirror the existing slides/doc/drive two-stage pattern:

- parseSpreadsheetRef classifies --url / --spreadsheet-token network-free
  into a sheet token or an (unresolved) wiki node_token.
- resolveSpreadsheetTokenExec (Execute only) resolves a /wiki/ node_token
  via wiki get_node, verifies obj_type=sheet, and returns the obj_token.
  The wiki:node:read scope is enforced on this path only, so non-wiki
  invocations are unaffected.
- resolveSpreadsheetToken stays network-free for Validate/DryRun, passing
  the node_token through unchanged.

All 47 Execute paths (including +batch-update and +workbook-export) switch
to the Exec resolver; Validate/DryRun keep the network-free one. No tool
schema change: the CLI feeds the resolved spreadsheet token as excel_id, so
this is a pure CLI-layer change.

Tested: unit (parse classification + wiki get_node e2e via httpmock) and
live end-to-end against a real wiki spreadsheet (read: +workbook-info,
+cells-get, +csv-get; write: +sheet-create, +sheet-rename, +csv-put).
2026-06-22 19:13:07 +08:00
xiongyuanwen-byted
b6da950be3 feat(sheets): styles 接受 halign/valign 等对齐字段别名
把模型常幻觉的 horizontal_align / halign / vertical_align / valign 映射到
规范字段 horizontal_alignment / vertical_alignment,覆盖 --styles 与 typed
--cells;与规范字段冲突时报错而非静默择一。同步 lark-sheets skill 文档补
对齐字段说明 + --print-schema --flag-name styles 提示。
2026-06-22 18:28:05 +08:00
xiongyuanwen-byted
aa545083b6 docs(lark-sheets): sync from spec — set+H 告诫通则化(移入 stdin 段) 2026-06-22 18:28:05 +08:00
xiongyuanwen-byted
5c7100ee4c fix(sheets): migrate +table-put to typed error contract
The merge from main brought in #1449 (retire legacy error envelopes),
which removed output.ExitError / output.ErrDetail and forbids
constructing them. Port tablePutPartial off the legacy envelope:

- no sheets written -> typed errs.APIError (plain failure)
- some sheets written -> ok:false result via runtime.OutPartialFailure
  carrying written_sheets, returning the partial-failure exit signal

Also fix two drifts the same merge introduced:
- regenerate flag_defs_gen.go to match the committed flag-defs.json
- update the --max-chars flag test to assert visible (no longer hidden)
2026-06-22 12:29:03 +08:00
xiongyuanwen-byted
3ef3a9d1d3 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-22 10:14:25 +08:00
xiongyuanwen-byted
bdad336caf docs(lark-sheets): sync from spec — set+H 改单引号 / 速查表补臆造命令名 / workbook-import 引导 2026-06-20 14:11:02 +08:00
xiongyuanwen-byted
39a7d4bfb4 feat(sheets): 写操作报错增强 + --token 别名
- 复合 JSON shape 校验失败时报错附 --print-schema 提示,agent 可直接拿到精确结构(pro26 头号:+cells-set --cells 反复猜 shape)
- JSON 解析失败且该 flag 支持 stdin 时提示改用 stdin(公式/引号/逗号内联到 shell 被转义弄坏 JSON)
- --token 作为 --spreadsheet-token 的解析期别名:复用 sheets 已有 PostMount 钩子 + pflag normalize,仅 sheets 包,common 零改动
2026-06-20 14:11:02 +08:00
liangshuo-1
bba13cfe0f chore: release v1.0.56 (#1518) 2026-06-18 18:53:21 +08:00
liujiashu-shiro
815cdb8f1c feat(im/convert): support content_v2 blocks in post message conversion (#1411)
Support content_v2 post message conversion in IM shortcuts so newer post payloads render with the expected markdown, mention, and image formats while preserving fallback compatibility with legacy content.
2026-06-18 17:53:22 +08:00
liangshuo-1
4f3ae0c71a fix: pin fetch_meta.py output to utf-8 encoding (#1516) 2026-06-18 17:18:45 +08:00
91-enjoy
96d70143c5 feat: support message recieve event card format (#1480)
Previously, im.message.receive events with message_type: interactive surfaced the raw JSON
payload as content, requiring callers to manually parse the card schema. This PR introduces a
user_dsl renderer (ConvertInteractiveEventContent) that converts interactive card content into
structured human-readable text — consistent with how text, post, image, and other message
types are already handled.

The output format is <card title="..." subtitle="...">...</card>, with each card element type
serialised to a readable representation (markdown body, button links, table rows, chart summaries,
etc.).
2026-06-18 17:18:01 +08:00
syh-cpdsss
83db15907f Improve OKR shortcuts (#1487)
* feat(okr): add +batch-create, +reorder, +weight shortcuts

Add three new OKR shortcuts for managing objectives and key results:

- +batch-create: Bulk create objectives with key results, with automatic
  rollback on failure
- +reorder: Adjust position of objectives or key results within a cycle/objective
- +weight: Adjust weights of objectives or key results with automatic
  normalization using fixed-point arithmetic to avoid float precision issues

Key implementation details:
- API paths use underscore separators (/objectives_position, /objectives_weight)
- Weight normalization uses json.Number for precise JSON serialization
- Items are sorted by position before API calls to match backend requirements
- Full unit test coverage and dry-run/live E2E tests
- Skill documentation with usage examples and parameter descriptions

Change-Id: I92b658e0cc42ffa8cbdaec2ec628a079bcfc38f5

* fix: skill simplify & minor fix

Change-Id: I3f27a01cdae2122f26e48ee2acb7f334f2bab7d2

* fix: CR issue

Change-Id: Id9fab84e06f0d67e9f79c1fb9946b6b633200592

* fix: CR issue 2

Change-Id: I6a5e57dd4b10dc79f8681ec614354fbba82abc04

* fix: error handle of +weight shortcut

Change-Id: I6e2a39269e62e3b504e681110843b2ccc315a527
2026-06-18 16:25:23 +08:00
xiongyuanwen-byted
4b404fc0ee docs(lark-sheets): sync from spec — --max-chars 放出为可见 flag + 落盘优先指引
源同步自 sheet-skill-spec:--max-chars 放出(默认 500000,可调小避免大输出被 Bash/终端转存为文件、改 has_more 分页);read-data 增「大数据优先落盘」指引。
2026-06-18 15:58:20 +08:00
xiongyuanwen-byted
fc6e1e25de docs(lark-sheets): sync from spec — +csv-put 含逗号公式正例 + 收敛警示标签
源同步自 sheet-skill-spec:write-cells 补含逗号公式 RFC 4180 转义正例与结构化写入优先指引;全 reference 收敛「高频致命错误」类标签。
2026-06-18 13:07:30 +08:00
xiongyuanwen-byted
14d3107bf2 feat(sheets): +cells-get/+csv-get --max-chars 默认值 200000 → 500000
放宽默认防爆上限。flag_defs_gen.go 由 go generate 重生;flag_defs_test.go
的 expected default 同步;flag-schemas.json schema_version 2 → 3 是上游
spec-tables 架构调整带来的元数据 bump,与本业务改动无关、go:embed 不解析
该字段、无功能影响。

Synced from sheet-skill-spec@93f7a78.
2026-06-17 21:24:54 +08:00
hanshaoshuai
1f2164c7c2 fix: trim semantic review input for broad changes 2026-06-17 20:15:04 +08:00
raistlin042
76f5419a0d feat: add +session-messages-list for session turn reply messages (#1402)
* feat(apps): add +message-get to fetch session turn reply messages

* docs(apps): add +message-get skill reference

* fix(apps): drop Required flags on +message-get so missing ids return structured exit-2 envelope

* docs(apps): route turn reply-message queries to +message-get in SKILL.md

* docs(apps): guide cloud-dev to read live turn progress via +message-get

* docs(apps): note +message-get reads a still-running turn incrementally

* docs(apps): route live-turn reply queries to +message-get in SKILL.md

* refactor(apps): rename +message-get to +session-messages-list with page_token paging

* refactor(apps): use typed errs validation in +session-messages-list

* docs(apps): clarify +session-messages-list paging stops on has_more, not token
2026-06-17 20:12:22 +08:00
evandance
c5b5aece33 refactor: retire legacy error envelopes and enforce typed contract (#1449)
* refactor: retire legacy error envelopes and enforce typed contract

Consolidate all command error reporting onto the typed errs.* contract, remove
the legacy error surface that predated it, and tighten the lint guards so the
contract holds across the whole repository going forward.

Every failure now reaches stderr as one envelope shape: a category, an
optional subtype, a human- and agent-readable message, and a recovery hint,
with invalid parameters listed under `params`. The legacy ExitError envelope,
its constructors, and the boundary bridge that promoted untyped config and
authorization errors are deleted, leaving a single path from error to wire.
Predicate commands keep their silent-exit behavior through a dedicated signal
that carries only an exit code.

Infrastructure paths that still emitted ad-hoc envelopes — flag parsing,
unknown commands and subcommands, plugin and policy guards, confirmation
prompts, and auth/config failures — now classify into the same taxonomy.
Business, API, auth, and config exit codes are preserved; the one behavioral
change is that Cobra usage failures (missing required flag, unknown command,
bad arguments) now emit the typed validation envelope and exit 2, matching the
explicit flag and subcommand guards, instead of Cobra's plain-text exit 1.

Enforcement is repo-wide rather than per-path:
- The errscontract guards run by default everywhere instead of through a
  migration allowlist, so legacy envelopes cannot be reintroduced anywhere.
- errorlint runs across the whole repository: every error wrap must use %w and
  every comparison must use errors.Is/errors.As, so interior wraps stay legal
  but can no longer break the chain the typed boundary relies on.
- The errs-no-bare-wrap guard is keyed by structural prefix instead of an
  explicit per-domain allowlist, so new shortcut domains are covered without
  editing a list. It runs where forbidigo is enabled (the shortcut domains and
  the auth/config/service command groups); repo-wide chain integrity for the
  remaining command paths is carried by errorlint above.

* test: align cli_e2e success assertions to the ok envelope

The api and service success path now emits the {"ok":true} envelope, so the
cli_e2e workflow assertions that still expected the old {"code":0} shape via
AssertStdoutStatus(t, 0) fail once they run with live credentials. Switch those
workflow assertions to AssertStdoutStatus(t, true); the fake-payload helper test
in core_test.go keeps its code-shape assertion.
2026-06-17 19:42:38 +08:00
fangshuyu-768
d687a76c79 feat: soften lark doc style guidance (#1463) 2026-06-17 19:16:02 +08:00
guokexin.02
4a4c3344c8 fix: align api success envelopes (#1489) 2026-06-17 17:41:48 +08:00
hanshaoshuai
c61acb5264 feat: add ci quality gate 2026-06-17 16:29:33 +08:00
zgz2048
7eeb111a2d fix: reject out-of-range base pagination flags (#1495) 2026-06-17 15:41:59 +08:00
zhengzhijiej-tech
e795f4f068 Merge pull request #1482 from larksuite/zzj/mention-doc-link
feat(sheets): document link requirement for @document mentions
2026-06-17 14:12:41 +08:00
xiongyuanwen-byted
2e4033a1a0 fix(shortcuts): clarify single-stdin constraint in flag help and error hint
Input flags advertised '(supports @file, - for stdin)' per flag, leading
AI agents to write '--a - <x --b - <y' where the second '<' silently
clobbers the first and the first flag reads the wrong payload. A process
has a single stdin, so at most one flag per call can use '-'.

- Reword the generated help hint to '- reads stdin (one flag per call;
  use @file for others)'.
- Add an actionable .WithHint to the stdin-conflict validation error
  pointing callers to @file for the extra flags.
- Assert the new hint in TestResolveInputFlags_DuplicateStdin.
2026-06-17 11:35:37 +08:00
liangshuo-1
714da970d0 chore(release): v1.0.55 (#1490) 2026-06-16 22:26:40 +08:00
xiongyuanwen-byted
fc44564b01 refactor(sheets): migrate legacy error helpers to typed errs in sheets domain
golangci-lint forbidigo (errs-no-legacy-helper / errs-no-bare-wrap) flagged
the table I/O, workbook, and dataframe shortcuts that landed on this branch:
93 common.FlagErrorf and 48 fmt.Errorf calls.

- Replace every common.FlagErrorf with common.ValidationErrorf (typed
  *errs.ValidationError, same signature) across workbook / table_io /
  dataframe / object_crud.
- writeDataframeOut's two final --dataframe-out write failures become typed
  errs.NewInternalError(SubtypeFileIO, ...).WithCause(err).
- applyWorkbookCreateVisualOps now passes the typed callTool error through
  unchanged (re-wrapping would downgrade classification) and attaches the
  failing op as a recovery hint only when none is set.
- The remaining fmt.Errorf are genuine intermediate errors that the command
  layer re-wraps into typed validation errors (buildTypedCell / Arrow
  decode-encode) or surfaces as a partial_success message string
  (writeTypedSheets via tablePutPartial); each carries a //nolint:forbidigo
  with that reason, per the lint guidance.

No behavior change: error messages and partial-success shapes are preserved;
gofmt, go vet, golangci-lint (0 issues) and sheets tests all pass.
2026-06-16 20:47:54 +08:00
xiongyuanwen-byted
7742a47072 fix(sheets): collapse duplicate validateCreateInput from bad merge resolution
A prior merge kept both branches' independently-added validateCreateInput
fields on objectCRUDSpec with conflicting signatures (pivot's
func(rt, input) and cond-format's func(input)), plus both call sites in
objectCreateInput, which failed to compile (validateCreateInput redeclared).

Collapse to the single richer func(rt flagView, input) signature and one
call site. cond-format's validateCondFormatAttrs (func(input), still shared
with validateUpdateInput) is wrapped in a closure that ignores rt. Both
behaviors are preserved: pivot --target-position/--range mutex and
cond-format attrs-shape-vs-rule_type validation.
2026-06-16 20:10:47 +08:00
xiongyuanwen-byted
3668b904ca Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-16 20:01:50 +08:00
xiongyuanwen-byted
1c68d31d12 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop
# Conflicts:
#	shortcuts/drive/drive_export.go
#	shortcuts/drive/drive_import.go
2026-06-16 19:52:50 +08:00
sang-neo03
ed7fdd1a27 feat: optimize event subscription precheck, links, and consumer guard (#1447)
* feat: add SubscriptionType and SingleConsumer to EventKey definition

* feat: fetch subscribed callbacks from application/get

* feat: build addons scan-to-enable deep link for event precheck

* feat: route callback precheck to application/get and emit scan links

* feat: add reject fields to hello_ack protocol message

* feat: add exclusive registration to event bus hub

* feat: reject duplicate consumer for SingleConsumer EventKey at bus handshake

* feat: surface bus consumer rejection as failed_precondition error

* fix: encode empty addons sides as [] not null per launcher contract

* fix: report missing callbacks when console has none subscribed

* feat: bound exclusive consumer cleanup wait with configurable timeout

* refactor: drain exclusive-wait timer and document websocket-only callbacks

* fix: use camelCase clientID param in event scan-to-enable link

* test: cover null/omitted callbacks and assert typed error category

* fix: keep auth login remediation for user-identity missing scopes

* refactor: simplify SubscriptionType normalization to match validateAuth style
2026-06-16 19:41:52 +08:00
xiongyuanwen-byted
4c51cd36fb docs(sheets): fix csv-get current_region guidance to cross-check row_count
current_region is a blank-row/column-bounded block, not the true sheet extent:
an internal blank row truncates it, so it can miss rows past the gap. The
read-data reference previously called it the "真实数据边界" and told agents to
prefer it over row_count — which drove the "read only to current_region's last
row, miss the tail" failure.

- current_region: warn it can be both smaller (internal blank rows truncate)
  and larger (trailing summary/signature rows) than the real data range.
- csv-get output contract: clarify its row_count/col_count is the returned size
  (= actual_range), not the physical sheet size; has_more only reflects the
  current range, not whether the whole sheet was read.
- "确定数据范围的正确流程": add a step to cross-check against +workbook-info's
  physical row_count and probe past current_region's last row for data beyond an
  internal blank row.
2026-06-16 18:48:00 +08:00
xiongyuanwen-byted
bbeae3636c fix(sheets): default +table-get to full used range, not A1 current region
+table-get without --range anchored its current_region probe at A1, so an
internal blank row or column silently truncated everything past it — agents
then treated the partial data as complete (the pro016 / pro025 incident).

- Probe the used range over the full physical grid (row_count × column_count
  from the workbook structure) so it spans internal blank rows/columns; fall
  back to the legacy A1 anchor when dimensions are unknown.
- Emit the actually-read `range` on every sheet so callers can detect
  truncation (get_cell_ranges has no has_more flag).
- Fix the same A1-anchor bug in append mode's last-data-row probe, which could
  otherwise overwrite data past an internal blank row.
- Add unit + dry-run/live E2E coverage; refresh synced skill docs.
2026-06-16 18:48:00 +08:00
wangweiming-01
4464ba7660 fix: validate drive import folder target (#1485)
Change-Id: I43755c3966b0daa06b708d2b3d03294f439547fa
2026-06-16 18:14:08 +08:00
zhicong666-bytedance
bb03c8ac4d feat(vc): support agent meeting event workflows (#1483)
* feat: support vc agent active meetings

* docs: clarify vc agent active meeting flow

* fix: align active meeting shortcut scope

* docs: clarify active meeting id fields

* fix: reject meeting numbers for vc events

* docs: clarify vc agent active meeting flow

* docs: refine vc agent meeting flow guidance

* docs: address vc agent skill review feedback

* docs: clarify vc meeting product wording

* docs: align vc agent skill with quality guidelines

* docs: trim vc agent skill token budget

* Revert "docs: trim vc agent skill token budget"

This reverts commit 8560bb9c19.
2026-06-16 18:08:07 +08:00
zhengzhijiej-tech
a9d88c5666 Merge pull request #1486 from larksuite/fix/cond-format-attrs-shape-validation
fix(sheets): reject cond-format attrs whose shape mismatches rule_type
2026-06-16 17:49:32 +08:00
zhengzhijie
4801675fd6 test(sheets): guard condFormatAttrsRequired against flag-schemas drift
Add TestCondFormatAttrsRequired_MatchesSchemaOneOf, comparing the
hand-maintained condFormatAttrsRequired table against the embedded
flag-schemas.json attrs oneOf (multiset of required-key sets, for both
create and update). The cross-field validator only holds if its
per-rule_type required keys mirror the schema branches, and the two
share no compile-time link — this pins them together so a future schema
sync that adds/drops a required key can't silently desync the table.
2026-06-16 17:45:11 +08:00
zhengzhijie
dd04b3705f fix(sheets): reject cond-format attrs whose shape mismatches rule_type
A conditional-format rule created with --rule-type colorScale but
cellIs-shaped attrs ({compare_type,value}, no color) was accepted by
the CLI and written through to the server, producing a color-less
color-scale segment. That dirty data crashes the frontend on snapshot
deserialization, so the spreadsheet can no longer be opened (5005).

The per-entry schema check can't catch this: properties.attrs.items is
a oneOf over all nine attr shapes and passes as soon as any branch
matches, blind to the sibling rule_type — {compare_type,value} matches
the cellIs branch even when rule_type says colorScale. The tool side
maps attrs blindly by rule_type and only validates dataBar count and
iconSet ordering, so the gap reaches the data layer.

Add a cross-field validator (validateCondFormatAttrs) wired into both
create and update via the new objectCRUDSpec.validateCreateInput hook
(twin of validateUpdateInput). It enforces, per rule_type, the keys
every attrs entry must carry — mirroring the tool's converter contract
— and treats an empty required string (notably color) as missing.
Rule types that take no attrs (duplicateValues / uniqueValues /
containsBlanks / notContainsBlanks) and updates that omit rule_type are
left to the server.
2026-06-16 17:23:58 +08:00
yballul-bytedance
3feb70b32a feat(drive): 支持导出 Base 结构快照 (#1481)
1. 为 drive +export 增加 --only-schema 参数,并透传 only_schema 到导出任务请求。
2. 限制该参数仅用于 bitable 导出 .base,并补充单测与 dry-run E2E 覆盖。

Change-Id: I736cebf5841cc1c6acaa8a3ab16be51ba4cb355d
2026-06-16 16:36:31 +08:00
ZEden0
64b1b3f3ed feat(docs): support lang for fetch v2 (#1459) 2026-06-16 16:25:36 +08:00
ZEden0
a0e83c7e59 feat(docs): add docx cover resource commands (#1468)
Spec source: active@bd186a6373948acc76d8b0872334b1a53ad40f5645b1a4e129937d7a51f5596c
2026-06-16 15:25:37 +08:00
zhengzhijie
439f184ba5 feat(sheets): document link requirement for @document mentions in cells flag schema
@document mentions (mention_type != 0) must pass link (doc URL) to render a
clickable card; @user mentions (mention_type=0) don't need it. Synced from the
upstream tools-schema.
2026-06-16 14:58:36 +08:00
xiongyuanwen-byted
825071fd7a docs(lark-sheets): point read-data to +sheet-info for hidden row/col identification
skip-hidden defaults to false (lossless reads), but the read primitives don't mark which rows/cols are hidden. Cross-reference +sheet-info --include hidden_rows,hidden_cols + row_indices/col_indices so agents can identify hidden ranges when they need to filter or interpret hidden data.

Synced from sheet-skill-spec.
2026-06-16 14:25:19 +08:00
xiongyuanwen-byted
72999cd303 feat(sheets): add --styles to +table-put for one-step typed write with styling
+table-put now accepts --styles (same shape as +workbook-create's --styles):
cell_styles merge into the set_cell_range matrix, while cell_merges /
row_sizes / col_sizes apply as their own tool calls after the write. The
styles payload is name-matched against the written sheets and validated up
front, so a malformed or mismatched style fails before any write lands.

Also points +sheet-create users to +table-put (auto-creates missing sheets)
when they need data/styles, via a runtime Tip and the lark-sheets skill
references. Flag is sourced from the upstream Base table and regenerated
through sheet-skill-spec (flag-defs.json / flag-schemas.json / gen file).

Adds unit tests (dry-run styles, name-mismatch reject, execute) and a
dry-run E2E (tests/cli_e2e/sheets/sheets_table_put_dryrun_test.go).
2026-06-16 12:56:59 +08:00
xiongyuanwen-byted
f9c73e217d docs(lark-sheets): clarify cell-image vs float-image routing and fix reference self-references
Synced from sheet-skill-spec.

- Add a binding-based decision (does the image belong to a record and move with its row?) to route +cells-set-image vs +float-image-create across the SKILL entry, float-image and write-cells references.
- Add routing rows to the SKILL command cheat-sheet and warn against defaulting to float-image out of familiarity.
- Replace mislabeled 本 skill / 子 skill / 跨 skill wording in references with 本文 / reference names, matching the existing convention.
2026-06-16 10:55:23 +08:00
liangshuo-1
297b2a222e chore(release): v1.0.54 (#1476) 2026-06-15 21:58:07 +08:00
Zhang-986
80a5f30f4d fix(event): clarify remote bus blocker recovery (#1454) 2026-06-15 20:27:59 +08:00
xzcong0820
cf35d1e499 feat(mail): auto-attach default signature on send/reply/forward (#1415)
* feat(mail): auto-attach default signature on send/reply/forward

- Add exported PlainTextFromHTML wrapper in draft/htmltext.go
- Add DefaultSendID/DefaultReplyID in signature/provider.go
- Add noSignatureFlag, autoResolveSignatureID, validateNoSignatureConflict,
  injectPlainTextSignature in signature_compose.go; remove validateSignatureWithPlainText
- mail_send, mail_draft_create: add --no-signature flag, auto-resolve default
  signature when no --signature-id given, inject plain-text sig in plain-text branch
- mail_reply, mail_reply_all, mail_forward: same flag/validate changes + timing fix
  (resolveSignature moved to after senderEmail is finalized)
- Update 5 reference docs: add --no-signature row, update --plain-text and
  --signature-id descriptions

---------

Co-authored-by: xzcong0820 <278082089+xzcong0820@users.noreply.github.com>
2026-06-15 20:04:05 +08:00
fangshuyu-768
fd16cf106b clarify lark-doc create title guidance (#1474) 2026-06-15 19:38:56 +08:00
1uckypeach
53076733ec docs(skills): add rename prompt for import without --name (#1461)
When --name is omitted, remind user that the title defaults to the source
filename and may duplicate content headings, causing visual redundancy.
Ask whether to rename before executing the import.
2026-06-15 19:30:51 +08:00
陈家名
a3bee13ca9 fix(vfs): reject blank local paths (#1460) 2026-06-15 19:14:31 +08:00
xiongyuanwen-byted
5f3c1c8e6a docs(lark-sheets): remove financial modeling standards reference
Drop the lark-sheets-financial-modeling-standards.md reference doc and all
pointers to it from SKILL.md, core-operations, and visual-standards. Bump
skill version to 3.0.0.
2026-06-15 18:46:34 +08:00
fangshuyu-768
6217bd2c29 fix docs fetch and update ergonomics (#1466) 2026-06-15 17:47:34 +08:00
search_zhuhao
72c294712c feat: 【larksuite/cli】【drive 搜索支持 original_creator_ids】 M-7074213537 (#1046)
sa: none

fg: none

cfg: none

doc: none

test: ppe
Change-Id: I88bedd02a5daa3307b05c9b6f94748e1544d279a
2026-06-15 14:18:45 +08:00
sammi-bytedance
37f4f899b2 docs(lark-im): document @mention format per message type (text/post/card) (#1419)
Split the send/reply @Mention sections by message type:
- text: <at user_id="ou_xxx">name</at> (inner name optional), @all
- post: inline form in text/md elements, or a dedicated {"tag":"at"} node
- interactive card: card-native <at id=>, <at ids=>, <at email=>
2026-06-15 14:08:50 +08:00
zhengzhijiej-tech
ead8aa854f Merge pull request #1439 from larksuite/fix/sheet-mention-type-enum
fix(sheets): add mention_type enum to set_cell_range cells schema
2026-06-15 11:50:35 +08:00
xiongyuanwen-byted
833b7cde33 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop
# Conflicts:
#	shortcuts/sheets/lark_sheet_workbook.go
#	shortcuts/sheets/lark_sheet_workbook_test.go
2026-06-15 11:26:15 +08:00
xiongyuanwen-byted
57d71607e1 feat(sheets): add --dataframe Arrow IPC input for +table-put/+table-get/+workbook-create
Introduce a binary-typed twin of --sheets: --dataframe accepts an Arrow IPC
(Feather v2) payload that pandas' df.to_feather() writes, deriving dtypes and
per-column number formats from the Arrow schema. The two producers are mutually
exclusive and funnel through a shared resolver so +table-put and
+workbook-create stay in lockstep; +table-get gains --dataframe-out for
single-sheet reads. Also auto-grow a sub-sheet's row/column count before
writing so blocks past the backend's default 200x20 bounds no longer fail with
range-exceeds-sheet-bounds.
2026-06-14 22:40:39 +08:00
AlbertSun
c0730b46bf feat: simplify proxy plugin warning and gate on tty (#1448) 2026-06-13 20:32:16 +08:00
hhang
751092c8ef fix(vfs): reject Windows absolute paths cross-platform (#1401)
* fix(vfs): reject Windows absolute paths cross-platform

* test(vfs): cover input Windows absolute paths
2026-06-13 18:56:13 +08:00
liangshuo-1
deb0bd9dd6 refactor: converge command pipelines onto a typed metadata model + catalog (#1191) 2026-06-13 18:02:50 +08:00
raistlin042
0fbfe68726 docs: drop Miaoda brand word from apps command help text (#1399) 2026-06-13 14:00:30 +08:00
liangshuo-1
e1af7e3018 chore: release v1.0.53 (#1443)
]
2026-06-12 20:03:08 +08:00
bubbmon233
693e299589 docs(mail): clarify message read shortcuts (#1261)
* docs(mail): clarify message read shortcuts

Update mail read shortcut help, docs, and triage guidance so single-message and multi-message reads are routed to the right commands.

Add focused tests for help text, dry-run copy, triage stderr hints, and batch_get chunking behavior.

sprint: S1

* docs(mail): align batch_get limit with gateway config

* docs(mail): use shell-safe batch message id examples

* docs(mail): trim batch_get pagination wording

* docs(mail): use placeholder style for message ids

* docs(mail): hide batch_get internals from help
2026-06-12 19:52:36 +08:00
Yuxuan Zhao
69f335be7c test(calendar): drop flaky calendar list e2e checks (#1441) 2026-06-12 19:00:09 +08:00
JackZhao10086
d1a0926dd6 feat/revoke token (#1434) 2026-06-12 17:49:33 +08:00
syh-cpdsss
008bdda861 docs(whiteboard): optimize whiteboard skill (#1371)
* docs(whiteboard): optimize whiteboard skill

Change-Id: Iabcbe9f4e309ae9f467ceec265320cea6cdfa81b

* fix: PR issue

Change-Id: I96d99037b3ba74a3ea9964991b67cdf15fb985be
2026-06-12 17:46:55 +08:00
syh-cpdsss
f1da8c274b docs(okr): optimize okr skill (#1368)
Change-Id: I095a3a7a935e4f84459d1be24015f59cd9e324a6
2026-06-12 17:46:27 +08:00
AlbertSun
842be3fdc5 feat(token): mint TAT via unified OAuth v3 Token Endpoint (#1408) 2026-06-12 17:44:07 +08:00
xiongyuanwen-byted
d2c326a78c feat(sheets): implement pandas-split --sheets protocol for +table-put/+table-get/+workbook-create
Synced from sheet-skill-spec canonical (cli:table_put schema +
references). +table-put/+workbook-create accept the new shape via a
tableSheetIn -> tableSheetSpec normalize step (dtype string -> internal
type/format mapping). +table-get emits the same shape so the writer's
df_to_sheet and the reader's sheet_to_df round-trip cleanly.

isoDateToSerial now accepts the full ISO datetime form
(2024-01-15T00:00:00.000, including timezone suffixes) emitted by
df.to_json(date_format="iso"), not just yyyy-mm-dd. End-to-end verified
by the spec repo's contracts/python_helper_roundtrip script against a
real Lark spreadsheet on pandas 2.2 and 3.0.
2026-06-12 17:32:08 +08:00
raistlin042
1cd7a88597 fix: read release error_logs from data.error_logs in apps +release-get (#1436) 2026-06-12 16:58:47 +08:00
zhengzhijie
422797305a fix(sheets): add mention_type enum to set_cell_range cells schema
Constrain rich_text mention_type to the proto MENTION_FILE_TYPE set so a
file @mention with an out-of-enum value (e.g. 6 = cloud shared folder) is
rejected by the schema validator before it reaches the server and fails
pb serialization ("mentionFileInfo.fileType: enum value expected").

- data/flag-schemas.json: mention_type gains enum + per-value description
- lark_sheet_write_cells_test.go: cover reject (6) + allow (0 / 2 / 22)
2026-06-12 16:53:40 +08:00
max
7c64e63b9d feat(note): clarify note ownership with dedicated detail and transcript flows (#1435)
* feat: split note domain

* fix: address note transcript review comments

* fix: stabilize empty note detail detection
2026-06-12 16:30:41 +08:00
luozhixiong01
8e60f01474 feat(im): unify sort flags into --sort field and --order direction (#1302)
The 4 im query commands had three inconsistent sort conventions and leaked upstream API jargon (ByCreateTimeAsc, member_count_desc) directly to users. This PR unifies them on a single rule — --sort selects a field, --order selects a direction, both from fixed enums — so an agent only ever picks from an enum, never constructs a string. Old flags (--sort-type, --sort-by, and --sort on messages/threads) are kept as hidden silent aliases (no deprecation warning), so existing scripts keep working byte-for-byte.
2026-06-12 15:27:54 +08:00
JackZhao10086
465c789f7c feat: add --json flag support to auth subcommands (#1431)
* feat: add --json flag support to auth subcommands

* feat(auth/logout): add json output support for logout command

* feat(auth/list): add json output support for auth list command
2026-06-12 15:04:14 +08:00
Yuxuan Zhao
2a7e9c7d0d test(drive): retry duplicate-remote push in live E2E (#1403) 2026-06-12 13:48:19 +08:00
xiongyuanwen-byted
3fa28c10fa Merge remote-tracking branch 'origin/feat/lark-sheets-develop' into feat/lark-sheets-develop 2026-06-12 12:03:00 +08:00
xiongyuanwen-byted
27d185c91c feat(sheets): rework +workbook-create flags and --styles
- --values builds a type-less typed payload, writing through --sheets' batched set_cell_range path (raw passthrough preserves auto-detect; large tables batch; big ints via json.Number)
- drop --headers (subsumed by --values first row) and --header-style (typed header no longer auto-bold; use --styles instead)
- styles: deep-merge overlapping cell_styles/border_styles fields (was wholesale-replace which dropped fields); add manual border_styles validation (style/weight enums + sides) since --styles is on parseJSONFlagSkip and bypasses the schema validator
- regenerate flag-defs/flag-schemas/skills mirror from sheet-skill-spec (--styles flag + full per-side border schema)
2026-06-12 12:02:32 +08:00
liangshuo-1
76ba6fad4f chore: add CODEOWNERS for internal/ and new skills domains (#1420) 2026-06-12 11:19:25 +08:00
liangshuo-1
510545f1e5 refactor(vc): consolidate note handling back into the vc domain (#1417) 2026-06-12 00:44:35 +08:00
max
c11cf3b716 feat: split note domain (#1345)
Add note shortcuts for note detail and unified transcript retrieval, route vc note detail parsing through the note domain, and update note/vc/minutes skill guidance for normal versus unified transcript handling.

Includes dry-run E2E coverage for the new note shortcuts and documents the remaining live E2E fixture gap.
2026-06-11 22:38:29 +08:00
liangshuo-1
ee2c93efeb chore: release v1.0.52 (#1412) 2026-06-11 22:05:51 +08:00
wangweiming-01
33e459a4de docs: optimize lark-drive skill routing (#1284)
* docs: optimize lark-drive skill routing

Change-Id: I79cebaa3e52b9291c89bdeffb50426e8f0f3bb2b

* docs: refine lark-drive skill guidance

Change-Id: I628291d6d2b60b0baa7202dddbb9a34138a27a3d
2026-06-11 20:19:07 +08:00
dc-bytedance
5aeae2db65 fix: harden riscv64 -race guard and restore Makefile newline
The cherry-picked riscv64 commit derived RACE_FLAG from `go env GOARCH`
via a grep pipeline, which ignores a GOARCH passed on the make command
line (e.g. `make GOARCH=riscv64 unit-test`) since command-line make
variables are not visible to $(shell ...). Switch to a make-native
filter that honors both, and restore the trailing newline the same
commit dropped.
2026-06-11 19:18:33 +08:00
Rocky Zhang
9b39d10203 feat: support riscv64 prebuilt binaries in release and install pipeline 2026-06-11 19:18:33 +08:00
Rocky Zhang
8572a58fda fix: support riscv64 by making -race flag arch-conditional 2026-06-11 19:18:33 +08:00
zhengzhijiej-tech
83926943ae Merge pull request #1397 from larksuite/fix-chart-aggregate-counta-zzj
feat(sheets): add counta to chart aggregateType enum
2026-06-11 19:11:36 +08:00
evandance
9bc66cc445 feat(apps): emit typed error envelopes across the apps domain (#1288) 2026-06-11 19:04:34 +08:00
shifengjuan-dev
e53f9d999e feat(im): add --chat-modes filter to chat search (#1317)
Add a server-side --chat-modes filter to the im +chat-search shortcut so
users can restrict results to regular groups and/or topic groups.

Change-Id: Ia59c2c05fb2e8e45bd741c8531ca0e3ca69de2f3
2026-06-11 16:54:27 +08:00
shifengjuan-dev
ae35b35693 docs(im): document chat.user_setting batch_query/batch_update (#1339)
Add the chat.user_setting resource 

Change-Id: Ifdd163bfa1cdbfcb56cbf12a3f52e40b61d85e2d
2026-06-11 16:52:05 +08:00
zhengzhijie
752bfcbbb9 feat(sheets): make --target-position and --range mutually exclusive on +pivot-create
Both flags map to the same wire field (properties.range), so passing
non-default values for both is ambiguous. Mirror the
--target-sheet-id / --target-sheet-name mutex pattern: --target-position
takes priority over --range, and supplying both with non-default values
is rejected up front with a typed FlagErrorf. --target-position=A1 is
the documented default and is treated as "not set".

Add a symmetric validateCreateInput hook on objectCRUDSpec (alongside
the existing validateUpdateInput), wire it into objectCreateInput, and
inject the pivot-specific check on pivotSpec.
2026-06-11 16:45:28 +08:00
fangshuyu-768
c2e617fc96 docs(skills): expand cite user guidance and fix typos (#1394) 2026-06-11 16:40:39 +08:00
liuxinyanglxy
3f77eded9d feat: per-resource subscription identity + Match hook (#1185)
Framework support for resource-scoped event subscriptions, so one
EventKey can fan out into independent per-resource subscription scopes:

- KeyDefinition gains SubscriptionKey / NormalizeParams / Match hooks
- ComputeSubscriptionID derives a dedup identity from (EventKey, sub-key
  params); plumbed through bus Hub, consume loop, and the
  Hello / PreShutdownCheck / ConsumerInfo protocol messages
- add a synchronous Match filter stage before Process
- change PreConsume cleanup to func() error and surface cleanup
  (unsubscribe) failures as WARN with an idempotency note
- adapt minutes/vc/whiteboard PreConsume to the new cleanup signature
- render SubscriptionID / SubscriptionKey in event status & schema output

No domain wires these hooks yet; covered by unit tests using bus/protocol
doubles. (Mail, the original exerciser, is intentionally not included.)

Change-Id: Ifc743f1aa0bc4dff0c8a1e35da24883694fe7699
2026-06-11 16:22:04 +08:00
shifengjuan-dev
e64610f6d2 docs(im): document chat.managers and chat.moderation API resources (#1294)
Add SKILL.md entries for the group manager and group moderation
(speaking-permission) API-meta resources:
- chat.managers.add_managers / delete_managers (指定/删除群管理员)
- chat.moderation.get / update (查询/更新群发言权限)
2026-06-11 15:12:21 +08:00
raistlin042
dfa26c38f6 feat: exclude .git directory from apps +html-publish package (#1396)
* feat: exclude .git from html-publish package walk

* docs: note .git auto-exclusion in html-publish reference

* test: update html-publish e2e for .git exclusion

* docs: simplify .git skip comment in html-publish walker
2026-06-11 14:58:58 +08:00
zhengzhijie
80d9f6b59b feat(sheets): add counta to chart aggregateType enum
Add `counta` (count non-empty cells, incl. text) to manage_chart_object
dim2.series[].aggregateType in the chart flag schema. `count` only counts
numeric cells, so counting occurrences of a text/category column renders an
empty chart; `counta` enables category frequency counts. Synced from the
sheet-skill-spec canonical schema.
2026-06-11 14:32:03 +08:00
evandance
154ecdb90f feat(wiki): emit typed error envelopes across the wiki domain (#1350)
Emit structured validation, API, network, file, and internal error envelopes for Wiki shortcuts so users and agents can recover from failed wiki workflows using stable type, subtype, param, and code fields.

Add Wiki domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
2026-06-11 14:02:29 +08:00
syh-cpdsss
483043c88b fix: parsing empty whiteboard (#1391)
Change-Id: I10082f89c36ed77e77e1d016be263e0f7369b7b3
2026-06-11 11:27:38 +08:00
linchao5102
6d8dc402ac fix: support git credential dry-run (#1390)
* fix: support git credential dry-run

* test: cover git credential dry-run output
2026-06-11 01:49:06 +08:00
liangshuo-1
9f2e049858 chore(release): v1.0.51 (#1388) 2026-06-10 22:51:08 +08:00
raistlin042
2c703f2fce feat: apps support multi dev modes (#1175)
* feat: add fullstack app-type and --message to apps +create (#1)

* feat: accept fullstack app-type and require --message for it

* feat: inject message into fullstack create request body

* refactor: align fullstack message injection with existing body-build style

* docs: document fullstack app-type and --message for apps +create

* docs: keep scene numbering consistent in lark-apps-create reference

* docs: add HTML/fullstack intent routing to lark-apps SKILL.md

* docs: cover fullstack in lark-apps skill description and clarify HTML flow step

* test: assert fullstack in allow-list error and reject wrong-cased fullstack

* feat: drop --message from apps +create (#4)

* feat: drop --message from apps +create

* docs: drop --message and document agent-generated name/description for apps +create

* feat: add apps local key-value file storage (#5)

* feat: add Miaoda app git credential support (#9)

* fix: remove APIError detail field dependency

* docs(apps): expand lark-apps skill for local-dev & cloud-chat workflows (#3)

Reframe lark-apps from an HTML-publish skill into a full Miaoda app dev
tool covering three paths: local fullstack dev, HTML hosting, and cloud
session dev. Builds on the fullstack create change already on this branch.

- SKILL.md: 3-path routing table; mental models (code via native git,
  develop/main branch model, DB via +db-* through Miaoda, env auto-pulled
  by `npm dev run`, auto-managed credentials); command index for the new
  verbs; ambiguous-input fallback (infer app type from need, ask local vs
  cloud instead of assuming; default HTML when no signal)
- add local-dev and cloud-dev playbooks
- create: keep HTML/fullstack + required --message; add local/cloud scene
  routing and --enable-multi-env-db
- list: usable by agents with --filter; app_id resolution order
  (user-provided / .spark/meta.json / +list --filter)

Co-authored-by: wangjiangwen-gif <286006750+wangjiangwen-gif@users.noreply.github.com>
Co-authored-by: raistlin042 <lvxinsheng@bytedance.com>

* feat(apps): add 4 db CLI commands (table-list / table-schema / sql / dev-init)

妙搭 data CLI 4 条命令,复用存量 OpenAPI URL + 1 个新增 dev-init:
- +db-table-list  → GET /apps/{id}/tables(游标分页,AppTable 含预估行数/占用空间)
- +db-table-schema → GET /apps/{id}/tables/{name}(默认结构化 schema;--format pretty 出建表 DDL)
- +db-sql         → POST /apps/{id}/sql_commands(?transactional=false DBA 模式)
- +db-dev-init    → POST /apps/{id}/db_dev_init(单库→online/dev,不可逆,high-risk-write)

要点:
- sql result 兼容两种 wire 形态(结构化 [{sql_type,data,record_count}] 与 legacy ["rows-json"])
- 多语句失败:server 返 code:0 + ERROR 哨兵,CLI 升级成 typed api_error(exit 非 0),
  detail 带 statement_index/completed/rolled_back,防止 agent 误判 ok:true 假成功
- pretty 渲染对齐 miaoda:列间两空格、CJK 双宽、size 友好格式(KB/MB/GB)
- 单测 + e2e dry-run 全覆盖;BOE 真机 e2e 验证通过(25 PASS)
- SKILL.md 注册 4 条命令 + 4 篇 reference

注:内含的 BOE 联调专用 env 覆盖(LARK_CLI_OPEN_API_BASE / LARK_CLI_X_TT_ENV,
internal/cmdutil + internal/envvars)未包含在本次提交,仅本地联调用。

Change-Id: I0fe4458086708a93941e2dee852fa6a10b53bd4a

* docs(lark-apps): db 能力补进 SKILL.md description 的 WHEN 段

按 skill 质量规范(description 三段式 WHAT+WHEN+NOT,加载前唯一可见信息),
原 WHEN 仅"连数据库调试"含糊覆盖 db。补成「查看或操作应用数据库(看表结构 /
跑 SQL / 初始化 dev 环境)」,让 +db-table-schema / +db-sql / +db-dev-init
类查询能精确触发,净增 ~12 字无膨胀。

Change-Id: Id52819fa7d6b8ed0c1f174bf5946d55da7b893d7

* Feat/apps env pull (#11)

* feat: add apps env-pull shortcut

* fix: support array env_vars response in apps env-pull

* fix(apps): improve env-pull merge and expiry output

* feat: add keyword/scope/app-type query to apps +list and unhide it (#8)

* feat: switch apps +create --app-type enum to lowercase html/full_stack

* feat: add keyword/scope/app-type query to apps +list and unhide it

* docs: document apps +list query params and lowercase app_type enum

* test: update apps cli_e2e dry-run tests for lowercase app_type and +list filters

* docs: trim redundant app_type case-sensitivity note in create skill

* docs: single-source apps +list usage contract to SKILL.md

* feat: add apps publish shortcuts (publish/status/history/error-log) (#12)

* feat: add apps publish shared guard and NodeStatus mapping

* test: cover json.Number path in injectStatusName

* feat: add apps +publish shortcut

Implements the `apps +publish` command with dry-run preview (upstream
PSM path shown) and an Execute gated by ensurePublishWired() per the
not-yet-deployed OpenAPI gateway constraint (publishAPIWired=false).

* refactor: make apps publish path placeholders var to satisfy go vet

Declare the four publishXxxPath constants as var instead of const so
go vet's printf analyzer skips them while they are empty placeholders.
Revert the Execute path-build in apps_publish.go from strings.Replace
back to fmt.Sprintf (now safe because the format string is a var).

* feat: add apps +publish-history shortcut

* feat: add apps +publish-status shortcut

* feat: add apps +publish-error-log shortcut

* feat: register apps publish shortcuts

Add AppsPublish, AppsPublishHistory, AppsPublishStatus, AppsPublishErrorLog
to Shortcuts() and update count test from 6 → 10.

* docs: add skill references for apps publish shortcuts

* docs: surface apps publish shortcuts in lark-apps SKILL.md

* docs: clarify publish instance id is not an approval instance

* docs: nudge agent to run apps +publish --dry-run for release requests

* feat: update apps publish shortcuts to v1.0.381 release protocol

Rename concept instance→release across all 4 publish shortcuts and their
tests: NodeStatus→ReleaseStatus enum, --instance-id→--release-id flag,
pipelineTaskID→releaseID response field, errorJobs→errorLogs, and
upstream HTTP path consts→RPC method name consts (PSM lark.apaas.devops
v1.0.381). Dry-run now shows psm+rpc_method instead of an HTTP path.

* docs: update apps publish skill docs to v1.0.381 release protocol

* fix: soften apps publish unavailable hint to user-facing language

* feat: update apps publish to v1.0.385 string status + --status filter

- Remove obsolete int-enum machinery (releaseStatusName/toInt/injectStatusName)
  and their encoding/json + fmt imports from apps_publish_common.go
- +publish Execute now returns status string alongside release_id
- +publish-history gains --status Enum flag (publishing/finished/failed);
  buildHistoryBody gains status param, table column status_name→status
- +publish-status Execute drops injectStatusName, pretty prints out["status"]
- +publish-error-log shapeErrorLog is string passthrough (no status_name)
- Unit tests updated: delete 3 obsolete common tests, update history/error-log

* docs: update apps publish docs to v1.0.385 string status + --status filter

* feat: wire apps publish shortcuts to final gateway paths (guard stays until deploy)

Replace RPC-name placeholders with real OpenAPI paths (publishCreate/Get/ErrorLog/ListPath consts). Switch DryRun to idiomatic HTTP form (POST/GET + real URL + body/params). Fix body/query placement: publish body has no app_id (path-only); history switches from POST body to GET query with snake page_token. Fix Execute response reads to snake_case fields (release_id, created_at, updated_at, error_logs). publishAPIWired stays false; 1-line flip activates live calls.

* docs: update apps publish docs to final gateway paths

Replace RPC/PSM dry-run example with real HTTP form (POST/GET /open-apis/spark/v1/apps/:app_id/releases[/:release_id[/error_logs]]).
Fix all response field names to snake_case (release_id, created_at, updated_at, error_log).
Note --status/--limit/--page-token as HTTP query params in publish-history.

* feat: enable apps publish gateway calls (remove not-deployed guard)

* docs: remove not-deployed transition notes from apps publish docs

* feat: use spark:app:publish scope for apps +publish

* feat(apps): add +init shortcut to initialize Miaoda app repo (#6)

* feat(apps): add command runner and credential redaction for +init

* fix(apps): make credential redaction scheme matching case-insensitive

* feat(apps): add +init shortcut declaration, validation, and dry-run

* feat(apps): implement +init orchestration (credential-init, clone, checkout, conditional push)

* fix(apps): redact full userinfo when repo URL contains literal @

* docs(apps): add +init skill reference

* fix(apps): declare explicit empty Scopes on +init shortcut

* fix(apps): consume repository_url from +git-credential-init in +init

* feat(apps): add +init template flag and absolute-path dir resolution

* refactor(apps): use shared charcheck for +init --dir validation

* feat(apps): add meta.json, steering, and empty-repo helpers for +init

* feat(apps): add +init npx scaffold orchestration (init/upgrade branches)

* feat(apps): wire +init scaffold, already-initialized short-circuit, npx dep check

* docs(apps): document +init npx scaffold, --template, --dir, already-initialized

* docs(apps): correct stale +git-credential-init unreleased note in +init ref

* fix(apps): reject all control chars in +init --dir

* feat(apps): add +init progress logging and optional --template resolver

* refactor(apps): inline constant in +init scaffold progress log

* docs(apps): document +init optional --template and stderr progress contract

* feat(apps): treat README-only repo as empty and commit with --no-verify in +init

* docs(apps): explain README-seed match and --no-verify rationale in +init

* docs(apps): document README-seed empty detection and commit --no-verify

* feat(apps): add session conversation lifecycle shortcuts (#13)

* feat(apps): add +session-create shortcut

* fix(apps): remove unused sessionPath helper, assert empty +session-create body

* feat(apps): add +session-list shortcut

* feat(apps): add +session-read shortcut

* feat(apps): add +session-stop shortcut

* feat(apps): add +chat shortcut

* feat(apps): register session lifecycle shortcuts

* docs(apps): add session conversation skill reference

* docs(apps): clarify fullstack session_id source and fallback

* style(apps): gofmt apps_session_create.go

* docs(apps): add conversation/session triggers to skill routing description

* docs(apps): add conversation flow guidance (when to reuse vs new session, per-step user prompts)

* docs(apps): slim session reference per skill quality standard (4047->1726 tok)

* docs(apps): tighten session additions in SKILL.md (4394->4145 tok)

* fix(apps): align +chat with v7.8 contract (async, no turn_id in response)

* fix(apps): update +chat path to .../sessions/{id}/chat (backend endpoint change)

* docs(apps): align SKILL.md session command shape with v7.8 contract

* style(apps): gofmt apps_db_table_schema_dryrun_test.go

Go 1.19+ gofmt 文档注释列表缩进新规则(普通缩进 → tab 对齐),
修复 fast-gate CI 的 gofmt 卡点。

Change-Id: Ic246a659e016d9d6216182199ef300ae6f00ef9d

* feat(apps): split +init commit, plainer wording, align skill branches (#14)

* refactor(apps): plainer +init progress/help wording, keep scaffold key

* refactor(apps): add porcelain change classifier for +init commit split

* feat(apps): split +init empty-repo commit into code + config, reword subjects

* refactor(apps): scaffold-kind constants and pathspec assertions for +init split

* docs(apps): use +init in Path A; align app-repo branch to sprint/default

* docs(apps): align local-dev playbook to sprint/default + origin remote

* docs(apps): document +init two-commit split and plainer init wording

* docs(apps): require asking clone dir before +init, no assumed path

* fix(apps): stage +init commits by exact paths to avoid gitignore error

* refactor(apps): lowercase miaoda in +init commit subjects

* test(apps): cover +init upgrade path with real git

* fix: harden app git credential handling (#16)

* fix: harden git credential refresh fallback (#18)

* fix(apps): validate env-pull key names before writing to .env.local (#17)

* fix(apps): validate env-pull key names before writing to .env.local

S2 (medium-low) from security review: env-pull wrote server-returned
env KEYs to .env.local without validation. A compromised or MITM'd
backend could inject arbitrary lines via keys containing newlines.

- Add envKeyPattern regex to validate keys match [A-Za-z_][A-Za-z0-9_]*
- extractEnvPullVars now returns skippedKeys for invalid key names
- Invalid keys are skipped (not hard-fail) so remaining valid keys
  are still pulled
- writeEnvPullPretty prints a warning listing skipped keys

* fix(skills): correct npm script syntax from 'npm dev run' to 'npm run dev'

* fix(skills): align env-pull guidance with implementation

🤖 Generated with [Aiden x Claude Code]

* test(apps): cover storage/git-credential error paths and fix tz-flaky env-pull tests (#19)

The coverage and unit-test CI jobs failed on two timezone-dependent
assertions in apps_env_pull_test.go: the code renders the database
expiry via time.Local() while the tests hard-coded a CST literal, so
they failed under CI's UTC. Compute the expected string from the same
timestamp with Local() instead, making the assertions timezone-agnostic.

Also add unit tests for the error branches codecov flagged as uncovered,
taking storage.go and git_credential.go to 100%:
- storage Read/Write/Delete/List filesystem-error paths
- +git-credential-remove ConfigWarning output (pretty and JSON)
- gitCredentialLocalError nil passthrough

* fix(apps): silence +init forbidigo, npx app sync -y --prefer-online (#20)

* fix(apps): add Subtype to env-pull error literals (#21)

typed_error_completeness lint requires all errs.XxxError literals to
set Problem.Subtype. Add the missing field to 11 error constructions:
- ValidationError (user input checks): SubtypeInvalidArgument
- ValidationError (API response parsing): SubtypeInvalidResponse
- InternalError (filesystem ops): SubtypeUnknown

* feat(apps): inject FORCE_DB_BRANCH=dev in env-pull output (#23)

* feat(apps): inject FORCE_DB_BRANCH=dev in env-pull output

Always write FORCE_DB_BRANCH="dev" into the resolved .env.local after
extracting upstream env_vars, so downstream tooling pinning the dev
database branch does not need a separate manual edit. Existing local
values are overwritten in place via the canonical merge path.

* docs(skills): document apps +env-pull in lark-apps skill

Add the env-pull entry to the lark-apps SKILL index and ship the
matching reference doc covering args, merge semantics, return shape,
error envelope subtypes, and dry-run behavior so AI agents can route
to it without reading the Go source.

* feat(apps): surface is_published and online_url in +list pretty view (#22)

* docs: refactor lark-apps skill per quality spec (#24)

Slim SKILL.md and references against the lark-cli skill quality spec
while preserving domain knowledge and safety guardrails.

- Compress SKILL.md (drop the MUST-read prelude, full command-index
  tables, and content already owned by lark-shared: auth, scope,
  exit-10, risk policy, _notice); add version field; zero CRITICAL
  markers.
- Defer flag enumeration in references to `--help`; convert
  narration-inducing prohibitions into positive defaults; de-duplicate
  the per-file error.hint relay into a single resident SKILL.md rule.
- Fix stale facts found against shortcuts/apps source: drop the
  non-existent +create --message and --enable-multi-env-db flags,
  +list --filter (now --keyword), +db-multi-env-init (now
  +db-dev-init), and the removed html-publish cwd hard-reject.
- Keep all safety guardrails: db-dev-init irreversibility/exit-10,
  db-sql non-transactional multi-statement, git-credential token
  handling, html-publish credential scan, access-scope confirmation.
- Restore intent lost during slimming: release_id is not an approval
  instance (do not route to lark-approval); resolve access-scope
  targets via contact/im; ask the user before publishing as a
  side-effect; distinguish developing an existing app locally
  (+init) from creating a new one (+create).

* test(apps): supplement shortcuts/apps unit-test coverage to 88% (#25)

* test(apps): cover db-table-list numeric/byte formatting helpers

* test(apps): cover db-sql cell/code/dml/error render helpers

* test(apps): cover env-pull newline/expiry/extract-vars helpers

* test(apps): cover db-sql render branches and env-pull expiry edge case

* test(apps): cover init empty-dir/meta/ls-files error branches

* test(apps): cover env-pull target/read/parent-dir error branches

* test(apps): cover stage-and-commit and commit-push error branches

* test(apps): cover access-scope target split and JSON validation

* test(apps): cover html-publish decode error and scaffold sync failure

* test(apps): cover apps-update body field combinations

* test(apps): cover access-scope body build branches

* feat(apps): pass --local to npx skills sync in +init (#26)

* feat(apps): pass --local to all npx miaoda-cli calls in +init

* feat(apps): pass --local only to npx skills sync in +init

* docs(apps): surface +publish and +init dir-choice in local-dev flow (#27)

* docs(apps): surface +publish as deploy action in skill routing

* docs(apps): add explicit deploy-after-local-edit section to local-dev

* docs(apps): promote +init dir-choice instruction to a domain rule

* docs(apps): make dev-method a signal-driven entry gate before routing (#28)

* docs(apps): restore three-path overview line in apps skill intro (#29)

* feat(apps): add executable Examples to shortcut --help and error hints (#30)

* test(apps): guard every shortcut has a help Example and no PII

* feat(apps): add help Examples to all 24 apps shortcuts

* feat(apps): add actionable hints to high-impact error paths

* test(apps): cover withAppsHint set-if-empty hint behavior

* feat(apps): use concrete enum value in access-scope-set Example

* docs(apps): clarify db-sql/db-table-list json default output behavior

两处仅补充注释,不改逻辑:
- +db-sql: data.results 在 json 默认路径原样透出全部行,CLI 不二次截断;
  server 对单条 SELECT 有 1000 行硬上限、超出直接返报错,非无界 token 黑洞。
- +db-table-list: json 默认透出含每表完整 columns[] 系产品设计(list 接口本就
  返回列定义,json 消费方一次拿全量、免逐表再调 +db-table-schema),pretty 仅摘计数。

Change-Id: I1a49de8defc4428bfe1e774e4fd7adb45e59e3af

* feat(apps): command-layer AI-friendliness governance (P0+P1) (#32)

* fix(apps): normalize --app-type case to align with server

* refactor(apps): migrate CallAPI to CallAPITyped for typed errors and retryable

* feat(apps): trim icon_url and created_at from +list default output

* feat(apps): add actionable hints to high-impact error paths

* feat(apps): add 2-3 help Examples to +chat and +access-scope-set

* docs(apps): add --jq filter tips to list/db commands

* docs(apps): sync +list reference with trimmed output fields

* test(apps): assert error hints and messages carry no secrets or PII

* fix(apps): prefix --jq tips with .data. so they run against the response envelope

* test(apps): expect --app-type uppercase normalization in create dry-run E2E (#33)

* fix(apps): scaffold via @latest miaoda-cli instead of @alpha (#34)

* feat(apps): rework lark-apps triggering, routing & confirm policy (#35)

* feat(apps): results-oriented triggering, pre-auth floors, terminal URL

Widen description WHEN to cover app-building openers (CRM/审批/HTML page)
with no Miaoda signal word, WHAT still anchored to 妙搭应用开发与托管.
Add a pre-authorization rule (auth words skip confirm) with two non-exempt
floors: destructive DDL (DROP/TRUNCATE/ALTER drop|modify column) dry-run,
and first public-URL publish (+publish/+html-publish) when no auth word.
Exempt html app_type from the local-vs-cloud dev-method gate, and scope
that gate to new-app creation only (existing-app ops route directly).
Require an accessible URL as the end-to-end terminal step.

* feat(apps): apply eval-fix behavior contracts across reference docs

init/local-dev: end-to-end default-directory escape hatch; end-to-end
new-build starts with +create. db-sql: additive DDL direct-exec when
authorized, destructive DDL stays dry-run. local-dev/publish-status:
return online_url via +list as the full_stack publish terminal step.
cloud-dev: generation != shareable URL, +publish handoff, background
until-poll snippet (sleep N && cmd intercepted; deprecate ScheduleWakeup),
multi-turn publish precondition. publish/publish-error-log: transient
failure (EAI_AGAIN/ETIMEDOUT/registry) discrimination, retry cap 2,
honest receipt. env-pull: first-launch fallback. local-dev/db-dev-init:
new full_stack ships dual DB, skip +db-dev-init.

* refactor(apps): apply review feedback — semantic criteria, drop overfit/unverified content

Per line-by-line review of the eval-fix changes:
- Entry routing reframed to objective/semantic criteria (new-vs-existing =
  'can an existing app be identified'; dev-method = who-writes-code
  preference), replacing keyword/example matching.
- db-sql DDL gate restated by effect (data-loss / reversibility), not a
  keyword list.
- Pre-authorization judged by expressed intent (not a word list); single
  non-exempt floor (destructive/irreversible DB dry-run); confirm policy in
  its own section, error.hint in 'failure handling'.
- init.md slimmed to command facts (directory choice owned by local-dev,
  no init<->local-dev cycle); local-dev defers new-vs-existing to the entry.
- Reverted unverified/redundant/runtime-coupled additions: cloud-dev
  session-read preview-URL claim + background-poll snippet + queued_count
  precondition; publish transient-retry/ScheduleWakeup; env-pull first-launch;
  db-dev-init positive restatement; SKILL terminal-URL mandate.
- Fixed dangling section references after the rename.

* fix(apps): scope pre-authorization to hands-off intent, not 'wants a result' (#36)

Follow-up to #35. The merged pre-authorization rule treated 'wanting the
final result' as authorization, so '先在本地跑起来让我看看' was read as
pre-authorized and the agent silently picked a clone directory without
asking. Re-state the criterion as the user's hands-off intent (explicit
waiver, or an end-to-end directive), judged uniformly across the flow
(directory/clone, publish) — not a per-decision carve-out. Merely wanting
a result or asking to review is not authorization.

* docs: clarify apps cloud dev publish state

* fix(apps): require commit+push before publish, clarify deploy flow (#38)

* fix(apps): require committing changes before publish in local-dev flow

* fix(apps): make commit+push mandatory before publish in agent rules

* fix(apps): scope selective-add caveat to incremental deploy, not new-app flow

* fix(apps): make pre-publish commit conditional on local changes

* fix(apps): tighten pre-publish commit wording in agent rules

* fix(apps): cloud-dev does not auto-deploy, add explicit publish step

* docs(apps): document +chat init vs incremental turn cost (#39)

First +chat on a not-initialized app runs full design+gen server-side
(~20-50 min); chat on an already-initialized app is incremental and
finishes in minutes. Surface this in the +chat Go comment as a pointer
and put the init-state check + matching polling cadence (5-10s vs
60-120s) in the lark-apps cloud-dev skill reference as the canonical
source. Cloud-side init check uses +session-read committed-version
info or +list is_published:true.

* docs(apps): document +chat init vs incremental turn cost (#40)

First +chat on a not-initialized app runs full design+gen server-side
(~20-50 min); chat on an already-initialized app is incremental and
finishes in minutes. Surface this in the +chat Go comment as a pointer
and put the init-state check + matching polling cadence (5-10s vs
60-120s) in the lark-apps cloud-dev skill reference as the canonical
source. Cloud-side init check uses +session-read committed-version
info or +list is_published:true.

* feat(apps): surface online_url/error_logs in +publish-status output (#41)

* refactor(apps): extract shared release error-log table helper

* fix(apps): keep error-log table byte-identical for null error_logs

* feat(apps): surface online_url/error_logs in +publish-status output

* docs(apps): read online_url/error_logs from +publish-status in publish flow

* docs(apps): align local/cloud dev publish flow with +publish-status fields

* refactor(apps): rename +db-dev-init→+db-env-create, trim db-table-list columns

- +db-env-create(原 +db-dev-init):新增 --env 参数(调用方传入,目前只支持 dev),
  --sync-data 改为 true/false 取值;服务端 URL 仍走 db_dev_init。
- +db-table-list:json 默认用白名单投影(dbTableListItem)只输出产品要求字段,
  每表 columns[] 折算成 column_count、不再透出完整列定义(与 +db-table-schema 重复且放大
  token);要完整列定义/索引/约束用 +db-table-schema。
- 同步对齐 db 相关 skill 文档(命令名、column_count、env-create 参数)。
- 单测 + cli_e2e dry-run 全绿。

Change-Id: I116ab11807679f8f06ed18221f705bab426d015c

* refactor(apps): rename +db-table-schema → +db-table-get

动词对齐 +db-table-list(list/get)。仅命令名 + 标识符 + 文档改名,行为/输出/URL 不变:
- AppsDBTableSchema→AppsDBTableGet,文件/测试/cli_e2e test 重命名
- buildDBTableSchemaParams→buildDBTableGetParams
- +db-sql / +db-table-list 里的交叉引用 hint、skill 文档同步

Change-Id: I36dfb8fd0d2613492a57dc7815bc58414c145480

* feat: auto-pull env vars after apps +init (#42)

* test: route apps +env-pull to its own fake-runner key

* feat(apps): add +env-pull envelope parsers for +init

* feat(apps): add pullEnv helper invoking sibling +env-pull

* feat(apps): +init auto-runs +env-pull after push (non-fatal)

* docs(apps): clarify db-sql --query @path is relative-only, use stdin for absolute paths

@path 受 lark-cli 全局文件安全策略约束,只接受 cwd 内相对路径;绝对路径 / cwd 不固定
场景改用 stdin(--query - < /abs/file.sql),无需先 cd。

Change-Id: Ib3453810cfc9303d72b4facf3493ad9688eeffd3

* docs(apps): refine db-sql --query path guidance wording

以 agent 视角重写:@ 仅接受工作目录内相对路径,绝对路径/越界路径被拒(CLI 文件访问统一约束);
工作目录外的文件经 stdin 传入。

Change-Id: Ic7db00934b3571368eb704451f4ce1776463806d

* feat(apps): make +db-sql high-risk-write (require --yes)

+db-sql 可含 DML/DDL,统一升级为 high-risk-write:框架对所有执行强制 --yes 确认关卡
(--dry-run 预览豁免),无 --yes 返 confirmation_required / exit 10。
- Risk: write → high-risk-write(去掉自定义门禁,直接用框架机制)
- skill 文档:命令骨架标注 --yes 要求;Agent 规则改为「执行需 --yes,只读可直接带、
  破坏性先 dry-run 确认再带」
- 单测所有执行调用补 --yes

Change-Id: I57e78832b35fa170a485774e6fb7289109d678c3

* docs(apps): clarify app_ (Miaoda) vs cli_ (Feishu) app id (#46)

* 优化云端开发skill,明确执行模型,参数解释 (#44)

Co-authored-by: fushengdong.1 <fushengdong.1@bytedance.com>

* refactor: rename apps publish commands to release and session-get (#45)

* refactor(apps): drop +publish-error-log, rename release path constants

* refactor(apps): rename +publish to +release-create

* refactor(apps): rename +publish-history to +release-list, unify pagination to --page-size

* refactor(apps): rename +publish-status to +release-get

Renames apps +publish-status → +release-get (AppsPublishStatus → AppsReleaseGet),
updates --release-id desc to reference +release-create, and fixes the Execute
error hint to point at +release-list instead of +publish-history.

* refactor(apps): rename +session-read to +session-get

* docs(apps): rename publish references to release, +session-read to +session-get

* refactor(apps): clean up residual publish/session-read references

Fix six leftover references missed in Tasks 1-6: +publish-history in
jq-tip test wantCmds map and common_test hint fixture (×3), +session-read
in apps_chat.go comment+output string (×2), apps_session_stop.go flag
desc (×1), apps_chat_test.go comment (×1), and +publish-status in
lark-apps-list.md agent rule prose (×1).

* docs(apps): clarify release-get link contract and session-get vs session-list

* docs(apps): generalize release-list page-size rule to N records

* feat(apps): rename +list --scope flag to --ownership (#47)

* feat(apps): rename +list --scope flag to --ownership

* test(apps): update +list cli_e2e dry-run for --ownership rename

* docs(apps): document +list --ownership flag

* feat(apps): align +release commands with new release API format (#48)

* feat(apps): align +release-create scope to spark:app:write

* feat(apps): raise +release-list --page-size documented max to 500

* feat(apps): show commit_id in +release-get pretty output

* docs(apps): update release reference docs for page-size 500 and commit_id

* test(apps): cover empty commit_id in +release-get pretty output

* docs: align lark apps cloud dev release flow

* feat(apps): redesign +db-sql → +db-execute (--sql/--file, default env dev)

按 db 子域命令最终设计重做执行入口:
- 命令 +db-sql → +db-execute(动词收尾,对齐 +db-table-list/-get)
- --query 拆为 --sql(内联/stdin)与 --file(.sql 文件路径),二选一互斥;
  --file 在 Validate 阶段读出归一化到 --sql
- 默认 --env online → dev(打生产库需显式 --env online)
- 文件/标识符/注册/测试/cli_e2e/skill 文档全部对齐重命名
- 新增测试:--sql/--file 互斥、--file 读取、默认 env=dev

不在本次范围:--transaction/--no-transaction(服务端 transactional 实为路径切换、
非真事务,需 dataloom 侧先支持真事务开关)、--max-rows/--timeout 等后续项。

Change-Id: I50c06faf83527471446e2a6651ccb51f6eedd6ff

* docs(apps): clearer --env online wording for +db-execute

把口语化的「打生产库需显式」改为「需要操作线上环境数据库时,显式指定 --env online」;
flag desc 同步去掉 hit production 措辞。

Change-Id: Iee82fccf17e08bddb4b760c3970a416746b10c4c

* docs(apps): drop 'ad-hoc' jargon from +db-execute description

中文文档/英文 description 去掉术语 ad-hoc;SELECT/DML/DDL 已表意,含义不丢。

Change-Id: Ie2cccc5fc3491fe5f57190a87b93ecd70405b156

* docs(apps): trim +db-execute when-to-use and --file path wording

- 何时用去掉「(查询 / 临时数据修复 / 应急 DDL)」枚举
- --file 路径说明去掉 .. /符号链接/统一约束 的技术化描述,改为「相对路径,
  否则用 --sql - < 文件路径」的产品化口吻

Change-Id: Ie70e57895c78650230b6942b03d90a2d95c937f2

* docs(apps): note --file rejects absolute/cwd-escaping paths

简短补回 --file 的路径约束(绝对路径 / 经 ..、符号链接越界会被拒),去掉冗余评注。

Change-Id: I549893c82cafbe97529e08dcbc3ee5496927da18

* fix(apps): replace t.Chdir with os.Chdir in db-execute test (Go 1.23 compat)

t.Chdir 是 Go 1.24 API,但 go.mod 为 go 1.23.0,CI(Go 1.23)报
"t.Chdir undefined"。改用 os.Chdir + t.Cleanup 还原,1.23 兼容。

Change-Id: I550611773e5088275be1c4344d4f8269610ce74a

* feat(apps): refine +init description and refresh env on re-init

* fix(apps): treat accessible-link requests as publish intent (#53)

* refactor(apps): +db-env-create --sync-data string-enum → Type:bool

原实现用 string + Enum["true","false"] + == "true" 模拟 bool,啰嗦且非惯用。
改为 Type:bool(rctx.Bool):传 --sync-data 即开启、省略为 false。
同步更新测试、cli_e2e dry-run、skill 文档。

Change-Id: I3068e0577fa20a7cbaf414ca9af3d197f6ae8049

* fix(apps): declare --app-type as strict lowercase enum (#55)

* docs(apps): front-load routing, dedupe, and trim lark-apps skill (#56)

* docs(apps): front-load intent-routing table and dedupe skill body

* docs(apps): dedupe publish guardrail and polling rules in cloud-dev

* docs(apps): trim env-pull implementation detail to behavior contract

* docs(apps): add +env-pull routing entry in SKILL.md

* docs(apps): fix create.md cross-ref to actual SKILL.md section name

* feat(apps): add error.hint to command failures and a consistency gate (#57)

* feat(apps): add appIDListHint const and wrap 4 pure app-id command failure paths

Adds shared `appIDListHint` recovery hint to common.go and wraps the
CallAPITyped failure branch of session-create, session-list, update, and
release-list to surface an actionable next-step hint on 4xx errors.
Includes httpmock unit tests in apps_hints_more_test.go (TDD: red→green).

* feat(apps): add sessionStopHint and createHint for session-stop and create commands

Adds per-command recovery hints with specific guidance: sessionStopHint
points at +session-list and +session-get; createHint explains valid
--app-type values and permission failure. Wraps the CallAPITyped failure
branch in both commands.

* feat(apps): add recovery hints for db-env-create, db-table-get, db-table-list

Adds dbEnvCreateHint, dbTableGetHint, and dbTableListHint with actionable
cross-command guidance (e.g. pointing at +db-table-list for env conflicts,
+db-env-create for missing dev env). Wraps only the CallAPITyped failure
branch; requireAppID validation errors are left untouched.

* refactor(apps): make session-stop hint runnable and align hint test names

* test(apps): guard withAppsHint upstream-wins contract and new hint leak safety

* test(apps): add help-skill command consistency gate

---------

Co-authored-by: linchao5102 <linchao.5102@bytedance.com>
Co-authored-by: Wang <wangjiangwen@bytedance.com>
Co-authored-by: wangjiangwen-gif <286006750+wangjiangwen-gif@users.noreply.github.com>
Co-authored-by: 陈兴炀 <chenxingyang.1019@bytedance.com>
Co-authored-by: aihao-git <aihao.0331@bytedance.com>
Co-authored-by: bali <bali@bytedance.com>
Co-authored-by: hunnnnngry <chenxi.xichen@bytedance.com>
Co-authored-by: shengdongyc <1135978761fsd@gmail.com>
Co-authored-by: fushengdong.1 <fushengdong.1@bytedance.com>
2026-06-10 21:45:45 +08:00
sammi-bytedance
501bf539af feat(im): complete audio/post rendering and add opt-in --download-resources (#1245)
Block 1 — field completion: audio renders <audio key="..." duration="Xs"/>
(falls back to [Voice: Xs]/[Voice]); post renders emotion -> :emoji_type:,
applies text.style (bold/italic/underline/lineThrough), passes through md;
sticker unchanged.

Block 2 — opt-in --download-resources (default off) on +chat-messages-list,
+messages-mget, +threads-messages-list: extract downloadable resource refs
during formatting (image/file/audio/video/media + post-embedded; sticker
excluded; merge_forward sub-items carry the top-level container message_id,
since the resources endpoint rejects sub-item ids with "234003 File not in
msg" and can only fetch a forwarded resource through the container; thread
replies get their own block), then download each distinct (message_id,
file_key) once into ./lark-im-resources/ with bounded concurrency (3), filling
back local_path/size_bytes; single-resource failures are isolated (error:true +
stderr warning). Path safety reuses normalizeDownloadOutputPath +
ResolveSavePath.

Batch download keys each file on disk by its unique file_key basename and only
appends an extension (from the Content-Disposition filename or MIME type) —
it does NOT substitute the server's Content-Disposition filename. Otherwise two
resources whose servers return the same filename (e.g. download.bin) would
resolve to the same ./lark-im-resources/ path and clobber each other
concurrently. The friendly "adopt the server filename" behavior is kept only
for an explicit +messages-resources-download with no --output.

Resource ref extraction guards against self-referential / cyclic merge_forward
prefetch maps (a real API sub-item list can include the container's own id or a
back-pointing merge_forward) via a visited set, so extraction terminates instead
of overflowing the stack. The container message_id is threaded through nested
merge_forwards as the download owner.

Also: document the feature (including the im:message:readonly scope requirement)
in skills/lark-im — SKILL.md is generated from skill-template/domains/im.md
(edit the source), plus the hand-written message-enrichment + 3 command
references.

Change-Id: I3a71d7d1b193130f551aaa2ec180ac1500d59ac4
Meego: https://meego.larkoffice.com/5e96d7bff4e7c525510f9156/story/detail/7331555925
2026-06-10 20:07:49 +08:00
Yuxuan Zhao
8e667db534 test(base): remove brittle primary field/view assertions (#1386) 2026-06-10 18:13:21 +08:00
evandance
e751a53f76 feat(markdown): emit typed error envelopes across the markdown domain (#1347)
Emit structured validation, API, network, file, and internal error envelopes for Markdown shortcuts so users and agents can recover from failed markdown workflows using stable type, subtype, param, and code fields.

Add Markdown domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
2026-06-10 17:42:18 +08:00
fangshuyu-768
e794fd5925 docs(skills): remove unsupported ⚠️ from callout emoji list (#1374) 2026-06-10 16:29:21 +08:00
zgz2048
077b5e7180 feat: configure initial base table schema (#1377)
* feat: configure initial base table schema

* fix: add base create table scopes
2026-06-10 15:47:33 +08:00
zhangjun-bytedance
0d20a02050 feat: replace words for transcript (#1372) 2026-06-10 14:41:44 +08:00
fangshuyu-768
7cc0b49603 docs(skills): warn about @file absolute path restriction in lark-doc skills (#1375) 2026-06-10 14:21:34 +08:00
evandance
6b48a39d55 feat(slides): emit typed error envelopes across the slides domain (#1349)
Emit structured validation, API, network, file, and internal error envelopes for Slides shortcuts so users and agents can recover from failed presentation workflows using stable type, subtype, param, and code fields.

Add Slides domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
2026-06-10 14:08:25 +08:00
evandance
b07be60068 feat(sheets): emit typed error envelopes across the sheets domain (#1348)
Emit structured validation, API, network, file, and internal error envelopes for Sheets shortcuts so users and agents can recover from failed spreadsheet workflows using stable type, subtype, param, and code fields.

Add Sheets domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
2026-06-10 11:51:42 +08:00
calendar-assistant
31bc87a2cc feat(vc): add recording event support (#1369) 2026-06-10 11:42:12 +08:00
liangshuo-1
7fdf55821b chore(release): v1.0.50 (#1359) 2026-06-09 22:43:44 +08:00
evandance
201e3e016f feat(doc): emit typed error envelopes across the doc domain (#1346)
Emit structured validation, API, network, file, and internal error envelopes for Doc shortcuts so users and agents can recover from failed document workflows using stable type, subtype, param, and code fields.

Add Doc domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
2026-06-09 20:43:20 +08:00
xiongyuanwen-byted
080ef44cdb Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-09 19:52:08 +08:00
xiongyuanwen-byted
eed711bb11 feat(sheets): guard +csv-put --csv against a path passed without @ (#1337)
+csv-put --csv data.csv (a forgotten @) was silently written as one-cell content, because any string parses as valid CSV — unlike malformed JSON it never errored, so the filename landed in the sheet instead of the file's contents.

+csv-put's Validate now rejects a --csv value when it names a real file in the cwd subtree (guardCSVValueIsNotFilePath; fileIO.Stat, fail-open), hinting to use --csv @file or stdin (--csv -). Scoped to --csv only — no framework or other-flag change. Checking real existence (not name shape) lets inline content that merely ends in a filename pass through. Adds TestGuardCSVValueIsNotFilePath.
2026-06-09 19:48:28 +08:00
fangshuyu-768
4f4c0b59c9 docs(lark-doc): replace append with block_insert_after in skeleton workflow guidance (#1340)
`append` always inserts at document end (equiv. `block_insert_after --block-id -1`),
but skill docs previously recommended it for the "skeleton + chapter-by-chapter fill"
pattern, causing all content to pile up after the last heading.

Changes:
- Remove `append` from skeleton workflow guidance in `lark-doc-create-workflow.md`
  and `lark-doc-create.md`; recommend `block_insert_after` with explicit `--block-id`
- Fix `block_move_after` required params: remove `--content` (not supported),
  only `--block-id` and `--src-block-ids` are valid
- Add bash language tag to code block for proper highlighting
2026-06-09 18:11:56 +08:00
xiongyuanwen-byted
f046fb6282 fix(sheets): regenerate flag defs and fix asasalint in table io 2026-06-09 17:48:58 +08:00
xiongyuanwen-byted
ca9eddb142 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-09 17:29:26 +08:00
evandance
2b4c6349a1 feat(event): emit typed error envelopes across the event domain (#1289)
Replace every command-facing error path in the event domain — the
consume/schema command layer, the +subscribe shortcut, EventKey
definitions, and the consume orchestration — with typed errs.*
envelopes, so consumers get stable type, subtype, param, hint, and
missing_scopes metadata for classification and recovery instead of
free-form message text.

- Input validation (--jq, --param, --output-dir, --filter, --route,
  unknown EventKey, EventKey params) reports validation /
  invalid_argument with the offending flag in param and an actionable
  hint.
- Scope preflight reports authorization / missing_scope with the
  machine-readable missing_scopes list; console-subscription and
  single-bus preconditions report failed_precondition with recovery
  hints.
- The consume API boundary passes already-typed errors through and
  classifies transport, non-JSON HTTP, and unparsable responses; the
  vc note-detail retry now matches the not-found code on typed errors
  (it silently never fired against the legacy envelope shape).
- Previously-bare failures exited 1 with a plain-text "Error:" line
  and now exit with their category code (validation 2, auth 3,
  network 4, internal 5) alongside the typed stderr envelope.
- forbidigo and errscontract guards now cover the event paths so
  regressions fail lint; AGENTS.md and the lark-event skill document
  the typed contract for agent consumers.

Validation: make unit-test (race) green; event unit and e2e suites
assert category/subtype/param/hint and cause preservation against the
real binary; errscontract and golangci lint clean.
2026-06-09 17:12:55 +08:00
wangweiming-01
944cd55fc7 docs: add drive comment location guidance (#1258)
Change-Id: I7cfdfd5a456658cca89fc974ef7a85dc20c2c395
2026-06-09 17:00:56 +08:00
zhengzhijiej-tech
1caeb2d377 Merge pull request #1351 from larksuite/fix/chart-dim-insert-example
docs(sheets): chart / filter / workbook reference corrections
2026-06-09 16:47:31 +08:00
zhengzhijie
a66bef66af docs(sheets): label +sheet-create --index as 0-based
The base flag description for +sheet-create's --index omitted the
coordinate base, while its siblings +sheet-move ("Target position
(0-based)") and +sheet-copy already state 0-based. Align the description
so the index base is unambiguous. Synced from the spec source
(flag-defs.json + workbook reference).
2026-06-09 16:25:02 +08:00
zhengzhijie
421805d35c docs(sheets): chart coordinate base / quoting + filter condition enums
Sync three reference-doc corrections from the spec source:

1. chart: label position.row as 0-based (first row = row:0), distinct
   from the 1-based row numbers used by A1 ranges and +dim-insert
   --position, removing the row-base ambiguity.

2. chart: convert the three runnable examples whose JSON contains a
   quoted sheet prefix ('Sheet1'!A1) from inline single-quoted
   --properties '{...}' to a stdin heredoc (--properties - <<'JSON').
   Inside an inline single-quoted string bash strips the inner quotes
   around the sheet name (and splits names with spaces into words),
   corrupting the JSON; a quoted heredoc delimiter performs no shell
   substitution and preserves it. Adds a short note on the pitfall.

3. filter / filter-view: add the full conditions[].type x compare_type
   enum table (text / number / multiValue / color and their respective
   compare_type values and values shape), and call out the
   equals/notEquals (with s) vs equal/notEqual (no s) gotcha. The docs
   previously only showed two values via examples.
2026-06-09 16:25:02 +08:00
zhengzhijie
8d5bb73c70 docs(sheets): fix invalid +dim-insert example in chart reference
The chart reference's placement example used non-existent flags
--dimension/--start/--end for +dim-insert. The real signature is
--position (required) + --count (required); copying the example
fails Validate with "--position is required". Replace it with
+dim-insert --position V --count 6 (insert 6 columns before V,
i.e. after U), aligning with the sheet-structure reference.
2026-06-09 15:34:05 +08:00
fangshuyu-768
7229baae40 fix: clarify --block-id supports comma-separated batch delete in help text (#1336) 2026-06-09 15:21:09 +08:00
fangshuyu-768
170565c57e fix: add @file/stdin support to drive +add-comment --content (#1343) 2026-06-09 15:20:25 +08:00
evandance
03ea6e78b8 feat(contact): emit typed error envelopes across the contact domain (#1287) 2026-06-09 12:07:35 +08:00
ViperCai
ed3fe9337f fix(slides): build create URL locally instead of drive metas call (#1329)
slides +create finished by calling /drive/v1/metas/batch_query just to
fetch the presentation URL. That call needs a drive scope the shortcut
never declares, so it 403'd for users who only authorized slides scopes
(both UserAccessToken re-auth and TenantAccessToken scope-not-opened),
producing a large share of the shortcut's failure telemetry — even though
the presentation itself was already created successfully.

slides creation never otherwise touches drive, so rather than gating a
drive-free operation behind a drive scope, build the URL locally from the
token via common.BuildResourceURL (the same brand-standard-host fallback
already used by drive +upload / wiki +node-create). The URL is now always
returned, no extra scope is required, and creation never blocks.

Tests are updated to match: drop the registerBatchQueryStub helper and its
call sites (the httpmock Verify cleanup was failing on the now-unconsumed
batch_query stubs), point url assertions at the brand-standard host, and
replace TestSlidesCreateURLFetchBestEffort with TestSlidesCreateURLBuiltLocally,
which asserts the url is produced with no drive call registered.
2026-06-09 11:30:14 +08:00
ZEden0
cc416a4de5 docs(lark-doc): document <folder-manager> resource block (#1168)
- lark-doc-xml.md §三「资源块」: add <folder-manager wiki-token="..."> entry
  with full sub-page schema (title / url / file-type+doc-id fallback /
  space-id / owner / owner-id / create-time / edit-time, ms timestamps,
  has-more="true" beyond 100 children)
- lark-doc-xml.md §四「复制」: append folder-manager to copy support list
  (per spec FE-1 TC-D acceptance)
- lark-doc-xml.md §八 完整示例: add folder-manager example
- lark-doc-fetch.md: add 子页面列表 section explaining fetch behavior,
  url-first / file-type+doc-id fallback, container-only on wiki.core
  failure or no permission

Spec ref: cli-docx-folder-manager FE-1

Change-Id: I746fbebcc3398c5ec0b144f2eb2a306e6d96fb74
2026-06-09 10:46:03 +08:00
JackZhao10086
00d45f8fa2 feat: adjust agent timeout hint output conditions (#1328) 2026-06-09 10:05:11 +08:00
liangshuo-1
0d847511d2 chore(release): v1.0.49 (#1331) 2026-06-08 21:38:23 +08:00
xiongyuanwen-byted
97b9ffb466 docs(sheets): align +csv-put help with formula support
Sync the formula-support wording from sheet-skill-spec (flag-defs, skill
references) and update the hand-authored cobra Description and comment for
+csv-put. +csv-put evaluates a leading-= cell as a formula via
set_range_from_csv; descriptions only, no behavior change.
2026-06-08 20:38:10 +08:00
fangshuyu-768
8f5504c51c docs: improve lark-doc skill guidance (#1283) 2026-06-08 20:02:28 +08:00
zhengzhijiej-tech
336f147ca6 Merge pull request #1296 from larksuite/feat/sheet-eval-guidance-fixes
docs(sheets): strengthen lark-sheets references for common editing pitfalls
2026-06-08 19:13:29 +08:00
fangshuyu-768
d0a896ce91 docs(skills): tighten drive and markdown guardrails (#1326) 2026-06-08 19:11:41 +08:00
zhengzhijie
0a47f35c7d docs(sheets): align write-cells reference with the generated output
Bring the hand-applied write-cells example in line with the spec-generated
reference so the CLI mirror is byte-identical to the canonical source.
2026-06-08 19:07:44 +08:00
fangshuyu-768
99ceb2279c feat(markdown): harden create upload failures (#1325)
* feat(markdown): harden create upload failures

* test(markdown): address AI review follow-ups
2026-06-08 18:17:35 +08:00
Emrys1105
ec2ffebf47 fix: keep bounded event consume runs alive after stdin EOF (#1285) 2026-06-08 18:09:21 +08:00
hugang-lark
ee5113f9d0 fix: optimize calendar,vc,minutes skill (#1269) 2026-06-08 17:36:05 +08:00
liangshuo-1
7cce7468d6 docs(approval): restructure skill with intent table and scope boundaries (#1307)
* docs(approval): restructure skill with intent table and scope boundaries

Rewrite the description for intent-based routing (situation framing
instead of method enumeration) and add the lark-task disambiguation.
Replace the bare method list with an intent-to-command table including
topic and add_sign_type enums, document the query-to-operate workflow
chain with a runnable example, and add an out-of-scope section routing
definition creation to the Feishu client/admin console.

Bump version to 1.1.0.

Change-Id: I33b7b13b7855d67f40954701a09b115e3c91176c

* docs(approval): strengthen description coverage of edge actions

Restore the "all processing operations" phrasing so edge actions like
remind route to this skill; weak-model routing evals regressed on the
narrower "query and process" wording (2 misses in 4 runs vs 0 after
the fix).

Change-Id: Ica1928dacf879b6c7a46dfda37e35b1be9391432

* docs(approval): drop misleading 已发起 from tasks query row

tasks query 查的是本人作为审批人的任务;已发起(本人发起的实例)应走
instances initiated,该路径已在下方表行列出。移除 tasks query 的「已发起」
标签与 topic=3 枚举,避免 agent 误用 tasks query topic=3 查已发起。
2026-06-08 17:32:10 +08:00
fangshuyu-768
281cdbd37c feat(drive): harden inspect shortcut failures (#1324) 2026-06-08 17:09:53 +08:00
Chenweifeng-bd
72ac526e23 docs: add lark sheets financial modeling guidance 2026-06-08 17:05:11 +08:00
ViperCai
add079ea1c docs(lark-slides): tighten routing/boundary and reconcile in-slide whiteboard (#1169)
Land the high-value, low-risk items from the skill-quality audit; SKILL.md only.

- description: drop the '接口通过 XML 协议通信' impl detail; append a 不负责
  out-of-scope clause so 'make a deck' / 'draw a diagram' stop mis-routing.
- replace the 权限速查 scope table with a ## 不在本 skill 范围 routing table
  (doc / whiteboard / drive / sheets / base).
- reconcile the whiteboard boundary with the in-slide <whiteboard> element
  (added on main, #1029): lark-whiteboard owns only standalone whiteboard
  objects in cloud docs; flow/architecture diagrams drawn inside a slide stay
  in this skill via <whiteboard>. Clarified in description and out-of-scope note.
- defer auth / permissions / global params to lark-shared as single source.
- move native-API resource hint into prose; reword schema reminder; move the
  'schema is source of truth' note next to 核心规则.

Deliberately not adopted: moving Design Ideas out of the body, relocating the
wiki-token section, dropping the native-API schema guardrail, and the bulk
lark-slides- reference rename.
2026-06-08 16:37:09 +08:00
evandance
076f4d579f feat(minutes,vc): emit typed error envelopes across both domains (#1234)
Failures from the minutes and video-conference commands now surface as
structured, typed errors carrying a stable category and subtype — spanning
input validation, missing permissions, network and file-I/O failures, and
remote API errors — so callers can branch on the error kind instead of
parsing free-form text. Batch commands report partial failures explicitly,
emitting per-item results with a non-zero exit instead of masking them.
2026-06-08 16:20:43 +08:00
SunPeiYang996
0c2fd08d5a feat:remove docs v1 api (#1291)
Change-Id: I29d0af3e5325261f94949d3ab3f65051fb6bd52b
2026-06-08 16:07:52 +08:00
zhengzhijie
023a8786f0 docs(sheets): reword guidance to avoid eval-specific phrasing
Replace scoring-framework wording in the examples with plain functional
consequences (e.g. "not delivered", "goes stale when the source changes",
"breaks the original visual format"), so the references stay agent-facing.
2026-06-08 15:44:35 +08:00
zhengzhijie
3ecd75b53d docs(sheets): keep original column widths; align chart axis with requested metric
- range-operations: only widen new / overflowing columns; never recompute or
  shrink the widths of existing columns (any blanket resize, even by 1px,
  breaks the original visual format)
- chart: when the user asks for a share / percentage, the value axis should be
  a percentage (pie, or stack.percentage on bar/column) rather than raw counts
2026-06-08 14:38:00 +08:00
liangshuo-1
9d845442ce feat: add skills command to read embedded skill content (#1318) 2026-06-08 13:58:45 +08:00
Max Huang
c07a14aa2b feat(lark-shared): document relative-path-only file arguments (#1319) 2026-06-08 13:19:03 +08:00
xiongyuanwen-byted
5bf71428a4 refactor(sheets): reuse the drive export core in +workbook-export
Replace +workbook-export's parallel export-task implementation with the shared drive ExportParams/RunExport core (pinned to type=sheet). Drops ~90 lines of duplicated poll/download code; +workbook-export now inherits drive's ctx cancellation, resume-on-timeout, filename sanitize/overwrite, and the full set of export status labels. The output contract aligns with drive's (adds ready/downloaded/doc_type; saved_path preserved). Also normalize an empty drive --output-dir to "." so drive +export behavior is unchanged, and fix the sheets export e2e to call +workbook-export instead of a nonexistent +export.
2026-06-08 12:58:11 +08:00
ethan-zhx
8b39f7243c feat: add iconpark lookup for lark slides (#1123) 2026-06-08 12:28:04 +08:00
liujinkun2025
e40ef66912 docs(lark-wiki): optimize skill guidance and routing boundaries (#1275)
- Add explicit NOT boundaries to the description and a dedicated
  "不在本 skill 范围" section: file upload -> lark-drive, content
  editing -> lark-doc / lark-sheets / lark-base.
- Move the Shortcuts table up, right after 快速决策, so command entry
  points are discoverable first; keep the member-add flow and
  target-semantics sections after it.
- Add an inline reminder under the delete-space guidance that a wiki
  URL / name is not a space_id and must be resolved via
  `wiki spaces get_node` first.
- Remove the duplicated permission (scope) table and the redundant
  schema note so auth/permission guidance stays centralized in
  lark-shared.
- Bump the skill version to 1.0.1.
- Keep skill-template/domains/wiki.md in sync with the SKILL.md
  introduction narrative.

Change-Id: If2b4341f350191ee0a65bf3a2cab9afa2b76d931
2026-06-08 11:10:59 +08:00
xiongyuanwen-byted
e819e819fe feat(sheets): add +workbook-import wrapping the drive import core
Import a local xlsx/xls/csv as a new spreadsheet by delegating to the shared drive import flow with the target type pinned to sheet. Refactor drive +import to expose ImportParams / ValidateImport / PlanImportDryRun / RunImport (behavior unchanged, existing drive tests still cover it); sheets reuses them. Regenerate flag_defs_gen.go and sync the spec mirror.
2026-06-08 11:00:46 +08:00
xiongyuanwen-byted
2017e9dab8 docs(sheets): sync SKILL.md (drop "Feishu sheets only" caveat)
Mirror the upstream sheet-skill-spec change removing the "applies to Feishu sheets only" tail from the 14 sheet reference descriptions.
2026-06-07 22:45:53 +08:00
xiongyuanwen-byted
74a02e6f2d docs(sheets): sync SKILL.md (drop "not for local Excel" caveat)
Mirror the upstream sheet-skill-spec change removing the "not applicable to local Excel files" tail from the sheets skill and reference descriptions.
2026-06-07 22:39:58 +08:00
zhumiaoxin
e1bb9db552 feat(im): format feed group error handling (#1308) 2026-06-07 21:12:19 +08:00
xiongyuanwen-byted
02f4f73227 docs(sheets): surface typed-write path at the write-decision point
Quick-ref table (SKILL.md, the first decision point) had no +table-put and
gated typed writes on "DataFrame", so a model holding a Counter/list/dict
would fall back to +csv-put and silently lose number/date fidelity.

- split csv-put row to plain-text values (no numeric/date semantics)
- add +table-put row for typed writes into an existing sheet
- add +workbook-create --sheets row for create + typed write in one shot
- add judgment note: number/amount/date/percent/count -> +table-put
  (or +workbook-create --sheets when the workbook does not exist yet);
  plain text -> +csv-put
- reframe write-cells scenario row to lead with numeric semantics
- point new-table writes at +workbook-create --sheets (one shot) instead
  of the create-empty-then-table-put two-step

Synced from sheet-skill-spec canonical (generate:cli + sync:cli).
2026-06-07 00:30:13 +08:00
zhangheng023
7c50b3d9e3 feat: fetch official skills index (#1301)
lark-cli update currently discovers official skills by parsing unstable human-oriented `skills add --list` output. This prefers the stable official JSON index for skills discovery, while preserving the existing CLI-list fallback and full-install fallback for resilience.

Changes:

- Add official skills index JSON parsing in `internal/skillscheck/sync.go`

- Prefer JSON index discovery before existing CLI list parsing in `internal/skillscheck/sync.go`

- Add reason-chain details when both discovery layers fall back to `fallbackFullInstall`

- Add bounded HTTPS fetch for `https://open.feishu.cn/.well-known/skills/index.json` in `internal/selfupdate/updater.go`

- Add unit tests for parser behavior, discovery fallback order, and fallback detail reasons in `internal/skillscheck/sync_test.go`

Co-authored-by: zhaoyukun.yk <zhaoyukun.yk@bytedance.com>
2026-06-06 18:29:04 +08:00
evandance
5788a6c384 feat(im): return typed error envelopes across the im domain (#1230) 2026-06-06 17:07:57 +08:00
zhumiaoxin
bd07859c90 feat(im): cli support feed group (#1102)
Add IM feed group support documentation for lark-cli, making the raw im feed.groups.* APIs discoverable and easier for agents to use correctly.
2026-06-06 14:25:31 +08:00
evandance
8c3cba17b2 feat(task): emit typed error envelopes across the task domain (#1231)
Task commands now return structured, typed errors instead of the legacy
exit-code envelope: every failure carries a stable category, subtype, and
recovery hint, so callers can branch on the error class instead of parsing
messages. Exit codes derive from the error category — input validation exits 2,
a permission denial exits 3, other API errors exit 1.

Batch operations (adding tasks to a tasklist, creating a tasklist with tasks)
now report partial failure honestly: the per-item successes and failures stay
on stdout and the command exits non-zero instead of masking failures as a
success.
2026-06-05 22:30:45 +08:00
xiongyuanwen-byted
a2625d036d feat(sheets): implement table-put/table-get and sync skill specs
- Add lark_sheet_table_io.go with +table-put / +table-get and tests
- Refactor read-data; extend workbook; register new shortcuts
- Sync generated flag defs/schemas (go:embed) from sheet-skill-spec
- Sync skill references (write-cells numeric-column guidance, plus
  read-data / workbook / chart updates)
2026-06-05 20:03:33 +08:00
evandance
6367aaa0f5 feat(okr,whiteboard): emit typed error envelopes across both domains (#1236)
The okr and whiteboard commands now report every failure as a typed error
envelope. Invalid flags, malformed input, output-file conflicts, and API or
transport failures alike carry a stable category, subtype, the offending flag
or Lark error code, and a meaningful exit code — so scripts and agents can
branch on the error shape instead of scraping message strings.
2026-06-05 20:00:04 +08:00
zhengzhijie
d005694e0f docs(sheets): strengthen lark-sheets references for common editing pitfalls
Add targeted guidance to six lark-sheets references to reduce frequent
mistakes when editing spreadsheets through the CLI:

- write-cells: sanity-check units / dimension conversion / quantity factors
  before formula writes (formulas can run clean yet be off by a factor);
  keep derived output off original data columns to avoid clobbering source
- core-operations: prefer live formulas for derived values even when "live
  update" is not explicitly requested; scope rewrite/transform precisely so
  rows/columns that should stay unchanged are kept 1:1; treat header-stated
  format rules as checklist items; confirm the artifact file actually exists
  before finishing; write back bare values from local scripts
- visual-standards: apply border/header formatting on explicit request and
  identify the real header row; keep font size consistent with the source
- range-operations: keep total column width within A4 for printing
- read-data: dedup/compare long numbers via raw values, not csv formatted
  display (scientific notation collapses distinct numbers and causes false
  duplicates)
- chart: format date/number axes via source-cell number_format; place charts
  outside the data area so they do not cover existing data
2026-06-05 19:20:25 +08:00
qinxiaoyun
37b17f3d37 feat(events): add whiteboard event domain with per-board subscription (#1265)
Wire the board.whiteboard.updated_v1 EventKey into the consume pipeline so that lark-cli event consume automatically calls the per-whiteboard subscribe / unsubscribe OAPIs instead of requiring callers to manage server-side subscriptions out-of-band.

Change-Id: I94323807e8dc649d3296f6922311d2acaf92284e
2026-06-05 17:09:17 +08:00
evandance
be5527ca4e feat(im): add feed shortcut create, list, and remove shortcuts (#1273)
Adds feed shortcut management to the im domain: pin chats to the user's feed sidebar, list pinned entries, and unpin them. Three new shortcuts wrap the im/v2/feed_shortcuts OpenAPI routes, which currently expose CHAT-type entries only and accept user identity only.
2026-06-05 16:42:48 +08:00
fangshuyu-768
a75420f72c docs: add markdown domain template (#1293) 2026-06-05 15:48:01 +08:00
evandance
f3949f04c4 feat(calendar): emit typed error envelopes across the calendar domain (#1232)
Calendar commands now return structured, typed error envelopes for every
failure mode — input validation, internal faults, and API responses —
instead of legacy generic errors. Callers and AI agents get consistent
exit codes and a machine-readable shape (type / subtype / code / hint),
and can tell bad input, an internal fault, and an API rejection apart.
Validation errors are attributed to the offending flag.

Server-supplied error details (e.g. why an event time was rejected) are
surfaced on the typed error's hint via a shared classifier improvement
that benefits every domain. Multi-step operations (create-with-attendees
rollback, multi-field update) preserve the real failure's classification
and report which steps completed.

The whole calendar domain is now lint-locked against reintroducing legacy
error constructors.
2026-06-05 13:06:50 +08:00
caojie0621
62364fc320 fix(drive): use docs secure label read scope (#1281) 2026-06-05 12:48:22 +08:00
fangshuyu-768
2f4e2c3019 docs: improve lark-markdown skill guidance (#1279) 2026-06-05 12:34:56 +08:00
evandance
3990151122 feat(base): emit typed error envelopes across the base domain (#1248) 2026-06-05 11:40:00 +08:00
MaxHuang22
fa929f02d6 feat: clear recommend.allow scope auto-approve overrides (#1272)
The recommend.allow list in scope_overrides.json special-cased a set of
calendar/contact/mail scopes into the auto-approve set on top of the
platform recommendations in scope_priorities.json. Remove all entries so
no scopes are special-cased anymore; auto-approve now reflects only the
platform recommend=true scopes (plus the recommend.deny removals).

Update registry tests to use a recommend=true scope (sheets:spreadsheet:read)
as the auto-approve sample and assert the override allow set is empty.

Change-Id: Ic555a2c664e2dbd742f79712253f2918dfabf7ce
2026-06-05 11:37:46 +08:00
sang-neo03
a4a4bd6ee0 feat: check shortcut example commands against the live CLI tree (#1244)
Validate the example commands embedded in shortcut definitions (the
"Example: lark-cli ..." lines in each shortcut's Tips, shown in --help)
against the real command tree built by cmd.Build. Implemented entirely as
test-only code in cmd/ (package cmd_test), so it ships in no binary and is
not importable by product code; the truth source is cmd.Build, the same
tree the binary uses, so the check cannot drift. It runs in the standard
unit-test CI job (go test ./cmd/...); a renamed command or unaccepted flag
in an example fails that job.
2026-06-05 10:59:55 +08:00
zhoujunteng-max
ac116e7ca3 feat(drive): add drive preview and cover shortcuts and document quota details (#1259)
* feat: support get quota detail

* feat: add drive preview and cover shortcuts

- add `drive +preview` and `drive +cover` shortcuts
- wrap `preview_result` output with stable preview item fields
- support cover download via `preview_download` with validated preset mappings
- update lark-drive skill references for preview and cover usage

* fix(drive): classify cover 404 as failed precondition

* fix(drive): show preview download step in dry-run

* docs(drive): clarify quota details user-only usage

* fix(drive): soften cover 404 guidance
2026-06-04 21:08:59 +08:00
evandance
5e6a3eb857 feat(mail): return typed error envelopes across the mail domain (#1250)
* feat(mail): return typed error envelopes across the mail domain

Replace every produced error path in shortcuts/mail with typed errs.* envelopes, so consumers get stable category, subtype, param/params, hint, retryable, and log_id metadata for classification and recovery instead of free-form message text.

- Locally constructed mail errors move from output.Err* / output.Errorf / final fmt.Errorf / common legacy helpers to errs.* builders, with structured params on multi-flag validation and failed-precondition states kept non-retryable.

- API-call failures move from runtime.CallAPI / DoAPIJSON legacy boundaries to runtime.CallAPITyped or runtime.ClassifyAPIResponse, and mail-specific enrichers read errs.ProblemOf so typed code, subtype, hint, and log_id metadata are preserved.

- Batch draft-send partial failures now use runtime.OutPartialFailure so successful and failed draft sends stay in stdout while the command exits through a typed multi-status signal.

- Add mail-domain typed helpers, mail API code metadata, and guard wiring to keep shortcuts/mail from reintroducing legacy envelopes or legacy API calls.

- Keep genuine intermediate fmt.Errorf wraps in parser/builder layers annotated with nolint comments; command-facing paths wrap them into typed validation, API, network, or internal errors.

* fix(mail): report aborted draft-send batches as a single failure result

When an account-level failure interrupts a batch send after some drafts
already went out, the command previously produced two machine-readable
failure results: the partial-failure ledger on stdout and a second error
envelope on stderr. Consumers could not tell which one to recover from.

The batch ledger is now the only failure result for that case: it gains
aborted and abort_error fields carrying the typed cause, so callers can
see which drafts were sent, which failed, why the batch stopped, and how
to recover — all from stdout. A --stop-on-error stop keeps these fields
unset because stopping early there is the caller's own choice.
2026-06-04 21:02:20 +08:00
liangshuo-1
493b3cce95 chore(release): v1.0.48 (#1270) 2026-06-04 20:49:54 +08:00
zhangheng023
abc0553f21 fix: use json skills list during update (#1251)
* fix: use json skills list during update

* fix: preserve versioned skill names
2026-06-04 19:19:26 +08:00
zhengzhijiej-tech
3149c77134 Merge pull request #1264 from zhengzhijiej-tech/feat/sheet-gridline
feat(sheets): add gridline show/hide shortcuts
2026-06-04 19:12:41 +08:00
xukuncx
a82a486508 feat(mail): preserve mailbox context in +triage output for public mailboxes (#1238)
When triaging a public/shared mailbox, downstream AI consumers (e.g.
mail +message) need the mailbox_id to construct correct API paths.
Previously the triage output only included message_id, causing
/user_mailboxes/me/messages/{id} lookups that fail for public mailboxes.

- Add mailbox_id field to every normalized message in structured output
- Add mailbox_id to top-level JSON/data output envelope
- Add mailbox_id to table rows when mailbox is not "me"
- Update stderr next-step tip to include --mailbox for non-me mailboxes
- Update next-page hint to include --mailbox for non-me mailboxes
- Add unit tests covering list, search, and public mailbox paths
- Update triage skill docs to show mailbox_id in output examples
2026-06-04 18:27:13 +08:00
zhengzhijie
6e067f2180 feat(sheets): add +sheet-show-gridline / +sheet-hide-gridline shortcuts 2026-06-04 17:00:07 +08:00
YH-1600
c000dc3a44 docs: refine lark-drive knowledge organize workflow (#1253)
Change-Id: I49b4f398d60c5bb073d6c8d61987bd16f1a29c4e
2026-06-04 15:31:46 +08:00
zhicong666-bytedance
256df8c0fb docs(vc-agent): require explicit leave request (#1260) 2026-06-04 14:33:57 +08:00
Huangwenbo-wb
7a0dbe057b docs(slides): add whiteboard element documentation and improve slide guidance (#1029)
* feat(slides): add whiteboard element support and reference documentation

- Add lark-slides-whiteboard.md covering SVG and Mermaid modes, routing
  rules, layout examples, known issues, and self-check checklist
- Register <whiteboard> in slides_xml_schema_definition.xml; remove it
  from the undefined element type list
- Update SKILL.md quick-reference table and按需再读 section to point to
  the new whiteboard reference
- Update xml-schema-quick-ref.md with <whiteboard> syntax examples
- Update slide create/get/replace references to include whiteboard as a
  valid <data> child element
- Tighten fallback_if_missing descriptions in planning-layer.md and
  asset-planning.md: replace "shapes" wording with neutral intent
  language and add "whiteboard diagrams" to the fallback tool lists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(slides): refine whiteboard reference doc structure and content

- Restructure doc: common attributes and prerequisites moved to top
- Move design quality rules under SVG mode section
- Add z-order inline note to full-screen layout example
- Replace JS coordinate script with Python, broaden scope to decorative elements
- Delete redundant Mermaid examples (keep one complete whiteboard+flowchart)
- Add prerequisite link and references section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(slides): clarify chart vs whiteboard selection and fix doc gaps

- lark-slides-whiteboard: add chart vs whiteboard decision table at top;
  fix intro and SVG use-case list to remove bar/line (those belong to <chart>)
- SKILL.md: split whiteboard quick-ref row into chart row + whiteboard row;
  fix sidebar link label to match actual scope
- asset-planning: correct chart asset type — remove funnel/scatter (unsupported
  by <chart> XSD) and note they fall back to <whiteboard> SVG
- visual-planning: add one-line whiteboard preference hint to
  architecture-diagram and process-flow layout types
- validation-checklist: add Whiteboard Elements section noting slide.get
  does not return SVG/Mermaid content; content correctness requires manual
  visual sign-off

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(slides): add SVG decorative visibility principles

Add two design rules to SVG quality requirements: check background
luminance before writing SVG (dark bg requires higher contrast), and
use non-linear brightness jumps (e.g. 0.10→0.40→0.70→1.0) instead of
linear opacity stacking (0.04→0.08→0.12) which produces near-identical
layers on dark backgrounds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(slides): add custom icon use case to whiteboard SVG

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(slides): fix whiteboard SVG rendering rules

- content area is determined by child element bounding box union, not svg width/height/viewBox/xmlns
- viewBox only purpose: provide reference for percentage-based attribute values; omit when using absolute coords
- remove redundant attributes from all svg examples, use bare <svg> tags
- drop positive/negative coordinate guidance; rendering rule simplified to bounding-box auto-scale
2026-06-04 11:58:09 +08:00
suhui928
8ce38793a7 feat: add contact skill domain guidance (#1144)
* feat(lark-contact): route user_profiles batch_query in skill

- Add user_profiles batch_query row to the routing table.
- Add a worked example next to the search-user one, with `lark-cli
  schema` first (best practice: don't guess `--data` / `--params`).
- Trim description: drop the duplicated trigger clause, add
  personal_status / signature to the capability list so routing picks
  this skill up for those queries.
2026-06-03 22:32:27 +08:00
liangshuo-1
54e646edc9 chore(release): v1.0.47 (#1255) 2026-06-03 22:26:44 +08:00
xiongyuanwen-byted
b07a6003f9 feat(sheets): spec-driven shortcut refactor with backward-compatible package (#1220)
* refactor(sheets): rebuild lark-sheets on sheet-skill-spec canonical + One-OpenAPI

Restart lark-sheets as a spec-driven downstream. Skill content (SKILL.md
and 16 references covering 13 operations skills + 3 workflow skills,
including the standalone filter-view skill) is mirrored from the
sheet-skill-spec canonical-spec; do not hand-edit, change upstream and
rerun npm run sync:consumers.

Drop the 11 legacy shortcut sources (spreadsheet / sheet management,
cell ops, dropdown, filter-view, float image, etc.) and 10 associated
tests. Wire up the new sheet_ai/v2 One-OpenAPI single entry that
dispatches by tool_name with JSON-string input/output, and land the
first canonical shortcut +workbook-info as a template that exercises
the public token XOR pair, Risk tiering, and zero-side-effect DryRun.

sheet_ai_api.go provides callTool / invokeToolDryRun and bypasses
runtime.CallAPI's silent swallowing of non-envelope responses so
gateway and business errors from the new endpoint surface precisely.

The remaining 55 shortcuts will be designed and landed separately,
canonical skill by canonical skill.

* feat(sheets): implement lark_sheet_workbook shortcuts (B1)

Land the 8 modify_workbook_structure shortcuts that round out the
lark_sheet_workbook canonical skill alongside the existing +workbook-info:
+sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy
/ +sheet-hide / +sheet-unhide / +sheet-set-tab-color. All eight call
modify_workbook_structure via the One-OpenAPI invoke_write endpoint,
dispatched by the `operation` enum.

Helpers in helpers.go grow publicSheetFlags() / resolveSheetSelector() /
sheetSelectorForToolInput() / sheetSelectorPlaceholder() so future
sheet-level shortcuts share the public --sheet-id / --sheet-name XOR
treatment. +sheet-create intentionally drops the sheet selector pair since
create has no existing-sheet anchor (matches the spec fix in
tool-shortcut-map.json).

+sheet-delete is the first high-risk-write shortcut in the canonical
package; the framework requires --yes (exit code 10 otherwise).

+sheet-move's tool requires source_index in addition to target_index. The
CLI accepts an optional --source-index override and falls back to a
single get_workbook_structure read to derive it (and to resolve sheet_id
from --sheet-name). DryRun stays network-free by rendering <resolve>
placeholders for any field that would need that read.

* feat(sheets): implement lark_sheet_sheet_structure shortcuts (B2)

Add 8 shortcuts under the lark_sheet_sheet_structure canonical skill:
+sheet-info (get_sheet_structure) plus +dim-insert / +dim-delete /
+dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup
(modify_sheet_structure, dispatched by operation enum).

Two reusable conversion helpers cover the impedance mismatch between
the CLI surface and the tool input:

  - dimRange / dimPosition translate the CLI's 0-based exclusive-end
    range into the tool's 1-based A1 notation. row 5..8 becomes
    position "6" + count 3 (insert) or range "6:8" (range ops); column
    26..29 becomes "AA:AC".
  - infoTypeFromInclude maps the fine-grained --include vocabulary
    (row_heights / col_widths / merges / hidden_rows / hidden_cols /
    groups / frozen) to the coarse info_type enum the tool accepts;
    mixed categories collapse to "all".

+dim-delete is high-risk-write (irreversible row/column removal).
+dim-freeze --count 0 auto-dispatches to operation=unfreeze. +dim-group
accepts --depth for forward-compat with a future server-side nested
group endpoint but does not pass it through today.

* feat(sheets): implement read_data / search_replace / write_cells shortcuts (B3)

Land 11 shortcuts across three canonical skills:

  - lark_sheet_read_data (3): +cells-get / +csv-get / +dropdown-get
  - lark_sheet_search_replace (2): +cells-search / +cells-replace
  - lark_sheet_write_cells (6): +cells-set / +cells-set-style / +csv-put
    / +dropdown-set / +dropdown-update / +dropdown-delete

+dropdown-get reads the data_validation field via get_cell_ranges with
the range carrying its own sheet prefix (no --sheet-id needed). The
fine-grained --include vocabulary (value / formula / style / comment /
data_validation) maps to the tool's coarse include_styles bool plus
value_render_option enum. +csv-get's --include-row-prefix=false strips
the [row=N] prefix client-side because the tool only emits the
annotated form.

+cells-search / +cells-replace flatten the tool's options sub-object
into four independent flags (--match-case / --match-entire-cell /
--regex / --include-formulas) per the flat-flag rule, then repack them on the way
in.

+cells-set takes a raw --data JSON body whose `cells` array must match
the --range dimensions. +cells-set-style fans a single --style block
out to every cell in the range via a new fillCellsMatrix helper; the
range parser (rangeDimensions / splitCellRef / letterToColumnIndex)
only accepts rectangular A1:B2 forms — whole-column / whole-row need
sheet totals and are deferred.

+dropdown-set fans the validation block out to one range; +dropdown-
update / +dropdown-delete iterate sheet-prefixed --ranges and call
set_cell_range sequentially (partial failure leaves earlier ranges
already mutated; the Tip calls this out). +dropdown-delete is
high-risk-write and requires --yes.

+cells-set-image stays deferred to the cli-only batch (needs the
shared local-file upload helper alongside +workbook-create / +dim-move
/ +workbook-export).

* refactor(sheets): move +dropdown-update / +dropdown-delete to lark_sheet_batch_update

Follow-up to B3 after the spec re-mapped these two shortcuts to the
batch_update tool (atomic multi-range CRUD) instead of fan-out via
set_cell_range. Drop their Go implementations + helper validateDropdownRanges
+ splitSheetPrefixedRange from lark_sheet_write_cells.go and remove the
registrations from Shortcuts(); the shortcuts will reappear under
lark_sheet_batch_update during B7.

Also pull in the re-rendered reference docs:
  - skills/lark-sheets/references/lark-sheets-write-cells.md
  - skills/lark-sheets/references/lark-sheets-batch-update.md

* feat(sheets): implement lark_sheet_range_operations shortcuts (B4)

Land 8 shortcuts across four canonical tools:

  - clear_cell_range  → +cells-clear      (high-risk-write)
  - merge_cells       → +cells-merge / +cells-unmerge
  - resize_range      → +dim-resize
  - transform_range   → +range-move / +range-copy / +range-fill / +range-sort

Three CLI↔tool vocabulary bridges live in this file:

  - +cells-clear: --scope content normalizes to the tool's clear_type
    "contents" (singular/plural spec mismatch is absorbed in the CLI).
  - +dim-resize: --size <px> wraps as resize_{height,width}:{value:N};
    --reset wraps as {reset:true}. The two flags are mutually exclusive
    and at least one is required.
  - +range-fill: CLI's five-valued --series-type collapses to the tool's
    binary fill_type — `copy` → "copyCells", anything else → "fillSeries"
    (the actual series progression is inferred server-side from the
    seed cells in --source-range).
  - +range-copy: --paste-type {values, formulas, formats} maps to the
    tool's {value_only, formula_only, format_only}; "all" omits the
    field entirely so the server applies its default.

+cells-clear is the second high-risk-write shortcut in the package;
the framework enforces --yes with exit code 10 as usual.

* feat(sheets): implement object-list shortcuts (B5)

Land 7 read shortcuts, one per object skill — chart / pivot table /
conditional format / filter / filter view / sparkline / float image. All
share the same shape (public sheet selector + optional <obj>-id filter)
so they're declared via newObjectListShortcut + an objectListSpec.

Notes:
  - +cond-format-list exposes --rule-id, which is renamed to
    conditional_format_id on the wire (the tool's full field name).
  - +sparkline-list exposes --group-id (the higher-level handle); the
    tool also accepts sparkline_id, intentionally not surfaced.
  - +filter-list takes no id filter — at most one sheet-level filter
    per sheet, so the listing is already unique.
  - +filter-view-list is `cli_status: cli-only` but get_filter_view_objects
    is in mcp-tools.json and dispatches through the same One-OpenAPI
    endpoint; no special path required.

* feat(sheets): implement object CRUD shortcuts (B6)

Land 21 shortcuts — three (create / update / delete) per object skill —
backed by the manage_<obj>_object tools dispatched on the operation
enum. Five standard objects (chart / cond-format / sparkline /
float-image / filter-view) share an objectCRUDSpec factory; pivot and
filter are special-cased.

Shared wire contract:
  excel_id + sheet_id|sheet_name + operation + [<obj>_id] + [properties]
CLI --data is passed through as the tool's `properties` field as-is, so
callers shape it per each object's spec doc.

Special cases:
  - pivot adds optional --target-sheet-id / --target-position on create
    (siblings of properties, not inside it).
  - cond-format exposes --rule-id (short CLI name) wired to the tool's
    conditional_format_id on the wire.
  - sparkline uses --group-id (higher-level object handle) instead of
    sparkline_id.
  - filter has no separate id flag — at most one filter per sheet, so
    filter_id is implicit. +filter-create promotes --range to a first-
    class flag (instead of burying it inside --data).
  - filter-view CRUD are `cli_status: cli-only` but
    manage_filter_view_object is in mcp-tools.json, so they go through
    callTool / One-OpenAPI alongside everything else.

All delete shortcuts are high-risk-write and require --yes.

* feat(sheets): implement lark_sheet_batch_update shortcuts (B7)

Land 4 shortcuts that all funnel through the batch_update tool's atomic
operations array:

  - +batch-update            raw passthrough; --data carries the full
                             { operations: [{tool, params}, ...] } payload
                             plus optional continue_on_error. high-risk-write
                             since the caller may stuff anything inside.

  - +cells-batch-set-style   --data is [{ranges, style}, ...]; CLI flattens
                             each (entry × range) pair into a set_cell_range
                             op with a fan-out cells matrix carrying
                             cell_styles + border_styles.

  - +dropdown-update         --ranges + --options (+ --colors / --multiple /
                             --highlight) — installs/replaces one dropdown
                             across many ranges, each becoming a separate
                             set_cell_range op with data_validation in cells.

  - +dropdown-delete         --ranges — clears data_validation across many
                             ranges (high-risk-write).

Default is strict transaction: if any sub-tool fails the whole batch rolls
back. +batch-update exposes --continue-on-error to flip the policy; the
three fan-out shortcuts leave it strict (they're meant to be all-or-nothing).

Reinstates validateDropdownRanges + splitSheetPrefixedRange that were
removed during B3 → B7 relocation.

* feat(sheets): implement cli-only shortcuts (B8) — 70/70 complete

Land the four cli-only shortcuts that can't route through the One-OpenAPI
dispatcher (their backing capabilities aren't in mcp-tools.json):

  - +workbook-create   POST /open-apis/sheets/v3/spreadsheets
                       + optional set_cell_range follow-up that zips
                       --headers and --data into the first sheet starting
                       at A1.

  - +workbook-export   POST /open-apis/drive/v1/export_tasks (type=sheet)
                       → poll /export_tasks/:ticket up to ~30s
                       → optional GET /export_tasks/file/:file_token/download.
                       CSV mode requires --sheet-id (single sheet export).

  - +dim-move          POST /open-apis/sheets/v2/spreadsheets/:token
                                              /dimension_range
                       CLI is 0-indexed inclusive (--start / --end); the v2
                       endpoint expects half-open [startIndex, endIndex)
                       so the body uses endIndex = --end + 1. --sheet-name
                       is resolved client-side to sheet_id via
                       lookupSheetIndex when needed.

  - +cells-set-image   common.UploadDriveMediaAll
                       (parent_type=sheet_image, parent_node=token)
                       then callTool set_cell_range with cells carrying
                       rich_text: [{type:"embed-image", attachment_token, attachment_name}].
                       --range must be exactly one cell.

All four use runtime.CallAPI / DoAPI directly; only +cells-set-image
combines a legacy upload with the new One-OpenAPI for the second step
(set_cell_range is in mcp-tools.json so callTool is the right path).

This closes the migration: 70 shortcuts × 17 canonical skills × matching
the sheet-skill-spec v0.5.0 tool-shortcut-map.

* test(sheets): cover all 70 shortcuts with dry-run + execute-path tests

Twelve _test.go files alongside the implementation, mirroring the legacy
package's coverage style:

  - testhelpers_test.go     shared rig: TestFactory + Mount + dry-run
                            capture + JSON-input decode + envelope helpers.
  - lark_sheet_*_test.go    one test file per implementation file (9
                            files), table-driven dry-run cases per shortcut
                            plus targeted validation guards.
  - execute_paths_test.go   end-to-end execute paths via httpmock stubs.
                            Covers callTool unwrap, JSON-string output
                            decoding, two-step lookup (+sheet-move),
                            batch_update fan-out, dropdown atomic writes,
                            and the legacy OAPI shortcuts (+workbook-create,
                            +dim-move) including CLI inclusive → API
                            half-open index conversion.

Test coverage on the sheets package is 60.5 % of statements with -race
clean, meeting the dev manual's ≥ 60 % patch-coverage gate.

* refactor(sheets): inline cli-only shortcuts into their canonical skill files

Two naming cleanups:

  - lark_sheet_cli_only.go is gone. The four shortcuts it grouped
    (+workbook-create / +workbook-export / +dim-move / +cells-set-image)
    were bundled by their implementation pattern (legacy OAPI direct
    calls) rather than by canonical skill. The whole sheets package IS
    the CLI implementation, so "cli only" wasn't a meaningful grouping
    at the Go layer. Each shortcut now lives next to its skill peers:

      +workbook-create / +workbook-export → lark_sheet_workbook.go
      +dim-move                           → lark_sheet_sheet_structure.go
      +cells-set-image                    → lark_sheet_write_cells.go

    Per-skill shortcut counts now match tool-shortcut-map.json exactly
    (workbook: 11, sheet_structure: 9, write_cells: 5). Helpers
    (buildInitialFillInput, pollExportTask, downloadExportFile,
    dimMoveBody) move with their shortcuts; nothing else in the package
    referenced them.

  - testhelpers_test.go → helpers_test.go. The _test.go suffix already
    conveys "test"; the leading "test" was redundant. Matches the
    helpers.go naming convention.

Behavior unchanged. go test -race -cover stays at 60.5 %.

* refactor(sheets): sync shortcut flags with sheet-skill-spec v0.5.0

Upstream hoisted a batch of high-frequency scalar fields out of --data
into independent flags and renamed several composite-JSON flags to
match their semantic content. CLI catches up.

Renames (drop-in, same payload semantics):

  - +cells-replace      --replace   → --replacement
  - +cells-set          --data      → --cells
  - +workbook-create    --data      → --values
  - +batch-update       --data      → --operations (now a bare array;
                                       still accepts the envelope form for
                                       back-compat with continue_on_error)

Flat-flag hoists out of --style / --data:

  - +cells-set-style / +cells-batch-set-style
      --style JSON drops; replaced by 11 flat style flags
      (--background-color / --font-color / --font-size / --font-style /
      --font-weight / --font-line / --horizontal-alignment /
      --vertical-alignment / --word-wrap / --number-format) plus
      --border-styles for the one field that's still nested. Both
      shortcuts share styleFlatFlags() + buildCellStyleFromFlags().
  - +cells-batch-set-style also drops the [{ranges, style}] array shape
      in favor of one --ranges + the same flat style flags applied to
      all of them.

Object CRUD --data → --properties everywhere (chart / pivot / cond-format
/ filter / filter-view / sparkline / float-image). Per-skill scalar
hoists merged into properties via an enhanceCreate/UpdateInput callback:

  - +pivot-create        adds --source (required), --range
                          (and continues to expose --target-sheet-id /
                          --target-position at top level)
  - +cond-format-{create,update}
                          adds --rule-type (enum) + --ranges (JSON array);
                          merged into properties.rule.type and
                          properties.ranges respectively
  - +filter-view-{create,update}
                          adds --view-name and --range; both override
                          their properties.* counterparts
  - +filter-update        adds first-class --range (was buried in --data)

Float-image is fully hoisted — no --properties flag at all. Ten flat
flags (--image-name / --image-token | --image-uri / --position-row /
--position-col / --size-width / --size-height / --offset-row /
--offset-col / --z-index) compose the properties block. Implemented as
its own factory (newFloatImageWriteShortcut) since it diverges from the
shared CRUD spec.

Tests track every flag renamed and add explicit cases for the new flag
combos. go test -race -cover stays at 60.3 %.

* refactor(sheets): align batch_update + cells-set with synced reference docs

Sync to upstream reference doc updates for 9 skills:

- batch_update sub-ops: rewrite wire fields tool/params -> tool_name/input
  in CellsBatchSetStyle and DropdownUpdate/Delete fan-out (the actual
  server contract per Schemas section); update --operations flag desc
  and tests.
- +cells-set --cells: accept bare 2D matrix [[{cell},...],...] instead
  of envelope {"cells":[[...]]}; spec example shows bare-array form.
- sparkline createDataDesc enum: win_loss -> winLoss (camelCase).

All other doc changes (float-image flat flags, cond-format
--rule-type/--ranges, pivot create-only --source/--range, filter /
filter-view extra flags, chart --properties) were already aligned in
commit ce33315.

* fix(sheets): repair cells-set-image rich_text embed payload

The server rejected set_cell_range calls from +cells-set-image with three
distinct errors: missing "text" property, missing image_width/image_height,
and unknown attachment_token field. Realign the rich_text element to the
embed-image schema (text/image_token/image_width/image_height) and decode
PNG/JPEG/GIF dimensions from the local file before the write.

* refactor(sheets)!: split +dim-resize into +rows-resize and +cols-resize

Sync to upstream spec change that splits the legacy +dim-resize shortcut
into +rows-resize and +cols-resize. Reasoning is that row vs column
resize has divergent semantics (only rows support auto-fit) and the
shared --dimension flag was hiding that.

Behavior changes (BREAKING):
- +dim-resize is removed; use +rows-resize or +cols-resize.
- --dimension and --reset flags are gone.
- --type enum replaces --size/--reset:
    pixel    (requires --size)
    standard (reset to sheet default; no --size)
    auto     (auto-fit row height; +rows-resize only)
- --end is now inclusive (was exclusive). Old "--start 0 --end 5"
  (5 rows) becomes "--start 0 --end 4".
- Wire payload for resize_height / resize_width changes from
  {value: N} | {reset: true} to {type: "pixel", value: N} |
  {type: "standard"} | {type: "auto"}.

Tests cover both shortcuts across pixel / standard / auto and the
new guard surface (--type pixel needs --size; standard/auto reject
--size; +cols-resize rejects --type auto; --end < --start).

Also pulls in synced reference docs for 5 skills (batch-update,
core-operations, range-operations, sheet-structure, visual-standards)
that update prose mentions of +dim-resize.

* feat(sheets): add --print-schema runtime introspection for composite JSON flags

Composite JSON flags (--cells / --properties / --operations /
--border-styles / --sort-keys / --options) carry non-trivial structured
payloads. Reference docs cover top-level fields but agents writing
those flags often need the full JSON Schema to build a valid payload.

This adds a system-level introspection contract so any shortcut whose
flags are tracked upstream can serve its schemas locally:

  lark-cli sheets <shortcut> --print-schema --flag-name <name>
  lark-cli sheets <shortcut> --print-schema                  # list flags

The schema data is embedded at build time from a synced artifact
(shortcuts/sheets/data/flag-schemas.json). Upstream is the source of
truth — never hand-edit the JSON; update the source Base table and
rerun the sheet-skill-spec sync.

Framework changes (shortcuts/common):

- types.go: Shortcut gains an opt-in PrintFlagSchema hook
  (flagName -> bytes/error). When non-nil the framework auto-injects
  --print-schema / --flag-name and short-circuits Validate/Execute.
- runner.go: register the two system flags when PrintFlagSchema is
  set; intercept in runShortcut before identity/scope/config so
  pure-local lookups don't trigger auth or network. Install a
  PreRunE that relaxes cobra's required-flag gate when
  --print-schema is set, since asking for a schema shouldn't need
  unrelated required flags.

Sheets surface (shortcuts/sheets):

- flag_schema.go (new): go:embed data/flag-schemas.json; expose
  printFlagSchemaFor(command) closure. When flagName is empty it
  emits a JSON listing of introspectable flags for discovery;
  otherwise it returns the schema subtree as pretty JSON.
- flag_schema_test.go (new): cover embed parsing, listing /
  by-name lookup, unknown-flag error path, registration via
  Shortcuts(), and the full system-flag short-circuit through
  cobra (required flags relaxed, schema printed on stdout).
- shortcuts.go: Shortcuts() now wraps shortcutList() and attaches
  PrintFlagSchema to every command present in flag-schemas.json,
  so shortcuts opt in by being listed upstream — no per-shortcut
  boilerplate.
- data/flag-schemas.json (new, synced from sheet-skill-spec):
  19 entries, schema_version "2". Generated upstream from the Lark
  Base source-of-truth (see sheet-skill-spec
  scripts/fetch_cli_flag_schema_map.mjs); ships only per-flag
  subtrees (not the full mcp-tools.json) to keep tool internals
  out of the open-source repo.

Skill docs (skills/lark-sheets):

- SKILL.md: system-flag table gains --print-schema / --flag-name and
  an "Agent 使用提示" note steering agents to prefer --print-schema
  over guessing JSON shape from the cheatsheet.
- references/*.md: regenerated by upstream sync (Schemas-section
  boilerplate updated, plus accumulated upstream prose refinements).

* docs(sheets): remove sandbox references and normalize tool names to CLI shortcuts

Replace export_sheet_to_sandbox / import_sandbox_to_sheet / doubao_code_interpreter
with local-script + batch csv-get/csv-put workflows; unify legacy MCP tool names
(set_cell_range, get_range_as_csv, etc.) to CLI shortcut format (+cells-set, +csv-get).

* feat(sheets): add flag-descriptions.en.json and wire applyFlagDescs into Shortcuts()

Embed data/flag-descriptions.en.json (synced from upstream spec) and
apply it at shortcut assembly time so every Flag.Desc is sourced from
the canonical JSON rather than hardcoded Go strings. Existing hardcoded
Desc values serve as fallback for flags not yet in the JSON.

Also sync reference doc updates from upstream.

* feat(shortcuts): support int64 and float64 flag types

Flag.Type previously could not express non-integer numbers. Add int64
and float64 cases to flag registration plus Int64/Float64 runtime
accessors.

* refactor(sheets): build shortcut flags generically from flag-defs.json

Replace flag-descriptions.en.json with the richer flag-defs.json (full
flag definitions: type / default / enum / input / hidden / required /
kind) synced from sheet-skill-spec. Add flagsFor(command) to materialize
each shortcut's []common.Flag straight from the JSON, skipping
system-kind flags the framework injects.

Migrate every sheets shortcut (including the CRUD/list/dim/merge/
visibility factories) to Flags: flagsFor("+command"), dropping all
hand-written flag literals plus the now-dead publicTokenFlags /
publicSheetFlags / styleFlatFlags helpers and enum vars. A coverage test
locks the Go-flags-match-JSON contract.

Align Go with the new spec where they diverged: +cells-get --ranges →
--range, font-size int → float64, +filter-view-create --range now
required, +sheet-create row/col-count defaults 200/20.

* docs(sheets): sync +batch-update CLI override schema (shortcut/input form)

Pulled from sheet-skill-spec:
- skills/lark-sheets/references/lark-sheets-batch-update.md: --operations
  now documents the {shortcut, input} form; tool_name references gone
- shortcuts/sheets/data/flag-schemas.json: --operations resolves to the
  CLI-side array<{shortcut(enum), input}> schema, sourced from spec's
  canonical-spec/tool-schemas/cli-schemas.json (cli: prefix). +dropdown
  --options also drilled one level deeper

NOTE: the binary still raw-passes --operations to MCP batch_update which
expects {tool_name, input}. A follow-up will add a shortcut→tool_name
translation layer (with per-shortcut operation field) before the docs
become actionable.

* feat(sheets): translate +batch-update sub-ops {shortcut,input} → MCP shape

Users now hand +batch-update --operations a CLI-shape array
([{shortcut, input}, ...]) and the binary translates each sub-op to the
underlying MCP batch_update shape ({tool_name, input(+operation)}) via
a new dispatch table in shortcuts/sheets/batch_op_dispatch.go.

Dispatch table covers 50 batchable write shortcuts. Excluded by design:
- all read ops
- fan-out wrappers (+batch-update self, +cells-batch-set-style,
  +dropdown-update, +dropdown-delete) — nesting these = nested batch
- +dim-move — single shortcut uses legacy v2 /dimension_range endpoint,
  not MCP, can't be batched
- +cells-set-image — multi-step image upload, not atomic-batch friendly
- +workbook-create — new workbook, not batch-on-existing semantics

Translator also rejects sub-ops that hand-fill input.operation (implied
by shortcut name) or input.excel_id / spreadsheet_token / url (set
once at +batch-update top level).

+dim-freeze always injects operation=freeze; the count==0 unfreeze
path of the single shortcut is intentionally not supported in batch —
callers should use the single shortcut for unfreeze.

Tests cover: end-to-end translation, --continue-on-error propagation,
13 rejection cases (banned shortcuts, malformed shapes, reserved keys).

Sync'd from sheet-skill-spec: skills/lark-sheets/references/
lark-sheets-batch-update.md + shortcuts/sheets/data/flag-schemas.json
pick up the corrected enum (+cells-set-style / +dropdown-set added,
+dim-move removed).

* fix(sheets): make +batch-update sub-ops reuse standalone flag→body translators

Sub-ops previously near-passed-through their input, so any shortcut whose
standalone translator renames fields broke inside a batch: +range-copy lost
range/destination_range (transform_range errored "range missing") and
+rows-resize lost range/resize_height ("No resize operation specified").

Introduce a flagView interface (satisfied by *common.RuntimeContext) and a
map-backed mapFlagView, then route every batchable sub-op through the SAME
*Input builder the standalone shortcut uses. mapFlagView seeds flag-defs.json
defaults for value reads while keeping Changed() user-driven, so a sub-op body
is byte-identical to the standalone body — locked by a batch-vs-standalone
contract test over all ~40 batchable shortcuts.

Also fix single-row/column resize: start==end now formats as "23:23" / "C:C"
(resize_range rejects a bare "23"); dimRangeFull keeps both sides while
dimRange's collapse stays for modify_sheet_structure consumers.

* fix(sheets): align +cells-get/+csv-get range flags with synced spec

sheet-skill-spec now declares +cells-get --range as a single string
(was string_array) and +csv-get --range as required. Match the
flag→body translators:

- +cells-get wraps the single --range into the tool's `ranges` array
  and validates with Str() instead of StrArray(), which silently
  returned nil against the now-String flag and broke the command.
- +csv-get gains a trim-based required-range guard.

Update read-data dry-run tests to single-range form and add a guard
test for the empty --range path.

* fix(sheets): push +batch-update sub-op validation down into xxxInput builders

Sub-ops that omit --sheet-id (or any other required flag) used to slip
past CLI validation — Validate ran only against the standalone shortcut
path, and batchOpDispatch's translators built bodies from whatever
flagView returned, so a structurally broken sub-op surfaced as an opaque
server "sheet undefined not found" after a network round-trip.

Push each batchable shortcut's check trio down into its xxxInput builder:

  1. resolveSpreadsheetToken — stays in Validate (batch already does it
     once at the top level; sub-ops don't repeat).
  2. requireSheetSelector(sheetID, sheetName) — new helper; flagView-
     agnostic XOR + control-char check, called at the top of every
     xxxInput.
  3. shortcut-specific required / range / enum checks (--dimension,
     --range, --start <= --end, --type pixel needs --size,
     --float-image-id, image-token XOR image-uri, ...) — moved out of
     Validate into the builder body.

All ~30 batchable xxxInput builders now return (map, error). Standalone
Validate shrinks to validateViaInput(xxxInput); DryRun / Execute
propagate the error. batch_op_dispatch entries drop the noErrTranslate
wrapper and pass the builder directly — its error bubbles up wrapped
with "operations[N] (+shortcut):" context.

Tests:

- TestBatchOp_ErrorEquivalence (7 cases): XOR / logical-constraint
  errors fire identically from standalone and batch sub-op paths.
- TestBatchOp_RejectsBadSubOpInput (8 cases): cobra-required flags that
  standalone catches via MarkFlagRequired now also get rejected CLI-side
  on the batch path (where cobra is not in the loop).
- TestBatchOp_BodyMatchesStandalone (~40 cases) and
  TestBatchOp_DispatchCoversReportedBugs continue to pass — bodies stay
  byte-identical.
- BOE smoke (spreadsheet ICFwstkUGheyfptGWS2bB7RgcDf, sheet 51991c):
  +batch-update with a sub-op missing --sheet-id now returns
  "operations[0] (+dim-insert): specify at least one of --sheet-id or
  --sheet-name" before any network call.

sheetMoveBatchInput (xiongyuanwen's batch-only explicit-source-index
requirement) is preserved — it's an orthogonal batch-specific constraint
not affected by this push-down.

* fix(sheets): align +cond-format / +filter with server schema (#4 + #5)

Two latent bugs in the object_crud translator surfaced during BOE smoke
testing of +batch-update. Both are schema-alignment fixes against
manage_conditional_format_object / manage_filter_object as declared in
sheet-skill-spec/canonical-spec/tool-schemas/mcp-tools.json.

#4 +cond-format: rule_type path + enum vocabulary
---------------------------------------------------
condFormatEnhance used to write the user's --rule-type value into
`properties.rule.type` (nested under a `rule` object). The server
schema actually puts it at flat `properties.rule_type` and silently
drops the nested form — so every conditional-format create/update
secretly built the wrong document.

Worse, the CLI enum exposed via flag-defs.json was its own invented
vocabulary (cellValue / formula / duplicate / unique / topBottom /
aboveBelowAverage / dataBar / colorScale / iconSet / textContains /
dateOccurring / blankCell / errorCell) — none of those values were
the strings the server accepts.

Fix:
- condFormatEnhance now writes `properties.rule_type = <value>`
  directly (no nested `rule` object).
- Synced flag-defs.json + lark-sheets-conditional-format.md enum
  vocabulary from base to match the server: duplicateValues,
  uniqueValues, cellIs, containsText, timePeriod, containsBlanks,
  notContainsBlanks, dataBar, colorScale, rank, aboveAverage,
  expression, iconSet.
- ⚠️ Breaking: scripts passing the old CLI-invented enum values
  (e.g. --rule-type cellValue) now get a cobra "invalid value …
  allowed: …" error listing the new vocabulary. No alias layer.
- TestObjectCRUDShortcuts_DryRun's +cond-format-update case updated
  to assert the flat properties.rule_type shape + new enum.

#5 +filter-{update,delete}: auto-inject filter_id = sheet_id
-------------------------------------------------------------
manage_filter_object's contract is "filter_id === sheet_id" for the
sheet-scoped filter (per per-tool description in mcp-tools.json),
and update / delete operations MUST carry filter_id. Standalone
filterUpdateInput / filterDeleteInput never set it, so the server
rejected with "filter_id is required for update/delete operation"
on every call — both standalone AND inside +batch-update.

Fix:
- filterUpdateInput / filterDeleteInput now set
  input["filter_id"] = sheetID.
- Because filter_id must equal sheet_id (not sheet_name), update /
  delete reject when only --sheet-name is given — there's no
  network lookup available inside the builder. The friendly error
  points at +workbook-info for resolving sheet-name → sheet-id.
- create still omits filter_id (server requires that — id is
  server-allocated on creation).
- New tests:
  * TestObjectCRUDShortcuts_DryRun gains a +filter-update happy-path
    case asserting filter_id is auto-injected + --range hoisting.
  * +filter-delete case updated to assert filter_id presence.
  * TestBatchOp_RejectsBadSubOpInput gains two cases asserting both
    +filter-update and +filter-delete reject --sheet-name-only with
    the friendly error.

Docs (#2 + #3 + #8) synced from sheet-skill-spec
-------------------------------------------------
Companion doc fixes that landed via npm run generate:cli + sync:cli
in sheet-skill-spec; included here because the regenerated flag-defs
and references markdown are byte-tracked in this repo:

- #2: lark-sheets-sheet-structure.md — +dim-{hide,unhide,group,
  ungroup} --start/--end desc changed from "(0-based, inclusive)" to
  "(0-based)" / "(exclusive)" to match the half-open range semantics
  the code has always implemented (requireDimRange: end > start;
  dimRange uses end - 1 for column end letters).
- #3: lark-sheets-workbook.md — +sheet-move section gains a note
  about the batch-internal requirement to pass --sheet-id AND
  --source-index explicitly (sheetMoveBatchInput's constraint).
- #8: lark-sheets-pivot-table.md — +pivot-create --properties
  example drops the stale data_range field (the actual server
  schema uses --source as a hoisted flag; properties only carries
  rows / columns / values / filters / show_*_grand_total).

* feat(sheets): add +cells-batch-clear fan-out over batch_update

Clear content/formats across many sheet-prefixed ranges in a single atomic
batch_update (one clear_cell_range op per range), mirroring the existing
+cells-batch-set-style / +dropdown-{update,delete} fan-out wrappers. The
--scope to clear_type normalization is shared with standalone +cells-clear
(normalizeClearType) so the two stay in lockstep.

high-risk-write (requires --yes); rejected as a batch sub-op like the other
fan-out wrappers. flag-defs/flag-schemas and skill docs updated to match.

* docs(sheets): sync stdin guidance and sparkline reference

- skills/lark-shared/SKILL.md: drop the generic "prefer stdin" section
- skills/lark-sheets/SKILL.md: add expanded stdin guidance (use stdin over @file abs paths; don't cd or write into the project dir)
- skills/lark-sheets/references/lark-sheets-sparkline.md: document the group_id / sparkline_id two-tier model with worked examples

* fix(sheets): require sparkline_id on +sparkline-update items (#6)

manage_sparkline_object uses two layers of IDs: --group-id picks the
sparkline group, and properties.sparklines[i].sparkline_id picks each
item inside the group. The server contract requires sparkline_id on
every update item (server maps each entry back to an existing
sparkline by this id). Agents that called +sparkline-update without
the per-item ids hit an opaque server-side rejection that didn't
mention sparkline_id at all, then got stuck in a try-fail-list-retry
loop.

Pre-check CLI-side in objectUpdateInput via a new validateUpdateInput
hook on objectCRUDSpec. sparklineSpec wires validateSparklineUpdateItems,
which walks properties.sparklines[] and rejects with a message that
points at +sparkline-list:

  +sparkline-update properties.sparklines[N] missing sparkline_id
  (run `+sparkline-list --group-id <id>` first to read sparkline_id
   for each item, then echo each id back on the corresponding update
   entry)

Scope is update-only. config-only updates (properties.config without
sparklines) stay legal — the validator skips when sparklines is
absent. Delete is not pre-checked: objectDeleteInput doesn't pass
properties through, so the partial-delete branch can't be reached
today (separate follow-up).

Tests:

- TestObjectCRUDShortcuts_DryRun: positive case for update with
  sparkline_id present.
- TestSparklineUpdate_MissingSparklineID: standalone path — error
  contains both "missing sparkline_id" and "+sparkline-list".
- TestBatchOp_RejectsBadSubOpInput: batch sub-op missing sparkline_id
  rejected with the same friendly error.

Docs synced from sheet-skill-spec (canonical change committed there):
skills/lark-sheets/references/lark-sheets-sparkline.md documents the
two-layer id model, the three "+sparkline-list first" cases, and both
delete modes.

* docs(sheets): sync lark-sheets skill from spec (audit 20260521)

Pull latest spec from sheet-skill-spec (PR ee/sheet-skill-spec!6 + earlier
develop commits) into skills/lark-sheets/ and shortcuts/sheets/data/.

Audit findings now reflected in CLI docs:

- A2 +cond-format-create example: --rule-type duplicate → duplicateValues
- A3 +cond-format-create Validate: cellValue/formula → cellIs/expression
- A5 +csv-put examples: --range → --start-cell; drop redundant --allow-overwrite
- A7 +sparkline-create: Validate / Examples aligned with real schema
  (config/sparklines), executable JSON example added
- B13 cross-doc dead links: lark_sheet_*/cli-shortcuts.md → lark-sheets-*.md
- C2 +csv-put: `=` literal warning next to Examples
- CC5 +rows-resize/+cols-resize --type auto: single point of truth in
  range-operations reference

flag-defs.json description / required sync (from base):

- A4 +float-image-update: image-name/position-*/size-* required → optional
  (patch mode)
- A8 +dim-move --start/--end description cleanup
- B3 +pivot-create --properties: data_range → source (real field name)

Also picks up the +cells-batch-clear shortcut doc (introduced in spec
develop). Go-side implementation for that shortcut is intentionally not
in this PR — docs-only preview; runtime dispatch will land in a follow-up.

`go test ./shortcuts/sheets/...` passes.

* feat(sheets): add +cells-set --copy-to-range and sync skill spec

Sync lark-sheets skill references and flag schemas from upstream
sheet-skill-spec, and wire the newly-specced --copy-to-range flag into
+cells-set: it passes copy_to_range to the set_cell_range tool so a
template block written via --cells fans out across a larger range with
auto-shifted formula refs.

* docs(sheets): sync lark-sheets skill spec (chart/pivot wire mappings, --end semantics)

Sync skill references and flag-defs descriptions from upstream
sheet-skill-spec: clarify +chart-create properties structure
(snapshot.data), +pivot-create --target-position / --range wire-field
mappings, add a cross-command --end endpoint-semantics table
(insert/delete/hide/group exclusive vs move/resize inclusive), note
--group-state default, and rename reference identifiers to lark-sheets-*.

Description-only refinement; the existing CLI implementation already
matches the clarified wire mappings and --end semantics.

* fix(sheets): make --max-chars the single read cap for +cells-get / +csv-get

Drop --cell-limit (+cells-get) and --max-rows (+csv-get) from the CLI surface
and pin the underlying tool's cell_limit / max_rows to a very large sentinel so
the tool's own defaults never truncate before --max-chars. --max-chars stays the
only knob (default 200000, unchanged).

- lark_sheet_read_data.go: add unboundedReadLimit (1e9); cellsGetInput pins
  cell_limit, csvGetInput pins max_rows; --max-chars still passed through
- data/flag-defs.json: synced from spec (drops the two flags)
- tests: spot-check moved to --max-chars; dry-run wantInput asserts cell_limit /
  max_rows are pinned high

Mirrors sheet-skill-spec (Base flag records removed).
go build ./... + go test ./shortcuts/sheets/ green.

* docs(sheets): sync lark-sheets read docs — --max-chars as single read cap

Sync skills/lark-sheets references from spec: drop --cell-limit / --max-rows
guidance; 大表分批读 switches to --range row windows + --max-chars auto cap + has_more.
Mirrors sheet-skill-spec 58e7456 and handler change 2befc49.

* docs(sheets): sync lark-sheets skill spec from upstream

Refine reference docs and flag-defs descriptions from upstream
sheet-skill-spec (--depth wording for +dim-group / +dim-ungroup,
plus assorted reference clarifications). Description-only; no CLI
behavior or flag surface change.

* docs(sheets): sync chart properties schema (position/size required)

Regenerate flag-schemas.json from upstream sheet-skill-spec: the chart
properties schema now marks position and size as required, and the chart
reference doc reflects the same. flag-schemas.json is print-schema-only
(no client-side validation), so this is a generated-artifact + doc sync
with no CLI behavior change.

* docs(sheets): sync lark-sheets skill spec from upstream

Refine reference docs and flag-defs descriptions from upstream
sheet-skill-spec: clarify +workbook-export sheet flag scope, +filter-*
--properties optionality (omitted => empty filter on --range; rules must
be non-empty when provided), float-image reference_id wording, and
assorted reference cleanups. Description-only; existing CLI behavior
(filter passthrough, properties optional) already matches.

* docs(sheets): sync lark-sheets skill spec from upstream

Trim and refine reference docs from upstream sheet-skill-spec
(condense core-operations workflow, tidy write-cells / range-operations /
float-image / SKILL guidance). Description-only; no flag or CLI behavior
change.

* docs(sheets): sync lark-sheets skill spec from upstream

Refine reference docs from upstream sheet-skill-spec (core-operations,
formula-translation, visual-standards, SKILL guidance). Description-only;
no flag or CLI behavior change.

* fix(sheets): correct +workbook-create initial fill and +dim-move endpoint

+workbook-create: the v3 create response does not echo the default sheet's id, so the initial-fill set_cell_range was sent with an empty sheet_id and rejected ("sheet_id or sheet_name is required"). Resolve the workbook's first sheet via get_workbook_structure before filling.

+dim-move: the move request was POSTed to the v2 dimension_range endpoint (the add/update/delete surface, which requires a `dimension` object) and rejected with "[9499] Missing required parameter: Dimension". Switch to the native v3 move_dimension endpoint (sheet_id in path; snake_case source.{major_dimension,start_index,end_index} + destination_index). CLI --end and v3 end_index are both 0-based inclusive, so they pass through unchanged.

* fix(sheets): align +workbook-create, +dropdown-*, +dim-move, +range-sort with server schema

Five separate E2E failures in shortcuts/sheets/ that all trace back to a
CLI ↔ server contract mismatch. Each is independently scoped; bundling
them because they share the test-report citation and the same one-line
fix shape in most cases.

buildInitialFillInput sent {"sheet_id": ""} on the secondary
set_cell_range call after creating the workbook. The empty value was a
holdover from "...otherwise server picks first sheet" — but
set_cell_range rejects an empty selector with
"sheet_id or sheet_name is required" rather than falling back to the
default sheet.

Use sheet_name "Sheet1" instead. POST /sheets/v3/spreadsheets always
creates that sheet on workbook creation, and set_cell_range accepts
sheet_name as an equivalent selector — saves an extra
get_workbook_structure round-trip just to learn the auto-generated id.

buildDropdownValidation emitted four fields that don't exist in the
canonical set_cell_range.data_validation schema:

  - "values" (options list)       → renamed to "items"
  - "multiple_values"              → renamed to "support_multiple_values"
  - "colors" (per-option color)    → removed (not in schema; flag also
                                     removed from data/flag-defs.json
                                     for +dropdown-set / -update)
  - "highlight_options"            → removed (not in schema; flag also
                                     removed)

The canonical schema lives at sheet-skill-spec/canonical-spec/tool-
schemas/mcp-tools.json (set_cell_range tool, data_validation property);
the colors / highlight knobs were CLI inventions the server never
accepted, so removing the flags is correct (renaming would leave the
flags broken). Skill reference docs (write-cells.md, batch-update.md)
synced.

validateDropdownOptionsColors lost its colors check; renamed to
validateDropdownOptions to reflect the narrower contract.

dropdownGetInput sent "Sheet1!C2:C6" verbatim as a ranges[] entry.
get_cell_ranges expects sheet_id / sheet_name as separate fields and
ranges entries without the sheet prefix; the server bounced with
"sheet not found, sheetId:" (empty).

Use the existing splitSheetPrefixedRange helper (declared in
lark_sheet_batch_update.go) to break "Sheet1!C2:C6" into ("Sheet1",
"C2:C6"), then thread the sheet name through sheetSelectorForToolInput
exactly like +cells-get does.

The shortcut was POSTing to /sheets/v2/spreadsheets/{token}/dimension_
range, which is the v2 insert-dimension endpoint and requires a top-
level {"dimension": {...}} body. Move uses a separate endpoint:

  POST /sheets/v2/spreadsheets/{token}/move_dimension
  body: { "source": {...}, "destination_index": N }

(camelCase "destinationIndex" → snake_case "destination_index" to
match the v2 contract.) Both DryRun and Execute updated, plus the
TestDimMove_DryRun and TestExecute_DimMove assertions.

transform_range.sort_conditions[i] requires both `column` (string) and
`ascending` (bool); rangeSortInput passed the --sort-keys array through
to the server unvalidated, so missing fields surfaced as opaque
"required property X missing" errors with no per-item context.

Walk the parsed array client-side, reject with item-pointing messages.
Test fixtures and a contract-test fixture switched from the historical
{col, order} vocabulary (which the server has never accepted) to the
correct {column, ascending}.

Server-schema citations and test-report case mapping in this branch's
plan file.

* revert(sheets): drop direct flag-defs.json edits — generated from spec

data/flag-defs.json is regenerated from the upstream sheet-skill-spec
canonical-spec; editing it here gets clobbered on the next sync. The
schema realignment for +dropdown-set / -update --colors / --highlight
removal needs to land on the base table first, then flow back through
sheet-skill-spec → larksuite-cli sync, not via a direct CLI-side edit.

Restore the previous flag entries verbatim. The Go-side change in
buildDropdownValidation still drops the wire fields, so:

  - users passing --colors / --highlight today see the flag accepted
    silently (no effect on the wire) until the upstream removal lands;
  - after upstream removal + sync, both the flag declarations and the
    Go-side handling will be in sync.

Functional fixes (#1 workbook-create, #3 dropdown-get, #4 dim-move,
#5 range-sort) and dropdown wire-shape rename (#2) are unaffected.

* revert(sheets): drop direct edits to skills/lark-sheets/references/

These md files are sync targets generated from sheet-skill-spec; editing
them here gets clobbered on the next sync, same as data/flag-defs.json.
The --colors / --highlight row removals belong on the upstream base
table → canonical-spec sync, not here.

Restore the previous --colors / --highlight rows in both
lark-sheets-write-cells.md (+dropdown-set) and lark-sheets-batch-update.md
(+dropdown-update). The Go-side change in buildDropdownValidation still
drops the wire fields, so:

  - users passing --colors / --highlight today see the flag accepted
    silently (no effect on the wire) until upstream removes the flag;
  - after upstream removal + sync, both flag declarations, ref docs, and
    Go-side handling will be in sync.

Functional fixes (#1 workbook-create, #3 dropdown-get, #4 dim-move,
#5 range-sort) and dropdown wire-shape rename (#2) are unaffected.

* docs(sheets): sync from sheet-skill-spec — remove dropdown --colors / --highlight

Upstream sheet-skill-spec base table deleted the --colors and --highlight
flags on +dropdown-set / +dropdown-update (the corresponding wire fields
data_validation.colors / .highlight_options were never accepted by the
server schema; see prior fix in this branch). Re-running the sync from
canonical-spec brings the CLI flag-defs and skill reference docs back in
line with the Go-side handling that already drops these fields.

Generated by `npm run sync:cli` in sheet-skill-spec @ ac7acef.

* fix(sheets): restore +dropdown --colors / --highlight, map to canonical fields

Reverses the --colors / --highlight removal from 7932ab2 (item #2 of the
batch-1 schema-alignment commit). That commit dropped both flags after the
test report flagged data_validation.colors / highlight_options as "unexpected
property" — at the time the canonical set_cell_range.data_validation schema
listed only help_text / items / operator / range / support_multiple_values /
type / values, so the flags had no server-side target and the removal was
correct.

Since then, set_cell_range.data_validation has gained two fields explicitly
modelling the dropdown highlight UI (mcp-tools.json in sheet-skill-spec
2026-05-22 base sync):

  enable_highlight  (bool)       — show pill backgrounds
  highlight_colors  (string[])   — hex pill colors, length must match items

So the flags are back, but rewired:

  --colors    -> data_validation.highlight_colors    (was: colors)
  --highlight -> data_validation.enable_highlight    (was: highlight_options)

--options -> items and --multiple -> support_multiple_values renames from
7932ab2 are kept.

Changes:

- buildDropdownValidation: re-add --colors / --highlight handling against
  the new field names; --colors length check stays inline (so dropdownSetInput
  Validate path catches it via validateViaInput, no separate guard needed).
- validateDropdownOptions -> validateDropdownOptionsColors: restore the
  Validate-time --colors length check on +dropdown-update / +dropdown-delete
  (called from lark_sheet_batch_update.go).
- TestDropdownSet_CellsShape: extend to assert highlight_colors /
  enable_highlight emitted; assert legacy `colors` / `highlight_options`
  absent.
- TestDropdownSet_ColorsLengthMismatch: new — covers the early Validate
  error path.
- TestDropdownUpdate_BatchPayload: extend to cover dropdownBatchInput
  propagation of --colors / --highlight through batch_update.
- skills/lark-sheets/references/lark-sheets-{write-cells,batch-update}.md,
  shortcuts/sheets/data/flag-defs.json, flag-schemas.json: synced from
  sheet-skill-spec generate output (MR !7).

* chore(sheets): re-sync from spec + loosen --colors length check

Catches up to sheet-skill-spec's 2026-05-25 base sync (MR !7) after
rebasing onto upstream feat/lark-sheets-refactor (12 new upstream commits
including the lark-sheets skill refactor + tools-schema migration).

Spec changes flowing in:

- highlight_colors description loosened: length may be **shorter than**
  --options (server cycles remaining slots through a built-in 10-color
  palette); previously the tool errored on any length mismatch.
- shortcuts/sheets/data/flag-schemas.json: mass re-mirror — generator now
  emits `type` before `properties` and adds explicit `additionalProperties:
  false` on object schemas (cosmetic, no behavior change).
- skills/lark-sheets/references/lark-sheets-{batch-update,chart,write-cells}.md:
  --options gains the type='list' tag; data_validation inline field-count
  goes 7 → 9 (catches up the highlight schema in the summary); chart
  position / size marked optional per upstream.

Go-side adjustment:

- buildDropdownValidation / validateDropdownOptionsColors: change the
  --colors length check from strict-equal to "must not exceed --options"
  to match the relaxed schema.
- TestDropdownSet_ColorsLengthMismatch -> TestDropdownSet_ColorsLongerThanOptions
  (now hits the overflow path with 3 colors vs 2 options).
- New TestDropdownSet_ColorsShorterAccepted: 2 colors vs 4 options is
  legal and forwarded as-is.

* docs(sheets): sync dropdown --colors/--highlight clarification from spec

Mirrors sheet-skill-spec MR !7 changes:

- skills/lark-sheets/references/lark-sheets-write-cells.md: new "Dropdown
  配色" section explaining how --colors (→ data_validation.highlight_colors)
  and --highlight (→ data_validation.enable_highlight) compose — length
  rule (shorter ok, longer rejected), --highlight gating, palette
  fallback behavior, minimal +dropdown-set example.
- skills/lark-sheets/references/lark-sheets-batch-update.md: one-line
  pointer to the write_cells section for +dropdown-update / -delete
  (same rules).
- shortcuts/sheets/data/flag-defs.json: --colors / --highlight `desc`
  fields gain the long-form server-field / length-rule descriptions
  used by `--help`.

No Go-side change — earlier commit 538eb2e already loosened the
buildDropdownValidation length check to "must not exceed"; this PR step
just makes the docs / `--help` text catch up.

* feat(sheets): +dropdown-set/-update --source-range for listFromRange mode

Previously +dropdown-set / +dropdown-update only emitted
data_validation.type=list — agents wanting listFromRange (dropdown options
sourced from existing cells, kept in sync with that range) had to drop down
to +cells-set and hand-build a data_validation map. The flag now exposes it
natively as --source-range, paired with --options under XOR.

CLI changes:

- shortcuts/sheets/lark_sheet_write_cells.go:
  * new dropdownTypeAndItems(runtime) — central XOR resolver: rejects 0 or
    2 of {--options, --source-range}, returns (sourceSize, partial dv with
    type+items|range filled in). Source size = options length for list
    mode, rangeDimensions(--source-range) cell count for listFromRange.
  * buildDropdownValidation rewritten to call the resolver, then layer
    --colors / --multiple / --highlight on top — semantics unchanged
    for callers, just two modes instead of one.
  * validateDropdownOptions / -Colors renamed to validateDropdownSourceOrOptions
    so the XOR + length check fires at +dropdown-update Validate time too.
  * --colors length error message generalized: "must not exceed dropdown
    source size (N)" (covers both modes).
- shortcuts/sheets/lark_sheet_batch_update.go: rename call site.
- shortcuts/sheets/lark_sheet_write_cells_test.go: 4 new tests —
  ListFromRange (happy path: range + items absent + colors + highlight all
  emit), ListFromRange_ColorsLongerThanCells (overflow against T1:T3 cell
  count), XorBothSet, XorNeitherSet. Updated the existing
  ColorsLongerThanOptions assertion to match the new "source size" wording.

Spec-driven changes (synced via npm run sync:cli from sheet-skill-spec
MR !7 2c298b6):

- shortcuts/sheets/data/flag-defs.json: --options Required flips to xor on
  +dropdown-set/-update; new --source-range row gains long-form description
  pointing at server data_validation.range + the XOR semantics.
- skills/lark-sheets/references/lark-sheets-write-cells.md: "Dropdown 配色"
  section reorganized into "Dropdown 选项 + 配色" — XOR comparison table
  (list vs listFromRange), shared config flag table (--highlight /
  --colors), explicit length rule covering both modes, side-by-side
  minimal examples, server-range-normalization gotcha callout.
- skills/lark-sheets/references/lark-sheets-batch-update.md pointer updated
  to mention both modes + that +dropdown-delete is unaffected.

PPE smoke (ppe_lark_cli_sheet) on UFJxszjrZhZ1LVtc9FdcICSbn6b C column:
- +cells-set C1 → "性别" (bold + centered): updated_cells_count=1
- +dropdown-set --range C2:C21 --source-range "Sheet1!T1:T3" --colors
  '["#cce8ff","#ffd6e7","#e6e6e6"]' --highlight: updated_cells_count=20
- read-back: data_validation.type=listFromRange + range=$T$1:$T$3 (server
  normalizes the prefix away on storage; highlight_colors /
  enable_highlight not echoed by get_cell_ranges, see byted-sheet read
  projection TODO).
- error-path replay (both XOR violations + colors > source-size) all
  rejected at Validate stage with the expected messages.

* docs(sheets): sync agent-voice rewrite of Dropdown 选项+配色 from spec

Mirrors sheet-skill-spec MR !7 60df610 — narrative now describes how the
flags interact (XOR, colors length rule, highlight gating, sheet-prefix
read-back gotcha) without exposing the underlying data_validation field
names or server-side normalization details that agents don't act on.

No Go-side change, no shortcut behavior change.

* chore(sheets): restore --colors in parseJSONFlag docstring example list

The earlier commit 49104ec swapped --colors out of parseJSONFlag's "Used
by" example list when it deleted the flag (item #2 there removed --colors
/ --highlight from +dropdown-set/-update). Subsequent commits 8672d8e /
538eb2e / fb90c8b reinstated --colors (and added --source-range) but did
not roll back this docstring tweak — leaving an orphan reference to
--properties where --colors used to be.

This restores the example list to its pre-49104ec form so the docstring
matches what the helper actually services on this branch's HEAD.

Pure docstring change — function behavior unaffected, no test movement.

* fix(sheets): post-rebase test fixups after dropping superseded fix #1

Two test fallouts from rebasing onto upstream 4be06c8 (which independently
re-fixed +workbook-create and +dim-move with a more thorough approach):

- shortcuts/sheets/lark_sheet_workbook_test.go: our PR's earlier
  TestWorkbookCreate_DryRun "with headers and data → 2-step plan" subtest
  asserted the expedient sheet_name="Sheet1" / no-sheet_id wire body that
  matched our dropped fix #1 implementation. Upstream's fix #1 resolves
  the workbook's first sheet via get_workbook_structure and fills with
  the real sheet_id instead. Reset this file to upstream's version — our
  superseded assertions disappear, upstream's tests cover the new wire
  shape.

- shortcuts/sheets/execute_paths_test.go: TestExecute_RangeSort fixture
  still used the legacy {col, order} sort-key shape because the rebase
  resolution picked the upstream version of this file wholesale (it
  contained other unrelated changes). Re-apply just the one fixture
  update to {column, ascending} so fix #5's CLI-side rejection logic
  exercises a valid input — server-side sort_conditions has required
  fields `column` (string) and `ascending` (bool); the historical
  {col, order} vocabulary was never accepted.

go build ./... + go test ./shortcuts/sheets/... -count=1 both green.

* feat(sheets): +dropdown --highlight tri-state via Changed() for opt-out

The server-side default for data_validation.enable_highlight flipped from
false to true (aligning with the UI behavior). With the previous code path

    if runtime.Bool("highlight") { dv["enable_highlight"] = true }

omitting --highlight and passing --highlight=false both produced the same
"enable_highlight key absent" body, leaving CLI users with no way to opt
out of the (now-default) highlighting.

Switch to runtime.Changed() so the translator can distinguish all three
input shapes:

  - omitted          -> no enable_highlight key (server applies default=true)
  - --highlight=true -> enable_highlight: true  (explicit no-op vs default)
  - --highlight=false -> enable_highlight: false (the only opt-out path)

flagView already exposes Changed() and mapFlagView (the +batch-update
sub-op adapter) implements it via raw-key presence — same pattern other
translators use for "Changed-only" branching (e.g. omit target_index
unless --index was set), so no interface surface change is needed.

Test coverage:
  - TestDropdownSet_HighlightTriState pins all four shapes (omit / presence
    form / explicit true / explicit false) and asserts the enable_highlight
    key's presence/value
  - TestBatchOp_BodyMatchesStandalone adds a --highlight=false sub-op case
    so the batch sub-op path produces a body byte-identical to the
    standalone +dropdown-set --highlight=false body

* chore(sheets): sync +dropdown flag desc + write-cells narrative from spec

Mirror sheet-skill-spec generated/ into shortcuts/sheets/data/ and
skills/lark-sheets/ for the +dropdown-set / +dropdown-update path. No
hand edits in this repo.

The +dropdown flag desc and the Dropdown 配色 narrative now match the
server-side enable_highlight default flip (true) and the tri-state
--highlight semantics introduced in the sibling commit:

  * --highlight desc: 不传 = 开(按内置 10 色色板循环上色),
    --highlight=false 关闭得到纯白下拉
  * --colors desc: 单独传即生效(高亮默认开),--highlight=false 时忽略
  * write-cells reference: 三种意图三条线(默认色板 / 指定颜色 /
    纯白下拉)+ 新增 --highlight=false 示例

Source upstream: sheet-skill-spec MR !8.

* fix(sheets): validate +cells-set-image --image path in Validate

The unsafe-path check only ran at Execute (via FileIO.Stat), so --dry-run
printed a misleading success preview for an absolute / out-of-cwd --image
path that a real run would then reject. Move the path-safety check into
Validate (validate.SafeLocalFlagPath), so --dry-run and Execute fail
identically and both name the real --image flag. File existence stays
deferred to Execute, so legitimate relative paths still preview cleanly.

Add TestCellsSetImage_DryRunRejectsUnsafePath.

* feat(sheets): support local --image in +float-image-create

+float-image-create now accepts a local file via --image (XOR with
--image-token / --image-uri): the CLI uploads it as a sheet_image and
embeds the returned file_token, removing the previous "upload elsewhere
to get a token first" workaround. Path safety is checked in Validate,
--dry-run previews the extra upload step, and +batch-update rejects
--image (no upload phase). +float-image-update is unchanged (it does not
register --image).

Also syncs the lark-sheets skill docs/flag-defs from sheet-skill-spec:
the new --image flag, partial-merge / border-per-side / bare sheet-prefix
clarifications, and refreshed dropdown --colors/--highlight descriptions
(already pending in the source Base table).

* fix(sheets): +dropdown-get accepts --sheet-id/--sheet-name + bare --range

Align +dropdown-get with its get_cell_ranges siblings (+cells-get / +csv-get):
sheet selection is now via --sheet-id / --sheet-name (XOR) and --range is a
bare A1 reference. The previous shape required the sheet prefix inside --range
(e.g. "Sheet1!A2:A100") and was the odd one out among the read-data wrappers;
callers pasting the sheet-id form straight from the URL hit a misleading
"sheet not found, sheetId: , sheetName: <id>" error because the prefix was
unconditionally treated as sheet_name.

Flag schema + skill reference regenerated from the upstream Lark Base
Shortcut-flags table.

* fix(sheets): drop Sheet1! prefix from +cells-get / +csv-get / +csv-put flag examples

Server tools-schema.json for get_cell_ranges, get_range_as_csv and set_range_from_csv
does not accept a sheet prefix on --range / --start-cell; the sheet is selected via
--sheet-id / --sheet-name. +csv-put --start-cell also now states it must be a single
cell (no range notation).

Synced from spec repo.

* feat: 把环境变量提交上去

* fix(sheets): clarify batch --ranges prefix must be sheet display name

E2E test cases repeatedly trip on this:

  $ lark-cli sheets +cells-batch-set-style \
      --ranges '["7f8fba!A2:B3","7f8fba!C2:D3"]' --font-color '#3366FF' ...

  → tool "batch_update" failed: [900015206]
    sheet "7f8fba" not found. Available sheets: [{id: "7f8fba", name: "Sheet1"}]

Callers paste the hex sheet-id (e.g. "7f8fba") from a spreadsheet URL /
+sheet-create response straight into the --ranges sheet prefix. The four
batch shortcuts (+cells-batch-set-style / +cells-batch-clear /
+dropdown-update / +dropdown-delete) fan each range out into a
batch_update sub-op (set_cell_range / clear_cell_range) and pass the
prefix through as sheet_name; the server only matches sheet_name
literally, so the lookup fails.

The set_cell_range tool schema is explicit: sheet_id is the
reference_id and "must be correct or it errors"; sheet_name is the
display name. CLI can't disambiguate purely from the literal because
users can rename sheets to anything (including six-char hex strings).

Cleanest fix is at the source: each batch shortcut's --ranges flag
description now states explicitly that the prefix must be the sheet
display name and that the sheet reference_id is rejected, so agents
reading the reference don't try the id form in the first place.

No Go changes; these files are regenerated from the upstream Lark Base
Shortcut-flags table via the sheet-skill-spec sync chain.

* docs(sheets): sync lark-sheets skill docs from upstream spec

- SKILL.md: clarify --url only resolves /sheets/ and /spreadsheets/ links; /wiki/ links must be resolved via wiki +node-get first (confirm obj_type=sheet, use obj_token)
- formula-translation: document IMPORTRANGE cross-workbook limits (max 5-level nesting, 100 refs per sheet)
- write-cells: document rich_text cells for hyperlinks, @mentions and @docs

* feat: 同步 tools-schema.json 改动

* fix(sheets): warn when +dropdown source-range exceeds 2000 cells with highlight on

byted-sheet's ListFromRangeValidation.checkOptionsValid() sets
isOptionError=true when shouldHighlightValidData is on and the source
range exceeds LIST_WITH_COLOR_MAX_COUNT (2000 cells) — the highlight +
large source combo is unsupported. CLI previously had no signal for
this, so users only learned by seeing the dropdown render as
option-error in the workbook.

Add a Validate-phase stderr warning in +dropdown-set and +dropdown-update
when --source-range covers >2000 cells unless --highlight=false. Soft
warning, never blocks the request. Inline --options is not subject to
this limit — server enforces no count or per-item length cap on inline
lists, so no warning fires there.

* docs(sheets): sync lark-sheets skill from spec — dropdown flag descs reflect server reality

Pulls sheet-skill-spec canonical-spec → generated → consumers chain for
dropdown flag desc corrections committed upstream (Shortcut-flags base
table rows for +dropdown-set / +dropdown-update --options and
--source-range).

Aligns flag descs with byted-sheet behavior:
- --options: dropped fabricated "≤500 items, each ≤100 chars, no commas"
  promise. byted-sheet ListOfItemValidation enforces none of these.
- --source-range: appended note about the only real cap —
  LIST_WITH_COLOR_MAX_COUNT=2000 when --highlight is on (server flags the
  dropdown as option-error beyond that; CLI warns at Validate time per
  bb7ccae).

Also picks up an unrelated upstream tools-schema.json drift (chart float
block schema + data_validation.items description tweak) that surfaced
via npm run check:tool-schemas; bundling keeps the spec sync gate green.

* revert(sheets): drop tools-schema drift mirror from previous spec sync

930c9c7 顺带 sync 了 spec 的 tools-schema bundling — 跟那条 commit 一起
误带进来 chart float block required 和 data_validation.items 描述微调,
这两处其实是上游 sheet-ai-skills 还在 pending 的 revert。

配套 sheet-skill-spec 的 revert commit (a3aa9f2 on
fix/dropdown-flag-desc-real-limits / !11),重跑 sync:consumers 拉回
正确的 generated mirror:
- shortcuts/sheets/data/flag-schemas.json(chart 部分)
- skills/lark-sheets/references/lark-sheets-{chart,batch-update,write-cells}.md(rendered schema 段)

dropdown 文案改动(flag-defs.json 4 处 desc + dropdown 段的 reference
渲染)不在本 commit 范围,保持 930c9c7 的状态。

* docs(sheets): sync lark-sheets skill from spec — +filter-view-update --properties desc

去掉 +filter-view-update --properties 描述里"pass at least one of
--properties.rules / --range / --view-name"的误导承诺。--properties
实际是硬必填(MarkFlagRequired),且 update 走 PUT 整组覆盖语义。

* fix(sheets): align +cells-search/+cells-replace option keys with server schema

The CLI emitted `options.regex` and `options.include_formulas`, but the
server-side `search_data` / `replace_data` tool schemas declare and
consume `use_regex` and `match_formulas`. Result: passing `--regex` or
`--include-formulas` always failed with `unexpected property ... is not
defined in schema`.

Keep the user-facing flag names (`--regex`, `--include-formulas`) — only
the JSON keys sent to the server change. Updates the dry-run test that
locked the wrong contract.

* docs(sheets): sync float-image reference from spec — fix non-runnable examples

Two examples in skills/lark-sheets/references/lark-sheets-float-image.md
didn't actually run against PPE; sync brings them in line with CLI behavior:

- +float-image-create local-path example missed --image-name (CLI rejects
  with `required flag(s) "image-name" not set` even when path basename
  already has the filename). Add `--image-name "logo.png"` + inline note.
- +float-image-update "only change position" example missed image source
  (CLI rejects with `one of --image, --image-token, or --image-uri is
  required`). Expand to two steps: list with --jq pulls the current
  image_token, then update re-passes --image-token to satisfy the guard.
- Leading warning realigned: image source is mandatory on every update
  call; "keep original image" still requires passing the token explicitly.

Upstream change: sheet-skill-spec MR fix/float-image-reference-examples.

* feat: 同步 tools-schema.json 改动

* fix(sheets): allow +float-image-update to omit the image source

The image source (--image-token / --image-uri) is the only optional part
of an update: omit all of them to keep the current image. image_name,
position and size stay required — the manage_float_image tool rejects an
update without them, and +float-image-list does not return image_name to
backfill. Previously the shortcut forced an image source even when only
position/size changed, so those updates were rejected CLI-side before any
API call (reported as a Fail case in the sheets e2e rerun).

- floatImageProperties: gate the image-source requirement on create only;
  keep image_name/position/size required on both; emit image_uri only when set
- sync flag-defs.json + lark-sheets-float-image.md from sheet-skill-spec
  (image-name/position/size now required on +float-image-update)
- tests: cover the image-source-optional dry-run; the single-required checks
  move to the +batch-update sub-op path (cobra owns the standalone path)

* docs(sheets): sync lark-sheets skill from spec

Mirror the canonical-spec reference fixes into the consumer skill:
- search_replace output contract: `matches[]` with `address` (+ `has_more`/`next_offset`)
- workbook sheet fields: `sheet_name`/`is_hidden`/`*_count`, no `frozen_*`
- `+range-fill` example uses a non-overlapping target (A3:A100)
- drop the unimplemented `envelope.meta.verification` auto-readback claim; advise
  manual list/get verification instead

* fix(sheets): allow +pivot-create to omit both sheet selectors

manage_pivot_table_object treats sheet_id / sheet_name as the placement
target — when both are absent, handleCreate() auto-creates a new sub-sheet
to host the pivot table. The CLI's flag schema didn't reflect this:

- Exposed a third flag --target-sheet-id that mapped to the same wire
  field as --sheet-id, leaving the caller unsure which one to use
- --sheet-id / --sheet-name had "XOR with the other" descriptions that
  read like "operation context", so callers (especially LLM tool callers)
  felt obligated to set one — frequently the source sheet — which
  silently disabled the backend's auto-create guardrail and dropped the
  pivot at A1, overlapping the source data

Wire change (synced from sheet-skill-spec): drop the duplicate
--target-sheet-id flag; rewrite --sheet-id / --sheet-name descriptions
to make the placement-target semantics explicit and call out that
omitting both is the recommended path.

Implementation change (this PR): add an at-most-one sheet-selector
helper and let object create-shortcuts opt into it.

- helpers.go: new optionalSheetSelector (both empty allowed; both set
  still rejected; control-char validation unchanged). requireSheetSelector
  is untouched — every existing caller keeps the exactly-one contract.
- lark_sheet_object_crud.go: objectCRUDSpec gains
  allowEmptySheetSelectorOnCreate; objectCreateInput dispatches to
  optionalSheetSelector when it's set. Only pivotSpec opts in;
  chart / cond-format / sparkline / filter-view / float-image keep
  the existing require semantics. DryRun and Execute switch to direct
  flag extraction (same pattern Validate already used) so the XOR
  check happens in exactly one place (the builder).
- pivotSpec: drop the enhanceCreateInput branch that read the now-removed
  --target-sheet-id flag.
- Tests: TestPivotCreate_SheetSelectorSemantics covers both-empty /
  both-set / single-set; TestObjectCreate_RequiresSheetSelector
  regresses chart / cond-format / sparkline / filter-view to lock the
  scope of the relaxation.

* docs(sheets): clarify filter/filter-view rules update is whole-set PUT

Synced from upstream tools-schema. The rules field on manage_filter_object and manage_filter_view_object now documents update as whole-set PUT semantics: submitted rules become the complete rule set, all existing columns' rules are cleared first, columns not listed lose their old rules (no merge), and [] clears everything. Description-only change, no structural/field change.

* refactor(sheets): switch dim-* / rows-cols-resize to A1-string range schema

The 9 row/column-region shortcuts used to share two int flags --start /
--end with inconsistent end semantics across commands — +dim-insert /
-delete / -hide / -unhide / -group / -ungroup treated --end as exclusive,
while +dim-move / +rows-resize / +cols-resize treated it as inclusive.
The skill reference even called this out as "the highest-frequency
off-by-one source", patched in docs rather than at the surface. Three
underlying tool schemas (position+count, A1 range string, 0-based int
pair) were all flattened onto the same --start/--end pair, which forced
a different normaliser per command and pushed mental math (count =
end - start) onto every caller.

Schema (sourced from base, regenerated via sheet-skill-spec, mirrored
into shortcuts/sheets/data/ and skills/lark-sheets/):

  +dim-insert                                    --position + --count
    rows: "3"; columns: "C". --count rows/columns
    inserted *before* --position.
  +dim-delete / -hide / -unhide / -group / -ungroup
                                                 --range
  +rows-resize / +cols-resize                    --range
    A1 closed range. Rows: "3:7" or "5". Columns: "C:F" or "C".
    Mixing letters and digits in one range is rejected.
  +dim-move                                      --source-range + --target
    --target must match --source-range's dimension (both row or both
    column). The move places the source block *before* --target.

Wire-shape preserved: modify_sheet_structure still receives `position`
+ `count` (insert) or a `range` A1 string (other dim-* ops); v3
move_dimension still receives 0-based inclusive ints (CLI parses the
A1 strings into them); resize_range still receives a two-sided A1
range (single-element form is expanded to "N:N" before send).

This is a flag-surface break (--start / --end / --dimension flags
removed from these 9 shortcuts); --dimension stays only on +dim-freeze
since it has no range to derive from.

Code: A1 parser added (parseA1Range / parseA1Position /
letterToColumnIndex reused from write_cells); dimRange / dimRangeFull /
dimPosition deleted; dim-move switches to source-range + target parsing;
resize gains a same-dimension guard so +rows-resize rejects "A:C" with
a clear "+rows-resize expects row numbers" message.

Tests: TestSheetStructureShortcuts_DryRun / TestDimMove_DryRun /
TestDimMove_Column / TestDimMove_MismatchedDimension /
TestDimRange_Validation / TestParseA1Range / TestResize_TypeAndSizeGuards
/ TestRangeOperationsShortcuts_DryRun all rewritten against the new
schema. Batch contract trio (BodyMatchesStandalone /
ErrorEquivalence / RejectsBadSubOpInput) and
TestBatchOp_DispatchCoversReportedBugs likewise. Full
`go test ./shortcuts/sheets/` passes.

* docs(sheets): sync +pivot-create placement reference from spec

Companion sync from sheet-skill-spec — the canonical reference rewrites
+pivot-create's "5 placement-related flags" rundown into a clearer
"4 placement-related flags" form (--target-sheet-id was already removed
in #1130, this updates the prose accordingly), and clarifies that
--sheet-id / --sheet-name on +pivot-create are the *placement* sheet
(not the source-data sheet), with omit-both as the strongly-recommended
default.

Also picks up a base-side --target-position description tweak that
dropped the now-stale "与 --target-sheet-id 配套" reference.

No CLI surface change.

* docs(sheets): sync +pivot-create summarize_by lowercase enum values from spec

* docs(sheets): wrap sheet names in single quotes in A1 examples

Synced from spec. Affects 3 reference md (pivot-table / batch-update /
write-cells) and 2 generated flag-data JSONs.

A1 examples like `Sheet1!A1:D100` now read `'Sheet1'!A1:D100` so models
default to single-quoted sheet names. Excel A1 notation requires single
quotes for sheet names containing hyphens / spaces / non-ASCII chars;
always-quoting is also valid for plain names, so this is the safer default
to teach.

Affected flags:
- +pivot-create --source
- +dropdown-update --ranges / --source-range
- +dropdown-delete --ranges
- +dropdown-set --source-range
- +cells-batch-set-style --ranges
- +cells-batch-clear --ranges

* docs(sheets): wrap A1 sheet names in handwritten examples + bash histexpand guide

Synced from spec. Affects 4 reference md (chart / pivot-table / sparkline /
write-cells) and SKILL.md.

In addition to wrapping sheet names in single quotes in all remaining
handwritten examples (covers chart refs.value / nameRef, sparkline source,
write-cells --source-range, pivot-create narrative), SKILL.md gains a new
"Shell quoting for A1 references with !" section.

The new section addresses bash history expansion: in interactive bash
(e.g., ShellExec sandbox), unescaped `!Word` after `"..."` triggers
`bash: !A1: event not found`, dropping the command before lark-cli sees
it. The section gives 4 quoting strategies (shell single-quote outer,
`set +H` prefix, mixed quoting, sheet-rename fallback) and an anti-pattern
list.

Affected files:
- skills/lark-sheets/SKILL.md (new section)
- skills/lark-sheets/references/lark-sheets-chart.md
- skills/lark-sheets/references/lark-sheets-pivot-table.md
- skills/lark-sheets/references/lark-sheets-sparkline.md
- skills/lark-sheets/references/lark-sheets-write-cells.md

* docs(sheets): drop bash histexpand section, fix write-cells table escape

Sync from spec, refining the bash-quoting deep-dive added in 0f695b6:

- Drop the `## Shell 调用注意事项` section in SKILL.md and the inline
  `⚠️ bash 引号` callouts in lark-sheets-pivot-table.md and
  lark-sheets-write-cells.md. The 4-scenario quoting table + anti-pattern
  list turned out too verbose for the SKILL intro; single-quoted examples
  in the references are themselves enough nudge.
- lark-sheets-write-cells.md L146: fix the table cell escape from the
  malformed `'''Sheet1''!T1:T3'` (consecutive `''` are no-op empty
  strings) to `''\''Sheet1'\''!T1:T3'`, matching the bash example at
  L191 verbatim.

Net: 1 insertion, 40 deletions across 3 files.

* feat(sheets): rename +pivot-create sheet selector → --target-sheet-{id,name}

+pivot-create's placement selector (where the pivot table lands) is no
longer the generic --sheet-id / --sheet-name; it is now
--target-sheet-id / --target-sheet-name. The new names mark this as the
*output* sheet, distinct from the *data-source* sheet (which lives
inside --source as `'Sheet'!Range`). The other +pivot-{list,update,delete}
shortcuts keep --sheet-id / --sheet-name (their semantics are
"sheet that hosts the existing pivot", same as every other shortcut).

Motivation: an LLM agent reading the previous CLI surface saw +pivot-create
expose --sheet-id and assumed (as it had to) that it pointed at the data
source, like every other shortcut. The new flag name makes the intent
unambiguous at the call site, without relying on the agent having read
the narrative caveat in the reference doc.

Background: evaluation case U046 spent multiple rounds tripping on this
exact confusion before working around it with +sheet-rename.

Implementation:
- objectCRUDSpec gains createSheetIDFlag / createSheetNameFlag (with
  default-fallback accessors sheetIDFlagOnCreate / sheetNameFlagOnCreate);
  newObjectCreateShortcut + objectCreateInput consult the spec instead of
  hard-coded "sheet-id" / "sheet-name". pivotSpec sets target-sheet-*;
  every other create spec inherits the defaults.
- optionalSheetSelector (only used by pivot create) takes the two flag
  names as parameters so its mutex / control-char errors quote the names
  the user actually typed (--target-sheet-id, not --sheet-id).
- batch_op_dispatch: introduce sheetSelectorFlagsForSubOp(shortcut) →
  (idFlag, nameFlag) returning target-sheet-* for "+pivot-create" and
  the defaults otherwise; translateBatchOp uses it so +pivot-create
  sub-ops in +batch-update accept the same renamed input keys.
- Tests:
  - lark_sheet_object_crud_test.go: pivot-create cases switch args and
    expected error wording to target-sheet-*; extra assertion that the
    mutex error quotes the renamed flag (regression guard against
    flag-name drift between code and error message).
  - batch_op_contract_test.go: +pivot-create sub-op test uses
    target-sheet-id / target-sheet-name input keys; the body-vs-standalone
    contract loop reads the selector via sheetSelectorFlagsForSubOp so
    every other shortcut keeps using sheet-id / sheet-name.

Synced reference docs (skills/lark-sheets/{SKILL.md,
references/lark-sheets-pivot-table.md}) mirror the spec's new flag names,
narrative, 3-placement-strategy block, and SKILL.md exception bullet that
explains why +pivot-create's badge says 无 sheet 定位 yet still has
placement selectors (just under different names).

flag-defs.json synced from spec picks up the renamed flags + kind=own.

All sheets-package tests pass.

* docs(sheets): strip migration-history language from pivot reference / SKILL

Synced from spec. Removes "renamed from / no longer called / not
--sheet-id" style migration-history language that snuck into the
previous sync. Reference and SKILL now describe the current flag names
directly without referencing the old names.

* docs(sheets): require +workbook-info before guessing sheet name

Synced from spec. SKILL.md adds a new rule under the sheet-locator
section: unless the user has explicitly named a sheet, the agent must
call +workbook-info first to fetch sheets[].sheet_id / sheets[].title
rather than guessing the default `Sheet1`. The Chinese-language tables
this CLI is typically used against rarely use that literal name —
"数据" / "Sheet" (no digit) / "工作表 1" / business-named sheets are
far more common — so guessing wastes a round-trip before the agent
ends up calling +workbook-info anyway.

The 统一调用范式 example also switches its `--sheet-name "Sheet1"`
placeholder to `<真实表名>` to remove the inadvertent suggestion that
`Sheet1` is a sensible default.

* docs(sheets): tell agent to `set +H` for A1 references containing `!`

Synced from spec. The sheet-locator section now warns: when a flag value
contains `!` (--source / --range / --ranges with a cross-sheet prefix),
run `set +H` at the start of the bash session to disable history
expansion — otherwise interactive bash (e.g. inside an agent's shell
sandbox) lexes "Sheet1!A1" as a history reference and fails with
`event not found` before lark-cli ever sees the argument.

When the sheet name itself contains hyphens / spaces / non-ASCII
characters, the A1 reference also needs single quotes around the sheet
name per A1 notation, e.g. --source "'Sales-2025'!A1:D100".

Also flips the previous `--range` example to `--range 'Sheet1!A1:B2'`
(shell single-quote) for consistency.

* feat(sheets): add schema-driven JSON flag validation

Validate composite JSON flags (--properties, --cells, --options,
--border-styles, --sort-keys) against the embedded flag-schemas.json
on every standalone and +batch-update sub-op invocation, replacing
ad-hoc per-shortcut guards.

Supports the JSON Schema subset actually used upstream: type / enum
/ oneOf / required / properties / items / nullable / minimum /
maximum / minItems / maxItems / additionalProperties (true | false
| <schema>). Enum errors quote the failing value, truncate beyond 8
entries, and surface case-only "did you mean" hints (SUM -> sum).

Coverage: 18 / 19 (shortcut, flag) pairs. +batch-update --operations
stays validator-skipped; its translator already does richer per
sub-op checks. mapFlagView.Command() routes batch sub-ops through
the same (command, flag) -> schema pipeline as standalone.

loadFlagSchemas() is now sync.Once-guarded so parallel first access
from t.Parallel test sets and concurrent shortcut invocations is
race-free.

Removes superseded hand-written guards:
  - +pivot-create validateCreateInput / validatePivotCreateProps
  - +range-sort sort-keys per-item shape check
Test fixtures updated to be schema-conformant (chart position/size,
pivot summarize_by lowercase, cells 2D-array shape).

* feat(sheets): add --rows-json output flag to +csv-get

+csv-get --rows-json returns structured rows ({row_number, values:{col→cell}})
instead of the CSV string, so callers can address cells by row_number / column
letter without parsing [row=N] or RFC-4180 CSV. Same read, alternate output
shape — a flag on +csv-get (default stays CSV), not a separate shortcut, since
the two differ only in representation.

- CsvGet.Execute: --rows-json reshapes the response via assembleRowsJSON
  (parses annotated_csv into per-row records keyed by column letter; every
  logical row emitted; embedded newlines parsed into cell values)
- surfaces the under-read hint structurally as data_not_fully_read
- flag-defs.json + read-data reference synced from spec

* feat(cli): agent-friendly errors, proxy silencing, +csv-put --range

Agent-experience fixes distilled from analyzing 50 real sheets
trajectories, where the top failures were hallucinated command/flag
names, proxy warnings corrupting JSON on stdout, and --range carried
over from +csv-get to +csv-put.

- did-you-mean: unify the duplicated Levenshtein into a shared
  internal/suggest package and wire its prefix-weighted ranker into
  unknown-subcommand and unknown-flag errors; flag-parse errors now
  return a structured envelope with suggestions plus the full valid list,
  so agents recover from semantic typos (e.g. --query vs --find).
- proxy: suppress the one-time proxy warning in non-interactive
  (agent/CI/piped) runs so a 2>&1-merged stderr line cannot corrupt
  stdout JSON; interactive sessions still warn.
- sheets +csv-put: accept --range as an alias for --start-cell (parity
  with +csv-get / +cells-set) and echo the computed writes_range in
  dry-run and the success envelope, so agents see the paste footprint
  before it overwrites neighbours.
- docs(sheets): add an intent->command cheat-sheet to SKILL.md, a
  runtime-prerequisites section, and document the --range alias and
  writes_range behaviour.

* feat(sheets): close P0-4 pivot gaps — enum case, clear→pivot-delete hint, placement warning

Last open P0 from the 50-trajectory analysis — the two pivot black holes:
upper-cased summarize_by, and pivots built over the source sheet that hit
#REF! and then couldn't be removed.

- enum case tolerance: validateAgainstSchema rewrites a case-only enum
  mismatch to the canonical (lower-case) spelling in place ("SUM" -> "sum")
  before the request is sent, killing the whole class instead of only
  hinting at it. Covers every nested enum (values[], calculated_fields[]);
  genuinely unknown values still fail with the existing did-you-mean message.
- +cells-clear / +cells-batch-clear: when the backend reports "can not find
  embedded block" (the range overlaps a pivot/chart), annotate the error
  with the real fix — clearing cells can't delete an embedded object; remove
  it with +pivot-delete / +chart-delete (id via +pivot-list / +chart-list).
  Applied to both shortcuts, a Tips line, and the cells-clear reference.
- +pivot-create: a --help Tips block making "omit --target-* -> backend
  auto-creates a sub-sheet, zero overwrite" the can't-miss default, plus a
  placement_warning (dry-run + execute output) when an explicit target sheet
  is set with no offset — definite when the target name matches the source
  sheet, conditional otherwise. Local-only, advisory, never blocks the call.

The placement_warning is structured output, not a stderr line, so it
survives non-interactive proxy-warning silencing and isn't swallowed by 2>&1.

* feat(sheets): strip UTF-8 BOM from stdin/@file flag input

resolveInputFlags now strips a leading UTF-8 BOM from content read via stdin
or @file, so it cannot corrupt the first CSV cell or break JSON parsing of
payloads like --operations / --cells downstream.

Also pulls the synced lark-sheets skill docs from sheet-skill-spec and drops
scheme-number tags from two test comments.

* fix(sheets): drop dead --value-render-option flag from +csv-get

+csv-get wraps get_range_as_csv, which has no value_render_option support
(absent from its input type, executor, and published tool schema — it always
returns formatted display text via getText()). The CLI passed the flag through
as a silent no-op: callers asking for raw_value/formula got formatted values.

Remove the flag from flag-defs, drop the value_render_option passthrough in
csvGetInput, and clean the stale SKILL references. The real value_render_option
capability is unchanged on +cells-get (get_cell_ranges) via --include formula.

* chore: rename ppe x-tt-env lane to ppe_moa_canvas

* docs(sheets): sync skill description from spec (cloud-drive alias, lark-drive search, doubao routing)

* feat(sheets): restore pre-refactor shortcuts under backward/ for compatibility

The lark-sheets refactor renamed every shortcut (verb-noun → noun-verb,
e.g. +create-sheet → +sheet-create) and dropped the old commands. External
callers and the tests/cli_e2e/sheets suite still drive the legacy command
names (+create, +read, +write, +create-sheet, ...), which broke.

Re-add the pre-refactor implementations verbatim from main as an isolated
shortcuts/sheets/backward package (package rename only) and register
backward.Shortcuts() alongside sheets.Shortcuts(). Both sets mount under the
`sheets` service; their command names are fully disjoint (38 new vs 42 old,
zero overlap), so old and new commands coexist without collision.

* fix(sheets): resolve 30 golangci-lint v2.1.6 issues — copyloopvar, nilerr, unused

Removed 25 Go 1.22+ loop variable copies (copyloopvar) from test files where
tc := tc / tt := tt / c := c are no longer needed. Fixed 4 nilerr false
positives in flag_schema_validate.go by making intentional error discards
explicit (schema validation failures skip silently — best-effort guard).
Dropped unused batchOpDispatchKeys helper in batch_op_dispatch.go.

* feat(sheets): flag pre-refactor backward aliases via _notice and --help grouping

Nudge users whose lark-sheets skill predates the refactor to migrate off
the pre-refactor aliases (+read, +write, ...), without requiring anyone
to read --help.

- internal/deprecation: process-level pending Notice slot (mirrors
  internal/skillscheck), surfaced in the JSON "_notice" envelope under a
  "deprecated_command" key.
- internal/cmdutil: shared DeprecatedGroupID cobra group + helper so both
  --help rendering and the unknown-subcommand path classify aliases the
  same way.
- shortcuts/register.go: applySheetsCompatGroups splits the aliases into a
  dedicated "update your skill" help group with "(-> +new)" pointers;
  wrapSheetsBackwardDeprecation records the notice from Validate/Execute so
  direct callers that never read --help still get flagged.
- cmd/root.go: extract composePendingNotice (now unit-testable) and split
  availableSubcommandNames into current vs deprecated buckets while still
  ranking unknown-subcommand suggestions across both.

* chore: drop hardcoded ppe lane routing from base security headers

The x-tt-env/x-use-ppe headers forced every request onto the
ppe_moa_canvas pre-release lane; they were only meant for exercising the
sheets refactor against the staging backend. Remove them so the CLI
routes to production by default.

* chore(sheets): promote lark-sheets skill to 2.0.0

Drop the -draft suffix now that the refactored sheets skill is ready to
ship.

* fix(sheets): correct +dropdown-get sheet-locator doc, finalize skill to 2.0.0

+dropdown-get requires a mandatory sheet selector — its Validate calls
resolveSheetSelector — so drop it from the "no sheet locator" exception
list in SKILL.md. It was wrongly grouped with +dropdown-update/+dropdown-delete,
which take only --ranges. +dropdown-get's own per-shortcut badge (公共四件套)
was already correct. Also finalize the skill version 2.0.0-draft -> 2.0.0.

* fix(sheets): enforce required-flag contract in batch sub-ops

Batch sub-ops reuse each shortcut's shared *Input builder through mapFlagView,
which seeds flag-defs defaults — so any required check that lives OUTSIDE the
builder (cobra MarkFlagsOneRequired, or a shortcut's own Validate) is silently
bypassed and the default value wins. Two gaps surfaced in PR review:

- +csv-put: with neither --start-cell nor --range set, start-cell's "A1"
  default won and the paste silently anchored at A1. Require an explicit anchor
  (guard on Changed, mirroring the standalone MarkFlagsOneRequired).
- +sheet-move: --index (plus >=0 bounds for index / source-index) was not
  enforced in the batch path; a missing --index silently moved the sheet to the
  front. Mirror SheetMove.Validate.

Also from the same review:
- +batch-update: an explicit --continue-on-error=false now wins over an
  --operations envelope's continue_on_error:true (guard on Changed, not value).
- validateDropdownRanges rejects malformed sheet!range ("!A1", "Sheet1!",
  "Sheet1!bad") at Validate instead of deferring to the server.

Tests added/updated for each path; full sheets suite green.

* fix(cli): surface skill in deprecated_command notice

deprecation.Notice carries Skill, but the _notice.deprecated_command payload
dropped it, forcing callers to parse `message` to learn which skill to update.
Emit `skill` when set, alongside the existing `replacement`.

* fix(sheets): harden batch type-checking and +workbook-create edge cases

From the branch code-review doc (3 findings):

- +batch-update sub-ops: `operations` is skipped by parse-time schema
  validation and mapFlagView coerces a type-mismatched scalar to its zero
  value, so "index":"abc" or "multiple":"true" silently became 0 / false and
  wrote to the wrong place. translateBatchOp now runs validateRawTypes, which
  checks each sub-op scalar against its flag-defs type and rejects mismatches.

- +workbook-create with empty arrays: buildInitialFillInput returned (nil,nil)
  for empty rows while the caller wrote fill["excel_id"] unconditionally, so
  --values '[]' panicked on a nil map and --headers '[]' produced an illegal
  "A1:1" range. It now also returns nil when no cells survive (maxCols==0
  guard) and Execute/DryRun skip the fill when fill==nil.

- +workbook-create partial failure: after the spreadsheet was created, a
  first-sheet lookup or fill failure returned a bare fmt.Errorf, losing the new
  token. It now returns a structured partial_success error carrying
  spreadsheet_token in the detail so callers can retry or clean up.

Tests added for each path; sheets suite green.

* fix(cli): structured errors for unknown flags, print-schema, deprecated aliases

From the branch code-review doc (3 findings):

- pure-group UnknownFlags: installUnknownSubcommandGuard whitelists unknown
  flags so a mistyped subcommand still reaches the suggestion path, but a lone
  unknown flag before any subcommand (`sheets --badflag`) was swallowed and the
  group fell through to help + exit 0. unknownSubcommandRunE now recovers the
  swallowed tokens (from os.Args captured at Execute entry) and fails with a
  structured unknown_flag error; a misplaced but known flag (e.g. --format)
  still prints help.

- deprecated-alias notice: a backward-compat alias that fails a cobra-level
  required flag short-circuits before RunE, so the Validate/Execute-wrapped
  deprecation notice was dropped. Added Shortcut.OnInvoke, fired from PreRunE
  (ahead of ValidateRequiredFlags); and the root legacy error fallback now
  routes through the structured envelope when a deprecation is pending so the
  migration hint survives. Non-deprecated errors keep the plain output.

- --print-schema: runShortcut returned the bare error from PrintFlagSchema. It
  is now wrapped as a structured output.ExitError (type print_schema_error) so
  agent introspection can parse the failure.

Tests added for each path; cmd + sheets suites green.

* fix(sheets): resolve --sheet-name via title + keep bare sheet selectors verbatim

Two review findings on the backward-compat layer:

- lookupSheetIndex matched only sm["sheet_name"], but get_workbook_structure
  surfaces the sub-sheet display name as "title". Every --sheet-name path that
  relies on the lookup (e.g. +sheet-move) failed to resolve. Fall back to
  "title" when "sheet_name" is absent so either field resolves.

- +read / +write / +append fell back to --sheet-id when --range was omitted,
  then routed that bare sheet id through the range normalizer. A sheet id that
  looks A1-ish (letters+digits, e.g. "shtABC123") got mangled into
  "shtABC123!shtABC123:shtABC123". Split the sheet-only path from the
  range-normalization path: read/append pass the selector through verbatim,
  write builds the rect from the selector's A1.

Regression tests added for both paths; sheets suite green.

* fix(sheets): silence nilerr/copyloopvar lint in batch type-check additions

- flag_view.go: annotate the fail-open return in validateRawTypes with
  //nolint:nilerr (matches the repo convention for intentional fail-open).
- execute_paths_test.go: drop the redundant tc := tc copy (Go 1.22+ scopes
  the loop var per iteration).

* test(sheets): data-driven required-flag parity contract for batch sub-ops

Adds TestBatchOp_RequiredFlagParity, the systematic standalone-vs-batch parity
check the branch review asked for. Data-driven over batchOpDispatch + flag-defs,
it asserts that for every batchable shortcut a +batch-update sub-op which
satisfies the sheet locator but omits the shortcut's business-required flags
fails in translateBatchOp, never silently defaulting.

This generalizes the hand-picked TestBatchOp_ErrorEquivalence / GuardsBeyondCobra
cases to the full 50-command surface and auto-covers shortcuts added later, so a
future refactor that moves a required check out of the shared *Input builder
(the failure mode behind the csv-put / sheet-move gaps) is caught here. 45
sub-tests run; locator-only commands (+sheet-delete / +sheet-hide / ...) have no
business-required flag to omit and are skipped. A missing-locator error is also
rejected so a bad fixture can't mask a real gap.

* refactor(sheets): drop unused int64 flag-type plumbing

No sheets flag-def declares an int64 type and RuntimeContext.Int64 had
zero callers, so remove the premature support: the RuntimeContext.Int64
helper, the registerShortcutFlagsWithContext int64 branch, the flagView
Int64 method + mapFlagView impl, and the typedDefault/validateRawTypes
int64 cases. float64 (consumed by --font-size) is kept.

* test(sheets): drop redundant copyloopvar copy in required-flag parity test

Go 1.22+ scopes the loop var per iteration, so `cmd, business := cmd, business`
in TestBatchOp_RequiredFlagParity is a no-op that trips the repo's copyloopvar
linter (same cleanup as 2132472). Behavior unchanged; 45 sub-tests still pass.

* revert(cli): drop non-interactive proxy-warning silencing

WarnIfProxied's interactivity gate is a generic CLI/agent-UX change
unrelated to the sheets refactor / backward-compat scope of this branch.
Split out to a dedicated PR; restore WarnIfProxied to its single-arg form
here (warn.go, warn_test.go, factory_default.go callers).

* docs(sheets): correct +workbook-info output field and batch +sheet-move index requirement

Sync from spec: +workbook-info returns sheet display name as 'title'
(sheet_name only as legacy fallback), and +sheet-move inside +batch-update
also requires --index, not just --sheet-id/--source-index.

* fix(sheets): reject non-integer numbers for batch int flags

validateRawTypes treated int and float64 identically (both only required a
JSON number), but mapFlagView.Int() truncates float64 via int(t), so a batch
sub-op accepted 1.9 for an int flag (e.g. --index) and silently floored it to
1. Standalone cobra rejects non-integer input for int flags at parse time;
enforce the same in the batch path with a math.Trunc check so batch/standalone
parity holds and positional fields can't land on a floored value.

* fix(cli): align flag-before-subcommand unknown_flag detail schema

The flag-before-subcommand recovery path emitted a Type: unknown_flag whose
detail only carried unknown_flags + command_path, diverging from
flagDidYouMean's unknown_flag detail (unknown, command_path, suggestions,
valid_flags). A consumer keyed on Type then saw two shapes for one Type.

Emit the same keys from both paths: add unknown (the offending flag; joined
when multiple), plus empty suggestions/valid_flags — the subcommand isn't
resolved at this point, so there is no meaningful flag universe to suggest
from, and the group's own flags would mislead. unknown_flags is retained as
the authoritative multi-flag field. Test locks the shared schema.

* perf(sheets): compile flag specs to Go to drop startup JSON parse

Every lark-cli invocation (sheets or not) unmarshaled data/flag-defs.json
(122KB) and data/flag-schemas.json (256KB) during package init, before
main(): flag-defs via the shortcut package vars (flagsFor runs at init),
flag-schemas via shortcuts.init() -> Shortcuts() -> commandsWithFlagSchema().
On a 0.5-core sandbox this cold-start cost lands on every command.

Compile both specs to Go at build time instead of parsing at runtime:

- flag-defs.json -> flag_defs_gen.go: flagDefs is a compiled map literal;
  loadFlagDefs() returns it directly (no embed, no Unmarshal).
  ~3.3ms/4110 allocs -> ~0.57ms/539 allocs at sheets package init.
- flag-schemas.json -> flag_schemas_gen.go: only the command-name set
  (commandsWithSchema) is compiled in; registration and the validate
  fast-path gate on it without touching the 256KB blob. The blob stays
  embedded and is unmarshaled lazily only on --print-schema or when
  validating a command that has a schema. Removes the 256KB parse from
  init entirely.

data/*.json remain the canonical source; *_gen.go are committed, derived
artifacts regenerated with `go generate ./shortcuts/sheets/...`
(shortcuts/sheets/internal/gen). *_gen_test.go guard source/generated drift.

No behavior change: flag rendering, required/enum/default, --print-schema,
and composite-flag schema validation verified unchanged; ./shortcuts/...
tests pass.

* ci(sheets): exempt internal/gen generators from forbidigo

The shortcuts/sheets/internal/gen code generator is a standalone
`package main` run via go:generate, not shortcut runtime code, so the
forbidigo bans on log.Fatal / os.ReadFile / fmt.Printf do not apply.
Making it "compliant" is impossible anyway: a structured error return
needs os.Exit (also banned), and the vfs alternative is blocked by
depguard shortcuts-no-vfs. Exempt shortcut internal/gen paths, matching
the existing _test.go and internal/vfs forbidigo exemptions.

* fix(cli): fail structured on flags before a missing subcommand

A pure group invoked with flags but no subcommand (e.g. `im --format=json`,
`sheets --format json`) silently fell through to help + exit 0, so an agent
could mistake a malformed call for success. The unknown-subcommand guard's
FParseErrWhitelist swallows the flags and leaves RunE with empty args; it now
recovers the raw flag tokens and fails structured:

  - unknown flag(s)        -> unknown_flag       (unchanged)
  - valid flag, no subcmd  -> missing_subcommand (new, exit 2)
  - bare group             -> help, exit 0       (unchanged)

Because the group RunE is hook-wrapped, returning a real error also makes
plugin observers record the call as failed instead of ok (the lifecycle Err
is no longer flipped to nil).

Hardening from the same review:
  - document the cobra error-text contract unknownFlagName relies on, in
    both cmd/root.go and go.mod, so an i18n/reword is caught on upgrade.
  - guard the reserved --print-schema/--flag-name registration with a Lookup
    so a shortcut declaring same-named flags can't panic pflag.

Tests cover the new missing_subcommand path and the reserved-flag collision.

* fix(cli): don't flag group-valid globals as a missing subcommand

9f8dfa72 made a pure group invoked with flags but no subcommand fail with
missing_subcommand, keying on "any flag defined in the tree". That also matches
inherited global flags (--profile, ...), so `lark-cli --profile p im` and
`lark-cli im --profile p` errored with a misleading "flag --profile belongs to
a subcommand" instead of printing the group's help — a regression, since a bare
group carrying a global flag should print help.

Only treat a flag as missing_subcommand when it is valid on a subcommand but
not on the group itself or inherited (subcommandOnlyFlagTokens). A bare group
carrying only group-valid/global flags falls through to help; flags that
genuinely belong to an omitted subcommand (`im --format json`) still fail
structured, and unknown flags (`im --badflag`) still report unknown_flag.

Test covers a global flag on a bare group resolving to help.

---------

Co-authored-by: zhengzhijie <zhengzhijie.j@bytedance.com>
2026-06-03 20:43:53 +08:00
max
03a589978f feat(vc): forward invite call-id on meeting join (#1243) 2026-06-03 20:23:09 +08:00
evandance
b3fcf55611 feat(common): emit typed validation errors from shared shortcut pre-checks (#1242)
Input pre-check failures shared by every shortcut — @file/stdin input
resolution, enum validation, and unsupported --dry-run — now leave the
CLI as typed validation envelopes naming the offending flag, so scripts
and AI agents can branch on `param` instead of parsing prose. Wire type,
exit code, and message text are unchanged; the new fields are additive.

The shared layer also gains typed replacements for its legacy
error-producing helpers, so each business domain can migrate to typed
errors without rebuilding common plumbing, and a path-scoped lint guard
keeps migrated domains from sliding back.

Changes:
- Shared pre-check failures (input flags, enum values, dry-run support)
  return typed validation errors carrying the offending flag as `param`.
- Every legacy error-producing helper in shortcuts/common has a typed
  replacement that preserves the existing message text: validation and
  flag-group checks, chat/user ID validation (callers name the flag so
  `param` is ground truth), "me" open-id resolution, safe-path checks,
  input-stat and save-error wrapping. Legacy helpers stay for
  not-yet-migrated domains, marked deprecated — including the legacy
  API-result classifier, whose typed route is runtime.CallAPITyped.
- A new errscontract rule rejects legacy common-helper calls on migrated
  paths, so a migrated domain cannot silently reintroduce legacy
  envelopes; drive is the first locked path and its last legacy
  ID-helper calls are replaced.
2026-06-03 19:20:19 +08:00
91-enjoy
2f35ce3724 feat: complete card message format (#1198)
The card message converter (shortcuts/im/convert_lib/card.go) previously
rendered a subset of card fields and had several mode-gated behaviors that
caused information to be silently dropped in concise mode. This PR audits
every element handler and brings the output up to full fidelity:
missing header fields are rendered, collapsible panels always expand, rich
element metadata (images, audio, video, overflow URLs, person names) is no
longer hidden behind cardModeDetailed, and several format bugs are fixed.

Change-Id: I422474ab6b7505e48ab5697793900df035be6e29
2026-06-03 19:17:12 +08:00
zgz2048
7e7f716a82 feat(base): add base block shortcuts (#1044)
* feat(base): add base block shortcuts

* fix(base): use block scopes for base block shortcuts

* fix(base): split base block shortcut scopes

* docs(base): consolidate base block help

* docs(base): simplify block help wording

* test(base): cover base block shortcut execution

* feat(base): filter base block list by type

* docs(base): clarify base block ids

* docs(base): simplify docx block help

* docs(base): refine base block agent help
2026-06-03 18:15:50 +08:00
xukuncx
1670a794f6 feat(mail): add message_ids validation in +messages before batch_get (#1202)
Add CLI-side validation for --message-ids in the mail +messages shortcut
to catch obviously invalid inputs before making any API call. The batch_get
endpoint would otherwise only reject malformed IDs server-side, returning
unclear errors.

Validation rules:
- Reject empty message-ids list
- Reject entries exceeding the server-mirrored batch limit of 20 IDs
- Reject entries with leading/trailing whitespace
- Reject entries containing control characters, whitespace, or path separators
- Reject duplicate message IDs

sprint: S2
2026-06-03 18:04:54 +08:00
liujiashu-shiro
33de28fd1a feat: improve lark im markdown guidance (#1237)
Improve the --markdown vs --text guidance in the lark-im send/reply reference docs. Reposition --markdown as the recommended default for agents, add explicit selection rules, and reframe the docs around usage scenarios rather than caveats.
2026-06-03 16:55:52 +08:00
caojie0621
85c7280d8b feat(wiki): support appid member type (#1235)
CCM-Harness: code
2026-06-03 15:49:25 +08:00
MaxHuang22
24ce3ec151 feat: add --json flag as no-op alias for --format json (#1104)
* feat(api): add --json flag as no-op alias for --format json

* feat(service): add --json flag as no-op alias for --format json

* feat(shortcut): add --json flag as no-op alias for --format json

Skip registration when a custom --json flag already exists on the
command (e.g. base shortcuts use --json for body input).

Change-Id: If66236cadeea7fa81811061cce775deff51b92ce
2026-06-03 13:58:14 +08:00
1194 changed files with 182399 additions and 20006 deletions

30
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,30 @@
/internal/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1
/skills/lark-approval/
/skills/lark-apps/
/skills/lark-attendance/
/skills/lark-base/
/skills/lark-calendar/
/skills/lark-contact/
/skills/lark-doc/
/skills/lark-drive/
/skills/lark-event/
/skills/lark-im/
/skills/lark-mail/
/skills/lark-markdown/
/skills/lark-minutes/
/skills/lark-okr/
/skills/lark-openapi-explorer/
/skills/lark-shared/
/skills/lark-sheets/
/skills/lark-skill-maker/
/skills/lark-slides/
/skills/lark-task/
/skills/lark-vc/
/skills/lark-vc-agent/
/skills/lark-whiteboard/
/skills/lark-wiki/
/skills/lark-workflow-meeting-summary/
/skills/lark-workflow-standup-report/

View File

@@ -10,8 +10,6 @@ on:
permissions:
contents: read
actions: read
checks: write
pull-requests: write
jobs:
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
@@ -80,10 +78,47 @@ jobs:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Resolve changed-from baseline
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: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
- name: Run errs/ lint guards (lintcheck)
run: go run -C lint . ..
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
deterministic-gate:
needs: fast-gate
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Resolve changed-from baseline
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: Run CLI deterministic gate
run: make quality-gate
- name: Upload quality gate facts
if: ${{ always() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: quality-gate-facts-${{ github.event.pull_request.base.sha }}-${{ github.event.pull_request.head.sha }}
path: .tmp/quality-gate/facts.json
if-no-files-found: error
retention-days: 7
coverage:
needs: fast-gate
@@ -103,6 +138,7 @@ jobs:
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
with:
files: coverage.txt
@@ -184,7 +220,7 @@ jobs:
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint]
needs: [unit-test, lint, deterministic-gate]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
@@ -205,9 +241,12 @@ jobs:
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint]
needs: [unit-test, lint, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
@@ -254,6 +293,9 @@ jobs:
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
security:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -291,7 +333,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
needs: [fast-gate, unit-test, lint, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -303,6 +345,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 | 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
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
@@ -318,6 +361,7 @@ jobs:
"${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.deterministic-gate.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \
"${{ needs.e2e-dry-run.result }}" \

560
.github/workflows/semantic-review.yml vendored Normal file
View File

@@ -0,0 +1,560 @@
name: Semantic Review
on:
workflow_run:
workflows: ["CI"]
types: [completed]
permissions:
actions: read
contents: read
jobs:
pr-quality-summary:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
issues: write
pull-requests: write
steps:
- name: Verify workflow run and pull request for summary
id: pr
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
if (runPRs.length > 1) {
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
let eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = eventHeadSha || run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
let factsArtifactName = "";
let artifactBaseSha = "";
let artifactError = "";
if (factsArtifacts.length !== 1) {
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
} else {
factsArtifactName = factsArtifacts[0].name;
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
}
}
if (!prNumber) {
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
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}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
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({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.head.sha !== targetHeadSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
return;
}
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
core.setOutput("stale", "true");
return;
}
if (artifactError) {
core.warning(`quality gate facts artifact binding is unavailable: ${artifactError}`);
}
core.setOutput("pr_number", String(prNumber));
core.setOutput("head_sha", targetHeadSha);
core.setOutput("base_sha", baseSha);
core.setOutput("run_id", String(run.id));
core.setOutput("facts_artifact_name", factsArtifactName);
core.setOutput("artifact_error", artifactError);
core.setOutput("stale", "false");
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
id: checkout
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
ref: ${{ steps.pr.outputs.base_sha }}
persist-credentials: false
- name: Verify summary facts artifact metadata
id: artifact
if: ${{ steps.pr.outputs.stale != 'true' && steps.pr.outputs.facts_artifact_name != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
const artifact = artifacts[0];
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
}
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
core.setOutput("artifact_id", String(artifact.id));
core.setOutput("artifact_digest", artifact.digest);
- name: Download facts artifact zip
if: ${{ steps.pr.outputs.stale != 'true' && steps.artifact.outputs.artifact_id != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
id: download
with:
script: |
const fs = require("fs");
const path = require("path");
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
const { data } = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifactId,
archive_format: "zip",
});
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
fs.writeFileSync(zipPath, Buffer.from(data));
core.setOutput("zip_path", zipPath);
- name: Verify and extract summary facts artifact
if: ${{ steps.pr.outputs.stale != 'true' && steps.download.outputs.zip_path != '' }}
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_DECISION_OUT: decision.json
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
- name: Publish PR quality summary
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
CI_QUALITY_SUMMARY_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
CI_QUALITY_SUMMARY_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
CI_QUALITY_SUMMARY_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
CI_QUALITY_SUMMARY_RUN_ID: ${{ steps.pr.outputs.run_id }}
CI_QUALITY_SUMMARY_ARTIFACT_ERROR: ${{ steps.pr.outputs.artifact_error }}
with:
script: |
const { publish } = require("./scripts/ci-quality-summary-publish.js");
await publish({ github, context, core });
semantic-review:
needs: pr-quality-summary
if: always() && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
checks: write
contents: read
issues: write
pull-requests: write
steps:
- name: Verify workflow run and pull request
id: pr
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
if (runPRs.length > 1) {
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
let eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = eventHeadSha || run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
let factsArtifactName = "";
let artifactBaseSha = "";
let artifactError = "";
if (factsArtifacts.length !== 1) {
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
} else {
factsArtifactName = factsArtifacts[0].name;
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
}
}
if (!prNumber) {
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
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}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
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({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.head.sha !== targetHeadSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
return;
}
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR base");
core.setOutput("stale", "true");
return;
}
if (artifactError) {
core.warning(`semantic review facts artifact binding is unavailable: ${artifactError}`);
}
core.setOutput("pr_number", String(prNumber));
core.setOutput("head_sha", targetHeadSha);
core.setOutput("base_sha", baseSha);
core.setOutput("head_owner", pr.head.repo.owner.login);
core.setOutput("head_repo", pr.head.repo.name);
core.setOutput("head_repo_id", String(pr.head.repo.id));
core.setOutput("head_is_base_repo", pr.head.repo.id === context.payload.repository.id ? "true" : "false");
core.setOutput("run_id", String(run.id));
core.setOutput("facts_artifact_name", factsArtifactName);
core.setOutput("artifact_error", artifactError);
core.setOutput("stale", "false");
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
id: checkout
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
ref: ${{ steps.pr.outputs.base_sha }}
persist-credentials: false
- name: Publish pre-checkout semantic review failure
if: ${{ failure() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome != 'success' && steps.pr.outputs.head_sha != '' && steps.pr.outputs.pr_number != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
with:
script: |
const runtimeBlockMode = process.env.SEMANTIC_REVIEW_BLOCK === "true";
const pr = Number(process.env.SEMANTIC_REVIEW_PR_NUMBER || 0);
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
const baseSha = process.env.SEMANTIC_REVIEW_BASE_SHA || "";
if (!Number.isInteger(pr) || pr <= 0 || !/^[a-f0-9]{40}$/i.test(headSha) || !/^[a-f0-9]{40}$/i.test(baseSha)) {
throw new Error("missing verified semantic review target");
}
const { data: pull } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr,
});
if (pull.head.sha !== headSha) {
core.notice("semantic review skipped infrastructure failure check: PR head changed");
return;
}
if (pull.base.sha !== baseSha) {
core.notice("semantic review skipped infrastructure failure check: PR base changed");
return;
}
if (pull.base.repo.id !== context.payload.repository.id) {
throw new Error("PR base repo mismatch before infrastructure failure check");
}
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: runtimeBlockMode ? "semantic-review/result" : "semantic-review/observe",
head_sha: headSha,
status: "completed",
conclusion: runtimeBlockMode ? "failure" : "neutral",
output: {
title: "Semantic review infrastructure failure",
summary: "Semantic review could not checkout the verified base commit. Inspect the workflow logs before relying on semantic review output.",
},
});
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
go-version-file: go.mod
- name: Verify semantic facts artifact metadata
id: artifact
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
if (!/^quality-gate-facts-[a-f0-9]{40}-[a-f0-9]{40}$/i.test(factsArtifactName)) {
throw new Error("missing verified facts artifact binding");
}
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
const artifact = artifacts[0];
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
}
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
core.setOutput("artifact_id", String(artifact.id));
core.setOutput("artifact_digest", artifact.digest);
- name: Download facts artifact zip
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
id: download
with:
script: |
const fs = require("fs");
const path = require("path");
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
const { data } = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifactId,
archive_format: "zip",
});
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
fs.writeFileSync(zipPath, Buffer.from(data));
core.setOutput("zip_path", zipPath);
- name: Verify and extract semantic facts artifact
if: ${{ steps.pr.outputs.stale != 'true' }}
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_DECISION_OUT: decision.json
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
- name: Download PR semantic waiver config
id: waiver_config
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_HEAD_OWNER: ${{ steps.pr.outputs.head_owner }}
SEMANTIC_REVIEW_HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
SEMANTIC_REVIEW_HEAD_IS_BASE_REPO: ${{ steps.pr.outputs.head_is_base_repo }}
with:
script: |
const fs = require("fs");
const path = require("path");
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
if (!/^[a-f0-9]{40}$/i.test(headSha)) {
throw new Error("missing verified semantic review target");
}
const headOwner = process.env.SEMANTIC_REVIEW_HEAD_OWNER || "";
const headRepo = process.env.SEMANTIC_REVIEW_HEAD_REPO || "";
if (!headOwner || !headRepo) {
throw new Error("missing verified semantic review head repository");
}
const waiverPath = "internal/qualitygate/config/semantic/waivers.txt";
const outPath = path.join(process.env.RUNNER_TEMP, "semantic-review-waivers.txt");
const headIsBaseRepo = process.env.SEMANTIC_REVIEW_HEAD_IS_BASE_REPO === "true";
if (!headIsBaseRepo) {
core.notice("fork PR semantic waiver config is ignored");
core.setOutput("path", "");
return;
}
let content = "";
try {
const { data } = await github.rest.repos.getContent({
owner: headOwner,
repo: headRepo,
path: waiverPath,
ref: headSha,
});
if (Array.isArray(data) || data.type !== "file" || data.encoding !== "base64") {
throw new Error(`${waiverPath} is not a base64 file at PR head`);
}
if (data.size > 256 * 1024) {
throw new Error(`${waiverPath} is too large: ${data.size} bytes`);
}
content = Buffer.from(data.content, "base64").toString("utf8");
} catch (err) {
if (err.status !== 404) {
throw err;
}
}
fs.writeFileSync(outPath, content);
core.setOutput("path", outPath);
- name: Run semantic review
id: semantic
if: ${{ steps.pr.outputs.stale != 'true' }}
env:
ARK_API_KEY: ${{ secrets.ARK_API_KEY }}
ARK_BASE_URL: ${{ vars.ARK_BASE_URL }}
ARK_MODEL: ${{ vars.ARK_MODEL }}
ARK_TIMEOUT_SECONDS: ${{ vars.ARK_TIMEOUT_SECONDS }}
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
run: |
args=(
--repo .
--facts facts.json
--decision-out decision.json
--markdown-out semantic-review.md
)
if [ -n "${{ steps.waiver_config.outputs.path }}" ]; then
args+=(--waivers-file '${{ steps.waiver_config.outputs.path }}')
fi
if [ "$SEMANTIC_REVIEW_BLOCK" = "true" ]; then
args+=(--block)
fi
go run ./internal/qualitygate/cmd/semantic-review "${args[@]}"
- name: Publish semantic review
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
with:
script: |
const { publish } = require("./scripts/semantic-review-publish.js");
await publish({ github, context, core });

2
.gitignore vendored
View File

@@ -35,6 +35,8 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
.lark-slides/
/notes/
/minutes/
internal/registry/meta_data.json
cmd/api/download.bin
app.log

View File

@@ -29,11 +29,11 @@ linters:
- unused # checks for unused constants, variables, functions and types
- depguard # blocks forbidden package imports
- forbidigo # forbids specific function calls
- errorlint # enforces error wrapping (%w) and errors.Is/As over == and type asserts
# To enable later after fixing existing issues:
# - errcheck # checks for unchecked errors
# - errname # checks that error types are named XxxError
# - errorlint # checks error wrapping best practices
# - gosec # security-oriented linter
# - misspell # finds commonly misspelled English words
# - staticcheck # comprehensive static analysis
@@ -49,39 +49,49 @@ linters:
- gocritic
- depguard
- forbidigo
# Paths that run forbidigo. Add an entry when a path joins one of
# the rules below.
- errorlint # tests legitimately do identity (==) and concrete type-assert checks
# forbidigo runs repo-wide (minus the boundaries below) so errs-no-bare-wrap
# has no gap. The framework bans (os/vfs, raw HTTP, fmt.Print, filepath,
# log) stay scoped to shortcuts/ + internal/ + config/auth/service via the
# next rule; elsewhere only errs-no-bare-wrap fires.
- path-except: (shortcuts/|internal/|cmd/|events/)
linters:
- forbidigo
- path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/)
text: (vfs|IOStreams|ctx\.Out|shortcuts-no-raw-http|filepath functions|os\.Exit|structured error return)
linters:
- forbidigo
- path: internal/vfs/
linters:
- forbidigo
# internal/gen build-time generators (standalone `package main` run via
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
# impossible here: a structured error return needs os.Exit (also banned),
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
- path: shortcuts/.*/internal/gen/
linters:
- forbidigo
# internal/qualitygate/cmd contains standalone CI tools. Their main
# entrypoints legitimately own process exit codes and stdio, matching the
# old tools/ layout before these packages moved under internal/.
- path: internal/qualitygate/cmd/[^/]+/main\.go$
linters:
- forbidigo
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer.
- path-except: shortcuts/
text: shortcuts-no-raw-http
linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
# errs-no-bare-wrap enforced across every command/wire boundary by
# structural prefix, so any future business domain or command is covered
# without editing an allowlist. Genuine intermediate wraps inside these
# paths use //nolint:forbidigo with a reason.
- path-except: (cmd/|shortcuts/|events/)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
# still used by other domains until their later migration phase.
- path-except: (shortcuts/drive/)
text: errs-no-legacy-helper
linters:
- forbidigo
settings:
depguard:
@@ -100,24 +110,6 @@ linters:
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
forbidigo:
forbid:
# ── legacy output.Err* helpers banned on migrated paths ──
# output.ErrBare is intentionally not listed — it is the predicate-
# command silent-exit signal, outside the typed envelope contract.
- pattern: output\.(ErrValidation|ErrAuth|ErrNetwork|ErrAPI|ErrWithHint|Errorf)\b
msg: >-
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── legacy shared error helpers banned on drive ──
# These helpers internally produce legacy output.Err* shapes, so they
# are invisible to the errs-typed-only ban above. Drive has migrated its
# calls to typed errs.* (drive-local driveInputStatError / driveSaveError);
# this prevents reintroduction. Other domains still use the shared
# helpers (migrated globally in a later phase), so this is drive-scoped.
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
msg: >-
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
shapes. Use the typed errs.NewXxxError builders or the drive-local
driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go).
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-

View File

@@ -17,6 +17,7 @@ builds:
goarch:
- amd64
- arm64
- riscv64
archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"

View File

@@ -11,7 +11,7 @@
```bash
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make test # Full: vet + unit + integration
```
@@ -75,7 +75,31 @@ The one rule to internalize: **every error message you write will be parsed by a
### Structured errors in commands
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
Picking a constructor:
| Failure | Constructor |
|---------|-------------|
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
Signatures that are easy to guess wrong:
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }``ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
### stdout is data, stderr is everything else
@@ -117,3 +141,74 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
| Modify shortcut flags/params | Required | If behavior changes |
| Shortcut bug fix | Required | If regression risk |
| Internal refactor (no shortcut impact) | Not needed | Not needed |
## CCM Harness Skill Routing (v2)
When the user's request matches one of these patterns, invoke the corresponding `/ccm-harness:*` skill via the Skill tool. Skills include multi-step workflows, gates, and quality checks that produce more reliable results than ad-hoc answers. When in doubt, invoke the skill — a false positive is cheaper than a false negative.
### 主链路spec → idl → dev → release
| 用户表达 | Skill |
|---------|-------|
| "新需求 / 这是个新功能 / 开始一个 req / 写 spec / 起 spec / PRD 来了 / 把 PRD 转 spec / 改 spec / 微调 spec / spec 局部更新 / spec 加一个字段" | `/ccm-harness:draft-spec <req-id> [<arg2>] [--force]`Phase 0 路由器自动决定 init / generate / update capability|
| "review spec / 看 spec / 评审 spec / spec 评审" | `/ccm-harness:spec-review <req-id>` |
| "生成 thrift / 起 idl / 把 spec 转 thrift" | `/ccm-harness:draft-idl` |
| "推 thrift / 落 contract / Frozen Spec / codegen / 生成框架代码" | `/ccm-harness:codegen-idl <req-id>` |
| "实现 spec / 写后端 / 后端开发 / 实现这个功能" | `/ccm-harness:backend-dev <req-id>` |
| "前端怎么改 / 写前端 / 前端开发 / 前端编码" | `/ccm-harness:frontend-coding <req-id>` |
| "部署 BOE / 上 PPE / 部署到 feature 环境" | `/ccm-harness:deploy <req-id>` |
| "提 release / 上线 / 发布 / 走 PRE-GRAY-ONLINE" | `/ccm-harness:release <req-id>` |
| "触发打包 / build / 起 SCM 编译" | `/ccm-harness:build <repo or psm>` |
### 守护与诊断
| 用户表达 | Skill |
|---------|-------|
| "工作流到哪一步了 / 下一步咋走 / 我迷路了" | `/ccm-harness:doctor` |
| "调试 / debug / 这个 bug 怎么排" | `/ccm-harness:debug` |
| "查 CI 失败 / CI 跑挂了 / pipeline 红了" | `/ccm-harness:check-ci-failure <mr>` |
| "检查实现是否符合 spec / 蓝图对比" | `/ccm-harness:check-impl-gap` |
| "spec 跟代码飘了吗 / drift 检查" | `/ccm-harness:spec-review <req-id> --mode drift` |
| "Hub 知识跟代码一致吗" | `/ccm-harness:check-knowledge-consistency` |
| "代码 review / 看 MR / cr 一下" | `/ccm-harness:code-review <mr>` |
| "设计 review / 看技术方案" | `/ccm-harness:design-review <doc>` |
### 工程辅助
| 用户表达 | Skill |
|---------|-------|
| "通知 reviewer / 发 review 卡片" | `/ccm-harness:notify-reviewer <mr>` |
| "自动修 MR / 按 review 改" | `/ccm-harness:autofix-mr <mr>` |
| "生成测试用例 / 起 case / 筛选可执行 case / E2E 用例 / Playwright 脚本 / 自动化验收" | `/ccm-harness:test`(路由到 `ccm-e2e-check -> exec-e2e` |
| "查 idl / 看 thrift 定义" | `/ccm-harness:lookup-idl <psm>` |
| "看仓库最近改了啥 / 仓库脉搏" | `/ccm-harness:pulse` |
### 元能力Skill / Prompt 研发)
| 用户表达 | Skill |
|---------|-------|
| "起一个新 skill / 设计 skill" | `/ccm-harness:meta-draft-skill` |
| "做评测集 / 给 skill 出评测数据" | `/ccm-harness:meta-build-evalset` |
| "跑评测 / Fornax 实验" | `/ccm-harness:meta-run-eval` |
| "优化 skill / skill 反馈优化" | `/ccm-harness:meta-optimize-skill` |
### 环境与配置
| 用户表达 | Skill |
|---------|-------|
| "升级 ccm-harness / 更新插件" | `/ccm-harness:upgrade` |
| "看遥测 / 最近的反馈" | `/ccm-harness:show-telemetry` |
| "清遥测 / 重置 telemetry" | `/ccm-harness:clear-telemetry` |
| "上报问题 / 提 issue" | `/ccm-harness:report-issue` |
| "看本地教训 / project learnings / 我们踩过啥" | `/ccm-harness:learn` |
| "反馈 / 评分这次 skill" | `/ccm-harness:feedback` |
### 使用提示
- **入口**:新需求**必从** `/ccm-harness:draft-spec <req-id>` 开始Phase 0 路由器自动决定建目录 / 带 PRD 一气呵成)。
- **不跳步**spec → idl → dev → release 是流水线,不是菜单——按顺序推进,反向走需要 `/ccm-harness:draft-spec <req-id> "<change-desc>"` 局部修(路由进 update capability
- **横切**:任何阶段发现 spec 要局部改 → `/ccm-harness:draft-spec <req-id> "<change-desc>"`;想检测漂移 → `/ccm-harness:spec-review <req-id> --mode drift`CI 红 → `/ccm-harness:check-ci-failure`;迷路 → `/ccm-harness:doctor`
完整流程文档:`docs/user-guide/workflow.md`

View File

@@ -2,6 +2,229 @@
All notable changes to this project will be documented in this file.
## [v1.0.56] - 2026-06-18
### Features
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
### Bug Fixes
- **api**: Align API success envelopes (#1489)
- **base**: Reject out-of-range pagination flags (#1495)
### Refactor
- Retire legacy error envelopes and enforce typed contract (#1449)
### Documentation
- **skills**: Soften lark-doc style guidance (#1463)
### Build
- Add CI quality gate with semantic review
## [v1.0.55] - 2026-06-16
### Features
- **vc**: Support agent meeting event workflows (#1483)
- **drive**: Support exporting Base structure snapshots (#1481)
- **doc**: Add docx cover resource commands (#1468)
- **doc**: Support `lang` for docx fetch v2 (#1459)
- **event**: Optimize subscription precheck, links, and consumer guard (#1447)
### Bug Fixes
- **drive**: Validate drive import folder target (#1485)
## [v1.0.54] - 2026-06-15
### Features
- **mail**: Auto-attach default signature on send/reply/forward (#1415)
- **drive**: Support `original_creator_ids` filter in search (#1046)
- **cli**: Simplify proxy plugin warning and gate it on TTY (#1448)
### Bug Fixes
- **doc**: Fix docs fetch and update ergonomics (#1466)
- **vfs**: Reject blank local paths (#1460)
- **vfs**: Reject Windows absolute paths cross-platform (#1401)
- **event**: Clarify remote bus blocker recovery (#1454)
### Refactor
- Converge command pipelines onto a typed metadata model + catalog (#1191)
### Documentation
- **im**: Document `@mention` format per message type (text/post/card) (#1419)
- **doc**: Clarify lark-doc create title guidance (#1474)
- **skills**: Add rename prompt for import without `--name` (#1461)
- **apps**: Drop Miaoda brand word from apps command help text (#1399)
## [v1.0.53] - 2026-06-12
### Features
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
- **auth**: Add `--json` flag support to auth subcommands (#1431)
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
### Bug Fixes
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
### Documentation
- **skills**: Optimize whiteboard skill (#1371)
- **skills**: Optimize okr skill (#1368)
## [v1.0.52] - 2026-06-11
### Features
- **events**: Per-resource subscription identity + Match hook (#1185)
- **apps**: Emit typed error envelopes across the apps domain (#1288)
- **wiki**: Emit typed error envelopes across the wiki domain (#1350)
- **im**: Add `--chat-modes` filter to chat search (#1317)
- **apps**: Exclude `.git` directory from `+html-publish` package (#1396)
- **build**: Support riscv64 prebuilt binaries in release and install pipeline
### Bug Fixes
- **apps**: Support git credential dry-run (#1390)
- **whiteboard**: Fix parsing empty whiteboard content (#1391)
- **build**: Make `-race` flag arch-conditional to support riscv64
### Documentation
- **im**: Document `chat.user_setting` batch_query/batch_update (#1339)
- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294)
- **skills**: Optimize lark-drive skill routing (#1284)
- **skills**: Expand cite user guidance and fix typos (#1394)
## [v1.0.51] - 2026-06-10
### Features
- **apps**: Support multi dev modes (#1175)
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
- **base**: Configure initial base table schema (#1377)
- **vc**: Add recording event support (#1369)
- **minutes**: Replace words for transcript (#1372)
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
- **slides**: Emit typed error envelopes across the slides domain (#1349)
### Documentation
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
## [v1.0.50] - 2026-06-09
### Features
- **doc**: Emit typed error envelopes across the doc domain (#1346)
- **event**: Emit typed error envelopes across the event domain (#1289)
- **contact**: Emit typed error envelopes across the contact domain (#1287)
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
- **cli**: Adjust agent timeout hint output conditions (#1328)
### Bug Fixes
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
- **slides**: Build create URL locally instead of drive metas call (#1329)
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
### Documentation
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
- **doc**: Document `<folder-manager>` resource block (#1168)
- **drive**: Add drive comment location guidance (#1258)
## [v1.0.49] - 2026-06-08
### Features
- **events**: Add whiteboard event domain with per-board subscription (#1265)
- **im**: Support feed group (#1102)
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
- **im**: Format feed group error handling (#1308)
- **im**: Return typed error envelopes across the im domain (#1230)
- **base**: Emit typed error envelopes across the base domain (#1248)
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
- **task**: Emit typed error envelopes across the task domain (#1231)
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
- **markdown**: Harden create upload failures (#1325)
- **drive**: Harden inspect shortcut failures (#1324)
- **slides**: Add IconPark lookup for Lark slides (#1123)
- **doc**: Remove docs v1 API (#1291)
- **cli**: Add `skills` command to read embedded skill content (#1318)
- **cli**: Fetch official skills index (#1301)
- **shared**: Document relative-path-only file arguments (#1319)
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
### Bug Fixes
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
- **drive**: Use docs secure label read scope (#1281)
### Documentation
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
- **skills**: Tighten drive and markdown guardrails (#1326)
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
- **markdown**: Add markdown domain template (#1293)
- **markdown**: Improve lark-markdown skill guidance (#1279)
- **doc**: Improve lark-doc skill guidance (#1283)
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
## [v1.0.48] - 2026-06-04
### Features
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
- **contact**: Add contact skill domain guidance (#1144)
### Bug Fixes
- **skills**: Use JSON skills list during update (#1251)
### Documentation
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
- **vc-agent**: Require explicit leave request (#1260)
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
## [v1.0.47] - 2026-06-03
### Features
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
- **base**: Add base block shortcuts (#1044)
- **im**: Complete card message format (#1198)
- **im**: Improve markdown guidance for messages (#1237)
- **vc**: Forward invite call-id on meeting join (#1243)
- **drive**: Emit typed error envelopes across the drive domain (#1205)
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
- **wiki**: Support `appid` member type (#1235)
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
- **config**: Validate credentials after `config init` (#1151)
### Bug Fixes
- **skills**: Recover empty fallback for skills to update (#1233)
## [v1.0.46] - 2026-06-02
### Features
@@ -989,6 +1212,16 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[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
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44

69
CLAUDE.md Normal file
View File

@@ -0,0 +1,69 @@
## CCM Harness Skill Routing (v2)
When the user's request matches one of these patterns, invoke the corresponding `/ccm-harness:*` skill via the Skill tool. Skills include multi-step workflows, gates, and quality checks that produce more reliable results than ad-hoc answers. When in doubt, invoke the skill — a false positive is cheaper than a false negative.
### 主链路spec → idl → dev → release
| 用户表达 | Skill |
|---------|-------|
| "新需求 / 这是个新功能 / 开始一个 req / 写 spec / 起 spec / PRD 来了 / 把 PRD 转 spec / 改 spec / 微调 spec / spec 局部更新 / spec 加一个字段" | `/ccm-harness:draft-spec <req-id> [<arg2>] [--force]`Phase 0 路由器自动决定 init / generate / update capability|
| "review spec / 看 spec / 评审 spec / spec 评审" | `/ccm-harness:spec-review <req-id>` |
| "生成 thrift / 起 idl / 把 spec 转 thrift" | `/ccm-harness:draft-idl` |
| "推 thrift / 落 contract / Frozen Spec / codegen / 生成框架代码" | `/ccm-harness:codegen-idl <req-id>` |
| "实现 spec / 写后端 / 后端开发 / 实现这个功能" | `/ccm-harness:backend-dev <req-id>` |
| "前端怎么改 / 写前端 / 前端开发 / 前端编码" | `/ccm-harness:frontend-coding <req-id>` |
| "部署 BOE / 上 PPE / 部署到 feature 环境" | `/ccm-harness:deploy <req-id>` |
| "提 release / 上线 / 发布 / 走 PRE-GRAY-ONLINE" | `/ccm-harness:release <req-id>` |
| "触发打包 / build / 起 SCM 编译" | `/ccm-harness:build <repo or psm>` |
### 守护与诊断
| 用户表达 | Skill |
|---------|-------|
| "工作流到哪一步了 / 下一步咋走 / 我迷路了" | `/ccm-harness:doctor` |
| "调试 / debug / 这个 bug 怎么排" | `/ccm-harness:debug` |
| "查 CI 失败 / CI 跑挂了 / pipeline 红了" | `/ccm-harness:check-ci-failure <mr>` |
| "检查实现是否符合 spec / 蓝图对比" | `/ccm-harness:check-impl-gap` |
| "spec 跟代码飘了吗 / drift 检查" | `/ccm-harness:spec-review <req-id> --mode drift` |
| "Hub 知识跟代码一致吗" | `/ccm-harness:check-knowledge-consistency` |
| "代码 review / 看 MR / cr 一下" | `/ccm-harness:code-review <mr>` |
| "设计 review / 看技术方案" | `/ccm-harness:design-review <doc>` |
### 工程辅助
| 用户表达 | Skill |
|---------|-------|
| "通知 reviewer / 发 review 卡片" | `/ccm-harness:notify-reviewer <mr>` |
| "自动修 MR / 按 review 改" | `/ccm-harness:autofix-mr <mr>` |
| "生成测试用例 / 起 case / 筛选可执行 case / E2E 用例 / Playwright 脚本 / 自动化验收" | `/ccm-harness:test`(路由到 `ccm-e2e-check -> exec-e2e` |
| "查 idl / 看 thrift 定义" | `/ccm-harness:lookup-idl <psm>` |
| "看仓库最近改了啥 / 仓库脉搏" | `/ccm-harness:pulse` |
### 元能力Skill / Prompt 研发)
| 用户表达 | Skill |
|---------|-------|
| "起一个新 skill / 设计 skill" | `/ccm-harness:meta-draft-skill` |
| "做评测集 / 给 skill 出评测数据" | `/ccm-harness:meta-build-evalset` |
| "跑评测 / Fornax 实验" | `/ccm-harness:meta-run-eval` |
| "优化 skill / skill 反馈优化" | `/ccm-harness:meta-optimize-skill` |
### 环境与配置
| 用户表达 | Skill |
|---------|-------|
| "升级 ccm-harness / 更新插件" | `/ccm-harness:upgrade` |
| "看遥测 / 最近的反馈" | `/ccm-harness:show-telemetry` |
| "清遥测 / 重置 telemetry" | `/ccm-harness:clear-telemetry` |
| "上报问题 / 提 issue" | `/ccm-harness:report-issue` |
| "看本地教训 / project learnings / 我们踩过啥" | `/ccm-harness:learn` |
| "反馈 / 评分这次 skill" | `/ccm-harness:feedback` |
### 使用提示
- **入口**:新需求**必从** `/ccm-harness:draft-spec <req-id>` 开始Phase 0 路由器自动决定建目录 / 带 PRD 一气呵成)。
- **不跳步**spec → idl → dev → release 是流水线,不是菜单——按顺序推进,反向走需要 `/ccm-harness:draft-spec <req-id> "<change-desc>"` 局部修(路由进 update capability
- **横切**:任何阶段发现 spec 要局部改 → `/ccm-harness:draft-spec <req-id> "<change-desc>"`;想检测漂移 → `/ccm-harness:spec-review <req-id> --mode drift`CI 红 → `/ccm-harness:check-ci-failure`;迷路 → `/ccm-harness:doctor`
完整流程文档:`docs/user-guide/workflow.md`

View File

@@ -5,10 +5,24 @@ BINARY := lark-cli
MODULE := github.com/larksuite/cli
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
DATE := $(shell date +%Y-%m-%d)
NODE ?= node
QUALITY_GATE_CHANGED_FROM ?= $(shell bash scripts/resolve-changed-from.sh)
QUALITY_GATE_CHANGED_FROM_RESOLVED = $(if $(strip $(QUALITY_GATE_CHANGED_FROM)),$(QUALITY_GATE_CHANGED_FROM),$(shell bash scripts/resolve-changed-from.sh))
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
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
# The repository's Go 1.23 CI toolchain does not support -race on riscv64.
# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`)
# over `go env GOARCH`, because command-line make variables are not visible to
# $(shell ...).
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
all: test
@@ -32,9 +46,15 @@ fmt-check:
exit 1; \
fi
script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta
go test -race -gcflags="all=-N -l" -count=1 \
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
./cmd/... ./internal/... ./shortcuts/... ./extension/...
# examples-build keeps the shipped plugin-SDK examples compilable. If this
@@ -46,7 +66,30 @@ examples-build:
integration-test: build
go test -v -count=1 ./tests/...
test: vet fmt-check unit-test examples-build integration-test
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))
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
go run ./internal/qualitygate/cmd/manifest-export \
--manifest-out $(QUALITY_GATE_MANIFEST_OUT) \
--command-index-out $(QUALITY_GATE_COMMAND_INDEX_OUT)
LARKSUITE_CLI_APP_ID=dry-run \
LARKSUITE_CLI_APP_SECRET=dry-run \
LARKSUITE_CLI_BRAND=feishu \
LARKSUITE_CLI_CONFIG_DIR=$${TMPDIR:-/tmp}/quality-gate-cli-config \
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
go run ./internal/qualitygate/cmd/quality-gate check \
--repo . \
--cli-bin ./$(BINARY) \
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
--facts-out $(QUALITY_GATE_FACTS_OUT)
install: build
install -d $(PREFIX)/bin

View File

@@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
## Installation & Quick Start

View File

@@ -41,7 +41,7 @@
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
| 🔗 应用 | 创建妙搭Spark/Miaoda应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
## 安装与快速开始

View File

@@ -10,6 +10,7 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -90,6 +91,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
@@ -122,7 +124,13 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--params and --data cannot both read from stdin (-)").
WithHint("pass at most one flag as '-'; give the other inline JSON or @file").
WithParams(
errs.InvalidParam{Name: "--params", Reason: "reads from stdin (-)"},
errs.InvalidParam{Name: "--data", Reason: "reads from stdin (-)"},
)
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
@@ -152,7 +160,10 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
return client.RawApiRequest{}, nil, err
}
if _, ok := dataFields.(map[string]any); !ok {
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--data must be a JSON object when used with --file").
WithHint(`with --file, --data carries multipart form fields, e.g. --data '{"image_type":"message"}'`).
WithParam("--data")
}
}
@@ -195,7 +206,13 @@ func apiRun(opts *APIOptions) error {
}
if opts.PageAll && opts.Output != "" {
return output.ErrValidation("--output and --page-all are mutually exclusive")
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--output and --page-all are mutually exclusive").
WithHint("drop --page-all to save a binary response, or drop --output to paginate JSON").
WithParams(
errs.InvalidParam{Name: "--output", Reason: "conflicts with --page-all"},
errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"},
)
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
@@ -232,7 +249,7 @@ func apiRun(opts *APIOptions) error {
}
if opts.PageAll {
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
}
@@ -242,7 +259,7 @@ func apiRun(opts *APIOptions) error {
// pass on *output.ExitError values. Typed *errs.* errors that flow
// through here keep their canonical message / hint from BuildAPIError;
// MarkRaw is a no-op on those (it only flips a flag on *ExitError).
return output.MarkRaw(err)
return errs.MarkRaw(err)
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
@@ -262,7 +279,7 @@ func apiRun(opts *APIOptions) error {
// MarkRaw: see comment above on the DoAPI path. Skips legacy
// *ExitError enrichment; typed errors flow through unchanged.
if err != nil {
return output.MarkRaw(err)
return errs.MarkRaw(err)
}
return nil
}
@@ -271,46 +288,76 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
return output.MarkRaw(err)
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
return nil
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return output.MarkRaw(err)
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
return errs.MarkRaw(apiErr)
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
output.FormatValue(out, result, output.FormatJSON)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return output.MarkRaw(err)
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
return errs.MarkRaw(apiErr)
}
output.FormatValue(out, result, format)
return nil
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -4,6 +4,8 @@
package api
import (
"context"
"encoding/json"
"errors"
"os"
"sort"
@@ -11,6 +13,7 @@ import (
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -66,6 +69,24 @@ func TestApiCmd_DryRun(t *testing.T) {
}
}
// Regression: --params null parses to a nil map; writing page_size onto it must
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
// write into the map ParseJSONMap returns.
func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with --page-size should not error, got: %v", err)
}
if out := stdout.String(); !strings.Contains(out, "page_size") {
t.Errorf("expected page_size applied over null --params, got:\n%s", out)
}
}
func TestApiCmd_BotMode(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -83,8 +104,19 @@ func TestApiCmd_BotMode(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "success") {
t.Error("expected 'success' in output")
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
if got["ok"] != true || got["identity"] != "bot" {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if !ok || data["result"] != "success" {
t.Fatalf("data = %#v, want result=success", got["data"])
}
}
@@ -310,8 +342,16 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
t.Error("expected 'falling back to json' in stderr")
}
// Should output JSON result to stdout
if !strings.Contains(stdout.String(), "u123") {
t.Error("expected user_id in JSON output")
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok || data["user_id"] != "u123" {
t.Fatalf("unexpected fallback envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("fallback success envelope leaked outer code: %s", stdout.String())
}
}
@@ -324,7 +364,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
Body: map[string]interface{}{
"code": 230001, "msg": "no permission",
"code": 230027, "msg": "user not authorized",
},
})
@@ -336,12 +376,20 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
t.Fatal("expected an error for non-zero code")
}
// Should still output the response body so user can see the error details
if !strings.Contains(stdout.String(), "230001") {
if !strings.Contains(stdout.String(), "230027") {
t.Errorf("expected error response in stdout, got: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "no permission") {
if !strings.Contains(stdout.String(), "user not authorized") {
t.Errorf("expected error message in stdout, got: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
}
}
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
@@ -377,6 +425,274 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
}
}
func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code on later page")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier successful page to remain streamed, got: %s", out)
}
if strings.Contains(out, "230027") || strings.Contains(out, "user not authorized") {
t.Fatalf("streaming stdout should not contain raw error JSON, got: %s", out)
}
if strings.Contains(out, "\n \"code\"") {
t.Fatalf("streaming stdout should not contain indented JSON error dump, got: %s", out)
}
}
func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("data.items = %#v, want one item", data["items"])
}
}
type apiContentSafetyProvider struct {
called bool
path string
data interface{}
match string
}
func (p *apiContentSafetyProvider) Name() string { return "api-test" }
func (p *apiContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
p.called = true
p.path = req.Path
p.data = req.Data
if p.match != "" {
b, _ := json.Marshal(req.Data)
if !strings.Contains(string(b), p.match) {
return nil, nil
}
}
return &extcs.Alert{Provider: "api-test", MatchedRules: []string{"pagination"}}, nil
}
func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &apiContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan paginated output")
}
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
data, ok := provider.data.(map[string]interface{})
if !ok {
t.Fatalf("scanned data type = %T, want map", provider.data)
}
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
alert, ok := got["_content_safety_alert"].(map[string]interface{})
if !ok || alert["provider"] != "api-test" {
t.Fatalf("missing content safety alert in envelope: %#v", got)
}
}
func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &apiContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan streamed paginated output")
}
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from api-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
}
if !strings.Contains(stdout.String(), `"id":"1"`) {
t.Fatalf("expected streamed ndjson output, got: %s", stdout.String())
}
}
func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
provider := &apiContentSafetyProvider{match: "blocked"}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
"has_more": false,
},
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
t.Fatal("expected content safety block error")
}
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
}
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
}
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "pagination" {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
}
}
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, code int) {
t.Helper()
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype || p.Code != code {
t.Fatalf("problem = %s/%s/%d, want %s/%s/%d", p.Category, p.Subtype, p.Code, category, subtype, code)
}
}
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
for _, tt := range []struct {
name string
@@ -718,3 +1034,23 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
}
}
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("--json should be accepted without error, got: %v", err)
}
if gotOpts.Method != "GET" {
t.Errorf("expected method GET, got %s", gotOpts.Method)
}
}

View File

@@ -91,6 +91,29 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *CheckOptions
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -109,6 +132,27 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *LogoutOptions
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthListCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -126,6 +170,27 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ListOptions
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -145,6 +210,29 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *StatusOptions
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -267,6 +355,32 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ScopesOptions
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--format", "pretty", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
if gotOpts.Format != "json" {
t.Errorf("expected format json, got %s", gotOpts.Format)
}
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,

View File

@@ -19,6 +19,7 @@ import (
type CheckOptions struct {
Factory *cmdutil.Factory
Scope string
JSON bool
}
// NewCmdAuthCheck creates the auth check subcommand.
@@ -37,6 +38,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
}
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.MarkFlagRequired("scope")
cmdutil.SetRisk(cmd, "read")

View File

@@ -33,12 +33,9 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
if got := output.ExitCodeOf(err); got != 1 {
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
}
var bare *output.ExitError
var bare *output.BareError
if !errors.As(err, &bare) {
t.Fatalf("expected *output.ExitError (ErrBare), got %T: %v", err, err)
}
if bare.Detail != nil {
t.Errorf("ErrBare must carry no Detail (no envelope), got %+v", bare.Detail)
t.Fatalf("expected *output.BareError (ErrBare), got %T: %v", err, err)
}
if stderr.Len() != 0 {

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -18,6 +19,7 @@ import (
// ListOptions holds all inputs for auth list.
type ListOptions struct {
Factory *cmdutil.Factory
JSON bool
}
// NewCmdAuthList creates the auth list subcommand.
@@ -34,6 +36,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
return authListRun(opts)
},
}
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd
@@ -44,12 +47,20 @@ func authListRun(opts *ListOptions) error {
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_configured",
})
return nil
}
// auth list is a read-only probe; the "configured but no users"
// branch below already returns exit 0 with a stderr hint, so we
// keep the same contract here. We still want the hint to be
// workspace-aware, so we pull the message+hint out of
// NotConfiguredError() instead of hard-coding it.
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
if cfgErr.Hint != "" {
@@ -61,6 +72,14 @@ func authListRun(opts *ListOptions) error {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
return nil
}

View File

@@ -4,6 +4,7 @@
package auth
import (
"encoding/json"
"strings"
"testing"
@@ -34,6 +35,33 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
}
}
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
// reason this hint exists workspace-aware in the first place: an AI agent
// in OpenClaw / Hermes that probes auth list before binding gets routed to
@@ -57,3 +85,48 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
t.Errorf("agent hint must not mention config init: %s", out)
}
}
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "No logged-in users") {
t.Errorf("stderr = %q, want no-users hint", stderr.String())
}
}

View File

@@ -296,10 +296,11 @@ func authLoginRun(opts *LoginOptions) error {
}
// Step 2: Show user code and verification URL.
// Both branches surface AgentTimeoutHint, but on different channels:
// JSON mode embeds it as a structured field (so an agent that captures
// stdout into a JSON parser sees it without stream-mixing surprises),
// text mode prints to stderr (alongside the URL prompt).
// JSON mode embeds AgentTimeoutHint as a structured field so agents that
// capture stdout into a JSON parser see it without stream-mixing surprises.
// Text mode prints the hint to stderr only when running under a non-TTY
// (i.e. piped / agent harness), since humans reading a terminal don't need
// the agent-oriented instructions.
if opts.JSON {
data := map[string]interface{}{
"event": "device_authorization",
@@ -317,7 +318,9 @@ func authLoginRun(opts *LoginOptions) error {
} else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
}
// Step 3: Poll for token
@@ -404,10 +407,11 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
}
}
// Skip the stderr hint in JSON mode the --no-wait call that issued the
// device_code already returned the hint as a JSON field, and writing
// text to stderr would pollute consumers that combine streams via 2>&1.
if !opts.JSON {
// Skip the stderr hint in JSON mode (the --no-wait call that issued
// the device_code already surfaced it as a JSON field), and also skip it
// when running on an interactive terminal — the agent-oriented
// instructions only matter for piped / harness environments.
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
log(msg.WaitingAuth)

View File

@@ -92,16 +92,11 @@ func buildDomainMeta(name, lang string) domainMeta {
Description: desc,
}
}
// Fallback: read from from_meta spec (legacy)
meta := registry.LoadFromMeta(name)
// Fallback: read from the typed service spec (legacy)
dm := domainMeta{Name: name}
if meta != nil {
if t, ok := meta["title"].(string); ok {
dm.Title = t
}
if d, ok := meta["description"].(string); ok {
dm.Description = d
}
if svc, ok := registry.ServiceTyped(name); ok {
dm.Title = svc.Title
dm.Description = svc.Description
}
return dm
}

View File

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

View File

@@ -9,6 +9,7 @@ import (
"errors"
"io"
"net/http"
"slices"
"sort"
"strings"
"testing"
@@ -214,6 +215,12 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
}
}
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
}
}
func TestCollectScopesForDomains(t *testing.T) {
projects := registry.ListFromMetaProjects()
if len(projects) == 0 {
@@ -871,7 +878,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
// contract that when --json is set and pollDeviceToken returns OK=false,
// stdout carries the structured authorization_failed event and stderr is
// NOT polluted with a typed envelope. The returned error is a bare
// ExitError with ExitAuth so the dispatcher only propagates the exit code
// BareError with ExitAuth so the dispatcher only propagates the exit code
// without emitting a second envelope on top of the JSON event.
func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
keyring.MockInit()
@@ -938,16 +945,13 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr)
}
// Returned error must be the bare *output.ExitError signal (no envelope).
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
// Returned error must be the bare *output.BareError signal (no envelope).
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAuth {
t.Fatalf("ExitError.Code = %d, want %d", exitErr.Code, output.ExitAuth)
}
if exitErr.Detail != nil {
t.Errorf("ExitError.Detail should be nil for bare signal, got: %+v", exitErr.Detail)
if bareErr.Code != output.ExitAuth {
t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth)
}
}

View File

@@ -18,6 +18,7 @@ import (
// LogoutOptions holds all inputs for auth logout.
type LogoutOptions struct {
Factory *cmdutil.Factory
JSON bool
}
// NewCmdAuthLogout creates the auth logout subcommand.
@@ -34,6 +35,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
return authLogoutRun(opts)
},
}
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "write")
return cmd
@@ -44,25 +46,65 @@ func authLogoutRun(opts *LogoutOptions) error {
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_configured",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
return nil
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
return nil
}
httpClient, httpErr := f.HttpClient()
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
for _, user := range app.Users {
if httpErr == nil && secretErr == nil {
if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil {
revokeToken := token.RefreshToken
tokenTypeHint := "refresh_token"
if revokeToken == "" {
revokeToken = token.AccessToken
tokenTypeHint = "access_token"
}
if revokeToken != "" {
_ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint)
}
}
}
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
}
}
app.Users = []core.AppUser{}
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": true,
})
return nil
}
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
return nil
}

356
cmd/auth/logout_test.go Normal file
View File

@@ -0,0 +1,356 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"net/url"
"strings"
"testing"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/zalando/go-keyring"
)
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
t.Helper()
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "test-app",
Apps: []core.AppConfig{
{
AppId: "test-app",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
Users: users,
},
},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != true {
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
}
if _, hasReason := payload["reason"]; hasReason {
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "Logged out") {
t.Errorf("stderr = %q, want success text", stderr.String())
}
}
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-refresh-token" &&
values.Get("token_type_hint") == "refresh_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-access-token" &&
values.Get("token_type_hint") == "access_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Status: 500,
Body: map[string]interface{}{"error": "server_error"},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
gotErr := stderr.String()
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
}
if !strings.Contains(gotErr, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", gotErr)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}

View File

@@ -19,6 +19,7 @@ type ScopesOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
Format string
JSON bool
}
// NewCmdAuthScopes creates the auth scopes subcommand.
@@ -30,6 +31,9 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
Short: "Query scopes enabled for the app",
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
if opts.JSON {
opts.Format = "json"
}
if runF != nil {
return runF(opts)
}
@@ -38,6 +42,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd

View File

@@ -17,6 +17,7 @@ import (
type StatusOptions struct {
Factory *cmdutil.Factory
Verify bool
JSON bool
}
// NewCmdAuthStatus creates the auth status subcommand.
@@ -35,6 +36,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
}
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd

View File

@@ -6,6 +6,7 @@ package cmd
import (
"context"
"io"
"io/fs"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
@@ -16,8 +17,10 @@ import (
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -31,9 +34,13 @@ import (
type BuildOption func(*buildConfig)
type buildConfig struct {
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
skipPlugins bool
skipStrictMode bool
skipService bool
serviceCatalog *apicatalog.Catalog
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
@@ -51,6 +58,18 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
}
}
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
// at build time. It is registered by the repo-root package main's init via
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
// breaking the single-file preview build (see skills_embed.go). nil in builds
// that embed no skills; the `skills` commands then return a typed internal error.
var embeddedSkillContent fs.FS
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
// repo-root package main's init; a wrapper main can call it before Execute to
// supply its own skill content.
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
// HideProfile sets the visibility policy for the root-level --profile flag.
// When hide is true the flag stays registered (so existing invocations still
// parse) but is omitted from help and shell completion. Typically called as
@@ -61,6 +80,41 @@ func HideProfile(hide bool) BuildOption {
}
}
// WithoutPlugins builds only repository-owned commands. It is intended for
// inspection tools that need a deterministic command tree.
func WithoutPlugins() BuildOption {
return func(c *buildConfig) {
c.skipPlugins = true
}
}
// WithoutStrictMode builds the complete repository-owned command tree without
// applying user/profile strict-mode pruning. It is intended for offline
// inspection tools, not production execution.
func WithoutStrictMode() BuildOption {
return func(c *buildConfig) {
c.skipStrictMode = true
}
}
// WithoutServiceCommands builds only hand-authored commands. It is intended for
// repository quality gates that should not depend on the remote OpenAPI
// metadata command surface.
func WithoutServiceCommands() BuildOption {
return func(c *buildConfig) {
c.skipService = true
}
}
// WithServiceCatalog builds generated service commands from a specific metadata
// catalog. It is intended for offline inspection tools that need deterministic
// embedded metadata while production execution keeps using the runtime catalog.
func WithServiceCatalog(catalog apicatalog.Catalog) BuildOption {
return func(c *buildConfig) {
c.serviceCatalog = &catalog
}
}
// Build constructs the full command tree. It also installs registered
// plugins and emits the Startup lifecycle event during assembly --
// so Plugin.On(Startup) handlers run even if the returned command is
@@ -103,6 +157,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
f.SkillContent = embeddedSkillContent
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
@@ -117,6 +172,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
// inherited by every subcommand, turning unknown-flag errors into a
// structured "did you mean" envelope.
rootCmd.SilenceUsage = true
rootCmd.SetFlagErrorFunc(flagDidYouMean)
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
@@ -133,15 +195,27 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
rootCmd.AddCommand(skill.NewCmdSkill(f))
if !cfg.skipService {
if cfg.serviceCatalog != nil {
service.RegisterServiceCommandsFromCatalog(ctx, rootCmd, f, *cfg.serviceCatalog)
} else {
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
}
}
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
installUnknownSubcommandGuard(rootCmd)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
pruneForStrictMode(rootCmd, mode)
}
if cfg.skipPlugins {
recordInventory(nil)
return f, rootCmd, nil
}
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
if installErr != nil {
installPluginInstallErrorGuard(rootCmd, installErr)

46
cmd/build_test.go Normal file
View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
func TestBuildWithoutPluginsStillBuildsBuiltinCommands(t *testing.T) {
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
if root == nil {
t.Fatal("Build returned nil root")
}
if findCommand(root, "api") == nil {
t.Fatal("builtin api command missing")
}
if findCommand(root, "docs +fetch") == nil {
t.Fatal("builtin docs +fetch shortcut missing")
}
}
func findCommand(root *cobra.Command, path string) *cobra.Command {
parts := strings.Fields(path)
cmd := root
for _, part := range parts {
var next *cobra.Command
for _, child := range cmd.Commands() {
if child.Name() == part {
next = child
break
}
}
if next == nil {
return nil
}
cmd = next
}
return cmd
}

View File

@@ -0,0 +1,160 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"sort"
"strings"
)
// universalFlags are accepted by every command (cobra auto-injects help; the
// root injects version). They are never reported as unknown.
var universalFlags = map[string]bool{"--help": true, "-h": true, "--version": true}
// catalog is the source-of-truth command catalog: command path -> accepted flag
// tokens. A path is the command words WITHOUT the "lark-cli" root prefix, e.g.
// "contact +search-user". The root command is the empty path "".
type catalog struct {
flagsByPath map[string]map[string]bool
group map[string]bool // paths that are parent groups (have subcommands)
sorted []string // cached sorted paths for suggestCommand; invalidated on addCommand
}
func newCatalog() *catalog {
return &catalog{
flagsByPath: map[string]map[string]bool{},
group: map[string]bool{},
}
}
// setGroup records whether path is a parent group (has subcommands). Leftover
// words after a group node are unknown subcommands; after a leaf they are
// positionals (e.g. "api GET /path").
func (c *catalog) setGroup(path string, isGroup bool) {
if isGroup {
c.group[path] = true
}
}
func (c *catalog) isGroup(path string) bool { return c.group[path] }
// addCommand registers a command path and the flags it accepts. Repeated calls
// for the same path union the flag sets. flags are full tokens ("--query", "-q").
func (c *catalog) addCommand(path string, flags []string) {
set := c.flagsByPath[path]
if set == nil {
set = map[string]bool{}
c.flagsByPath[path] = set
}
for _, f := range flags {
set[f] = true
}
c.sorted = nil // invalidate cached suggestion list
}
func (c *catalog) hasCommand(path string) bool {
_, ok := c.flagsByPath[path]
return ok
}
// hasFlag reports whether flag is accepted by command path (universal flags
// always pass).
func (c *catalog) hasFlag(path, flag string) bool {
if universalFlags[flag] {
return true
}
set := c.flagsByPath[path]
return set[flag]
}
// longestPrefix returns the longest known command path that is a prefix of
// words, plus how many words it consumed. This separates real subcommands from
// trailing positionals (e.g. "api GET /path" resolves to "api"). When words is
// empty it falls back to the root command. ok=false means not even the first
// word names a command.
func (c *catalog) longestPrefix(words []string) (path string, n int, ok bool) {
if len(words) == 0 {
if c.hasCommand("") {
return "", 0, true
}
return "", 0, false
}
for i := len(words); i >= 1; i-- {
cand := strings.Join(words[:i], " ")
if c.hasCommand(cand) {
return cand, i, true
}
}
return "", 0, false
}
// paths returns all known command paths, sorted.
func (c *catalog) paths() []string {
out := make([]string, 0, len(c.flagsByPath))
for p := range c.flagsByPath {
out = append(out, p)
}
sort.Strings(out)
return out
}
// suggestCommand returns the known command path closest to want (small edit
// distance), for error hints. Returns "" when nothing is reasonably close.
func (c *catalog) suggestCommand(want string) string {
if c.sorted == nil {
c.sorted = c.paths() // built once after the catalog is fully populated
}
return closest(want, c.sorted)
}
// suggestFlag returns the flag of path closest to flag, for error hints.
func (c *catalog) suggestFlag(path, flag string) string {
set := c.flagsByPath[path]
cands := make([]string, 0, len(set))
for f := range set {
cands = append(cands, f)
}
sort.Strings(cands)
return closest(flag, cands)
}
// closest returns the candidate with the smallest Levenshtein distance to want,
// but only if that distance is within a tolerance scaled to want's length
// (avoids absurd suggestions).
func closest(want string, cands []string) string {
best := ""
bestD := 1 << 30
for _, cand := range cands {
d := levenshtein(want, cand)
if d < bestD {
bestD, best = d, cand
}
}
tol := len(want)/2 + 1
if bestD > tol {
return ""
}
return best
}
func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
prev := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
cur := make([]int, len(rb)+1)
cur[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
}
prev = cur
}
return prev[len(rb)]
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import "strings"
// Finding kinds.
const (
unknownCommand = "unknown_command"
unknownFlag = "unknown_flag"
)
// finding is a single mismatch between an example command reference and the
// catalog.
type finding struct {
line int
raw string
kind string // unknownCommand | unknownFlag
path string // resolved command path (unknownFlag) or attempted path (unknownCommand)
flag string // offending flag (unknownFlag only)
suggest string // nearest known command/flag, "" if none close
}
// checkRefs validates refs against cat and returns all mismatches in order.
func checkRefs(cat *catalog, refs []ref) []finding {
var out []finding
for _, r := range refs {
path, n, ok := cat.longestPrefix(r.words)
if !ok {
attempted := strings.Join(r.words, " ")
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownCommand,
path: attempted, suggest: cat.suggestCommand(attempted),
})
continue
}
// Leftover words after a group node are an unknown subcommand (e.g. a
// mistyped method like "batch_modify_message"). After a leaf they are
// positionals (e.g. "api GET /path"), so only groups trigger this.
if n < len(r.words) && cat.isGroup(path) {
attempted := strings.Join(r.words, " ")
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownCommand,
path: attempted, suggest: cat.suggestCommand(attempted),
})
continue
}
for _, f := range r.flags {
if cat.hasFlag(path, f) {
continue
}
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownFlag,
path: path, flag: f, suggest: cat.suggestFlag(path, f),
})
}
}
return out
}

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"regexp"
"strings"
)
// ref is one lark-cli command reference extracted from a shortcut example.
type ref struct {
line int // 1-based line number (the line where the command starts)
raw string // reconstructed command text, for error display
words []string // command words before the first flag (subcommand candidates)
flags []string // flag tokens used, e.g. "--query", "-q"
}
const cliToken = "lark-cli"
// subcommandStart guards against false positives from prose: a real command's
// first word is ASCII (a service name or a +shortcut). A token starting with
// CJK / punctuation is treated as narration, not a command.
var subcommandStart = regexp.MustCompile(`^[A-Za-z+]`)
// shellStops are standalone tokens that terminate a command (pipes, redirects,
// separators). Separators glued to a token (`get;`, `foo|`) are handled inline.
var shellStops = map[string]bool{
"|": true, "||": true, "&&": true, "&": true, ";": true,
">": true, ">>": true, "<": true, "2>": true, "2>&1": true,
}
// wordTrailPunct is sentence / CJK punctuation that can cling to a command word
// in prose ("auth login." / "auth login"); stripped so the word still resolves
// instead of being dropped as an unknown command or non-ASCII narration.
const wordTrailPunct = `.,;:!?"')]},。、;:!?)】」』`
// parseRefs extracts every lark-cli command reference from text (a shortcut's
// Tips line, which may embed an "Example: lark-cli ..." command). It is
// deliberately format-agnostic: it keys on the "lark-cli" token whether it sits
// in a ```bash fence, an inline `code` span, or bare prose. Backslash
// line-continuations are joined first so a multi-line invocation is parsed as
// one command; inline-code backticks and trailing # comments terminate it.
func parseRefs(content string) []ref {
var refs []ref
lines := strings.Split(content, "\n")
for i := 0; i < len(lines); i++ {
lineNo := i + 1
logical := lines[i]
// Shell line continuation: a trailing backslash joins the next physical
// line. Without this, flags on the continuation lines of a multi-line
// `lark-cli ... \` example are never seen by the checker.
for endsWithBackslash(logical) && i+1 < len(lines) {
logical = strings.TrimRight(logical, " \t")
logical = logical[:len(logical)-1] // drop the trailing backslash
i++
logical += " " + lines[i]
}
refs = append(refs, parseLine(logical, lineNo)...)
}
return refs
}
func endsWithBackslash(s string) bool {
return strings.HasSuffix(strings.TrimRight(s, " \t"), `\`)
}
func parseLine(line string, lineNo int) []ref {
var refs []ref
rest := line
for {
idx := strings.Index(rest, cliToken)
if idx < 0 {
break
}
after := rest[idx+len(cliToken):]
beforeOK := idx == 0 || isBoundary(rest[idx-1])
afterOK := after == "" || isBoundary(after[0])
if beforeOK && afterOK {
if words, flags, raw, ok := parseCmd(after); ok {
refs = append(refs, ref{line: lineNo, raw: cliToken + raw, words: words, flags: flags})
}
}
rest = after
}
return refs
}
// parseCmd tokenizes the text following "lark-cli" into leading command words
// (the subcommand path, up to the first flag) and flag tokens. It stops at a
// shell separator (standalone or glued), an inline-code backtick, a comment, or
// a placeholder/prose word. ok=false filters out non-commands.
func parseCmd(after string) (words, flags []string, raw string, ok bool) {
// An inline code span ends at the next backtick; a command never spans one.
if i := strings.IndexByte(after, '`'); i >= 0 {
after = after[:i]
}
// Drop $(...) command substitutions so flags belonging to the inner command
// (e.g. `--data "$(jq -n --arg x ...)"`) are not mistaken for lark-cli flags.
after = stripCmdSubst(after)
var kept []string
inFlags := false
for _, orig := range strings.Fields(after) {
tok := orig
if shellStops[tok] || strings.HasPrefix(tok, "#") {
break
}
// A shell separator glued to a token ends the command mid-token
// ("get;", "foo|next"): keep the part before it, handle it, then stop.
stop := false
if i := strings.IndexAny(tok, ";|"); i >= 0 {
tok, stop = tok[:i], true
}
switch {
case tok == "" || tok == "-":
// empty (after a glued separator) or a bare stdin marker — skip
case strings.HasPrefix(tok, "-"):
if f := normalizeFlag(tok); f != "" {
inFlags = true
flags = append(flags, f)
kept = append(kept, tok)
}
case inFlags:
// positional / flag value after the first flag — not a command word
kept = append(kept, tok)
default:
// Command-path word. ASCII placeholder markers (<x>, [x], {x|y},
// +<verb>, ...) end the command — checked on the RAW token so the
// trailing-punct stripping below cannot erase a "..." ellipsis
// ("base +..." must stay a placeholder, not become "+").
if strings.ContainsAny(tok, "<>[]{}|") || strings.Contains(tok, "...") {
stop = true
break
}
// Strip trailing sentence/CJK punctuation so "login." / "login"
// resolve to "login"; non-ASCII narration ends the command.
w := strings.TrimRight(tok, wordTrailPunct)
if w == "" || hasNonASCII(w) {
stop = true
break
}
words = append(words, w)
kept = append(kept, tok)
}
if stop {
break
}
}
if len(kept) > 0 {
raw = " " + strings.Join(kept, " ")
}
// Keep root-only refs ("lark-cli --help") and refs whose first word looks
// like a subcommand; drop prose ("lark-cli 就能搞定 ...").
if len(words) == 0 {
return words, flags, raw, len(flags) > 0
}
if !subcommandStart.MatchString(words[0]) {
return nil, nil, "", false
}
return words, flags, raw, true
}
// stripCmdSubst removes $(...) command substitutions (including nested ones)
// from s, leaving the surrounding text intact. Backtick substitutions are
// already handled upstream (a command never spans a backtick).
func stripCmdSubst(s string) string {
var b strings.Builder
depth := 0
for i := 0; i < len(s); i++ {
if depth == 0 && i+1 < len(s) && s[i] == '$' && s[i+1] == '(' {
depth = 1
i++ // skip '('
continue
}
if depth > 0 {
switch s[i] {
case '(':
depth++
case ')':
depth--
}
continue
}
b.WriteByte(s[i])
}
return b.String()
}
// isPlaceholderOrProse reports whether a command word is a doc placeholder
// (<resource>, [flags], {a|b}, +<verb>, ...) or narration (CJK / other
// non-ASCII), rather than a literal command token.
func isPlaceholderOrProse(w string) bool {
if hasNonASCII(w) {
return true
}
return strings.ContainsAny(w, "<>[]{}|") || strings.Contains(w, "...")
}
func hasNonASCII(s string) bool {
return strings.IndexFunc(s, func(r rune) bool { return r > 127 }) >= 0
}
// flagShape matches the leading flag token, stripping any trailing junk such as
// a "=value" suffix or punctuation that bled in from the surrounding markdown
// ("--help\"", "--help;", "--params={}"). The underscore is allowed because
// real flags use it ("--input_format", "--output_as"). Returns "" for non-flags.
var flagShape = regexp.MustCompile(`^--?[A-Za-z][A-Za-z0-9_-]*`)
// normalizeFlag extracts the canonical flag token from tok, or "" if tok is not
// a real flag (e.g. a shell-string fragment like "-草稿'").
func normalizeFlag(tok string) string {
return flagShape.FindString(tok)
}
func isBoundary(b byte) bool {
switch b {
case ' ', '\t', '`', '(', ')', '\'', '"', '*':
return true
}
return false
}

113
cmd/cmdexample_test.go Normal file
View File

@@ -0,0 +1,113 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// This file and its cmdexample_*_test.go siblings implement a test-only check:
// the example commands embedded in shortcut definitions (the "Example: lark-cli
// ..." lines in each shortcut's Tips, shown in --help) must match the real
// command tree. It lives entirely in _test.go files (package cmd_test) so it
// ships in no binary and is not importable by product code; the truth source is
// cmd.Build, the same tree the binary uses, so the check cannot drift.
//
// It runs in the standard unit-test CI job (go test ./cmd/...). A mismatch — an
// example using a renamed command or an unaccepted flag — fails that job.
package cmd_test
import (
"context"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// TestShortcutExampleCommands checks the example commands embedded in every
// shortcut's Tips against the live command tree. A shortcut that defines no
// example is simply skipped.
//
// Because the examples and the command definitions live in the same Go code,
// this is a self-consistency check: any mismatch (an example using a renamed
// command or a flag the command doesn't accept) is a bug to fix at the source.
// It runs over all shortcuts — no baseline, no diff — since a wrong example is
// always a defect, never acceptable "pre-existing drift".
func TestShortcutExampleCommands(t *testing.T) {
// Reproducibility: use the embedded API metadata (not a developer's stale
// ~/.lark-cli remote cache, which can miss commands) and an empty config
// dir so local strict mode / plugins / policy cannot reshape the tree.
// t.Setenv auto-restores after the test, so other cmd tests are unaffected.
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cat := buildCmdExampleCatalog()
type located struct {
shortcut string
f finding
}
var findings []located
for _, sc := range shortcuts.AllShortcuts() {
var refs []ref
for _, tip := range sc.Tips {
refs = append(refs, parseRefs(tip)...)
}
label := strings.TrimSpace(sc.Service + " " + sc.Command)
for _, f := range checkRefs(cat, refs) {
findings = append(findings, located{shortcut: label, f: f})
}
}
if len(findings) == 0 {
return
}
sort.Slice(findings, func(i, j int) bool { return findings[i].shortcut < findings[j].shortcut })
for _, lf := range findings {
hint := ""
if lf.f.suggest != "" {
hint = " (did you mean " + lf.f.suggest + "?)"
}
if lf.f.kind == unknownFlag {
t.Errorf("shortcut %q example uses unknown flag %s on %q%s\n %s",
lf.shortcut, lf.f.flag, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
} else {
t.Errorf("shortcut %q example uses unknown command %q%s\n %s",
lf.shortcut, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
}
}
t.Fatalf("%d shortcut example command(s) don't match the real CLI — "+
"fix the Example in the shortcut definition.", len(findings))
}
// buildCmdExampleCatalog walks the live cobra command tree and records every
// command path (minus the "lark-cli" root prefix) with its accepted flags and
// whether it is a parent group. This is the same Build() the binary uses, so
// the catalog can never drift from the real commands.
func buildCmdExampleCatalog() *catalog {
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
cat := newCatalog()
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
var flags []string
add := func(fl *pflag.Flag) {
flags = append(flags, "--"+fl.Name)
if fl.Shorthand != "" {
flags = append(flags, "-"+fl.Shorthand)
}
}
c.Flags().VisitAll(add)
c.InheritedFlags().VisitAll(add)
c.PersistentFlags().VisitAll(add) // root's own persistent flags (e.g. --profile)
cat.addCommand(path, flags)
cat.setGroup(path, c.HasSubCommands())
for _, sub := range c.Commands() {
walk(sub)
}
}
walk(root)
return cat
}

View File

@@ -0,0 +1,233 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"strings"
"testing"
)
func testCatalog() *catalog {
c := newCatalog()
c.addCommand("", []string{"--profile"}) // root
c.setGroup("", true)
c.addCommand("contact", []string{"--profile"})
c.setGroup("contact", true)
c.addCommand("contact +search-user", []string{"--query", "--as", "--format", "-q"})
c.addCommand("api", []string{"--params", "--data", "--as"}) // leaf (no subcommands)
c.addCommand("mail", nil)
c.setGroup("mail", true)
c.addCommand("mail user_mailbox.messages", []string{"--profile"})
c.setGroup("mail user_mailbox.messages", true)
c.addCommand("mail user_mailbox.messages batch_modify", []string{"--params", "--data"})
return c
}
func TestCmdExampleCatalogHasCommandAndFlag(t *testing.T) {
c := testCatalog()
if !c.hasCommand("contact +search-user") {
t.Fatal("expected contact +search-user to exist")
}
if c.hasCommand("contact +nope") {
t.Fatal("did not expect contact +nope")
}
if !c.hasFlag("contact +search-user", "--query") {
t.Fatal("--query should be valid")
}
if c.hasFlag("contact +search-user", "--nope") {
t.Fatal("--nope should be invalid")
}
// universal flags pass on any command
for _, f := range []string{"--help", "-h", "--version"} {
if !c.hasFlag("contact +search-user", f) {
t.Fatalf("universal flag %s should pass", f)
}
}
}
func TestCmdExampleLongestPrefix(t *testing.T) {
c := testCatalog()
tests := []struct {
words []string
want string
wantN int
wantOK bool
}{
{[]string{"contact", "+search-user"}, "contact +search-user", 2, true},
{[]string{"api", "GET", "/open-apis/x"}, "api", 1, true}, // trailing positionals
{[]string{"nope"}, "", 0, false},
{nil, "", 0, true}, // empty -> root
}
for _, tt := range tests {
got, n, ok := c.longestPrefix(tt.words)
if got != tt.want || n != tt.wantN || ok != tt.wantOK {
t.Errorf("longestPrefix(%v) = (%q,%d,%v), want (%q,%d,%v)",
tt.words, got, n, ok, tt.want, tt.wantN, tt.wantOK)
}
}
}
func refWordsOf(refs []ref) [][]string {
var out [][]string
for _, r := range refs {
out = append(out, r.words)
}
return out
}
func TestCmdExampleParseRefsExtractsCommands(t *testing.T) {
content := strings.Join([]string{
"运行 `lark-cli contact +search-user --query 张三` 搜索", // inline code
"```bash",
"lark-cli api GET /open-apis/x --params '{}'", // bash block
"```",
"用 lark-cli mail user_mailbox.messages batch_modify 即可", // bare prose command
"npx foo | lark-cli api GET /y", // after a pipe
}, "\n")
refs := parseRefs(content)
if len(refs) != 4 {
t.Fatalf("expected 4 refs, got %d: %v", len(refs), refWordsOf(refs))
}
if got := refs[0]; strings.Join(got.words, " ") != "contact +search-user" ||
len(got.flags) != 1 || got.flags[0] != "--query" {
t.Errorf("ref0 = %+v", got)
}
if got := refs[1]; strings.Join(got.words, " ") != "api GET /open-apis/x" {
t.Errorf("ref1 words = %v", got.words)
}
}
func TestCmdExampleParseRefsFiltersPlaceholdersAndProse(t *testing.T) {
// A line whose first word is prose yields no command at all.
if refs := parseRefs("lark-cli 就能搞定这件事"); len(refs) != 0 {
t.Errorf("prose-first line should yield 0 refs, got %v", refWordsOf(refs))
}
// Syntax templates / trailing prose may leave a real leading word ("mail"),
// but no placeholder or CJK token may leak into the command words — that is
// what prevents false positives like an "<resource>" unknown-command report.
for _, line := range []string{
"lark-cli mail <resource> <method> [flags]",
"lark-cli apps +<verb> [flags]",
"lark-cli base +...",
"lark-cli mail 写信场景下的格式说明",
} {
for _, r := range parseRefs(line) {
for _, w := range r.words {
if isPlaceholderOrProse(w) {
t.Errorf("%q: placeholder/prose token %q leaked into words %v", line, w, r.words)
}
}
}
}
}
func TestCmdExampleParseRefsStripsTrailingJunk(t *testing.T) {
// frontmatter-style quoted value: the trailing quote must not bleed into the flag
refs := parseRefs(`cliHelp: "lark-cli contact --help"`)
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
if len(refs[0].flags) != 1 || refs[0].flags[0] != "--help" {
t.Errorf("expected flag --help, got %v", refs[0].flags)
}
// bare "-" (stdin marker) and "=value" suffix
refs = parseRefs("lark-cli api GET /x --params={} --data -")
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
flags := strings.Join(refs[0].flags, " ")
if flags != "--params --data" {
t.Errorf("expected '--params --data', got %q", flags)
}
}
func TestCmdExampleCheck(t *testing.T) {
c := testCatalog()
tests := []struct {
name string
r ref
wantKind string // "" = no finding
wantPath string
}{
{"valid shortcut", ref{words: []string{"contact", "+search-user"}, flags: []string{"--query"}}, "", ""},
{"valid leaf positional", ref{words: []string{"api", "GET", "/x"}}, "", ""},
{"unknown top command", ref{words: []string{"nope"}}, unknownCommand, "nope"},
{"group leftover = unknown subcommand",
ref{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}},
unknownCommand, "mail user_mailbox.messages batch_modify_message"},
{"unknown flag", ref{words: []string{"contact", "+search-user"}, flags: []string{"--nope"}}, unknownFlag, "contact +search-user"},
{"universal flag ok", ref{words: []string{"contact", "+search-user"}, flags: []string{"--help"}}, "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := checkRefs(c, []ref{tt.r})
if tt.wantKind == "" {
if len(fs) != 0 {
t.Fatalf("expected no finding, got %+v", fs)
}
return
}
if len(fs) != 1 {
t.Fatalf("expected 1 finding, got %d: %+v", len(fs), fs)
}
if fs[0].kind != tt.wantKind || fs[0].path != tt.wantPath {
t.Errorf("got kind=%s path=%q, want kind=%s path=%q", fs[0].kind, fs[0].path, tt.wantKind, tt.wantPath)
}
})
}
}
func TestCmdExampleCheckSuggestsNearest(t *testing.T) {
c := testCatalog()
fs := checkRefs(c, []ref{{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}}})
if len(fs) != 1 || fs[0].suggest != "mail user_mailbox.messages batch_modify" {
t.Fatalf("expected suggestion 'mail user_mailbox.messages batch_modify', got %+v", fs)
}
}
// TestCmdExampleParseRefsRobustness covers the parser edge cases hardened after
// review: backslash continuation, underscore flags, $(...) substitution, glued
// separators, trailing punctuation, and the "..." placeholder.
func TestCmdExampleParseRefsRobustness(t *testing.T) {
cases := []struct {
name, content, wantWords, wantFlags string
wantRefs int
}{
{"backslash continuation joins flags",
"lark-cli contact +search-user \\\n --query foo \\\n --as user",
"contact +search-user", "--query --as", 1},
{"underscore flag not truncated",
"lark-cli whiteboard +update --input_format mermaid",
"whiteboard +update", "--input_format", 1},
{"command-substitution flags ignored",
`lark-cli slides x create --data "$(jq -n --arg c '{}')" --as user`,
"slides x create", "--data --as", 1},
{"glued separator truncates",
"lark-cli auth login; echo done",
"auth login", "", 1},
{"trailing CJK punctuation stripped",
"用 lark-cli auth login。",
"auth login", "", 1},
{"ellipsis placeholder stays placeholder",
"lark-cli base +...",
"base", "", 1},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
refs := parseRefs(tt.content)
if len(refs) != tt.wantRefs {
t.Fatalf("refs=%d want %d: %v", len(refs), tt.wantRefs, refWordsOf(refs))
}
if tt.wantRefs == 0 {
return
}
if got := strings.Join(refs[0].words, " "); got != tt.wantWords {
t.Errorf("words=%q want %q", got, tt.wantWords)
}
if got := strings.Join(refs[0].flags, " "); got != tt.wantFlags {
t.Errorf("flags=%q want %q", got, tt.wantFlags)
}
})
}
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"reflect"
"testing"
"github.com/spf13/cobra"
)
// TestCommandCatalogPath pins that the auth-hint path reconstruction inverts the
// service command tree for any depth — flat dotted resources AND genuinely
// nested resources — so it round-trips through apicatalog.Resolve instead of
// assuming a fixed root->service->resource->method shape.
func TestCommandCatalogPath(t *testing.T) {
chain := func(names ...string) *cobra.Command {
var parent, leaf *cobra.Command
for _, n := range names {
c := &cobra.Command{Use: n}
if parent != nil {
parent.AddCommand(c)
}
parent = c
leaf = c
}
return leaf
}
tests := []struct {
name string
leaf *cobra.Command
want []string
}{
{"flat dotted resource", chain("lark-cli", "im", "chat.members", "create"), []string{"im", "chat.members", "create"}},
{"nested resources", chain("lark-cli", "im", "spaces", "items", "get"), []string{"im", "spaces", "items", "get"}},
{"service level", chain("lark-cli", "im"), []string{"im"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := commandCatalogPath(tt.leaf); !reflect.DeepEqual(got, tt.want) {
t.Errorf("commandCatalogPath = %v, want %v", got, tt.want)
}
})
}
// The root command (no parent) has no catalog path.
if got := commandCatalogPath(&cobra.Command{Use: "lark-cli"}); len(got) != 0 {
t.Errorf("root path = %v, want empty", got)
}
}

View File

@@ -4,8 +4,7 @@
package completion
import (
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
@@ -32,7 +31,9 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
case "powershell":
return root.GenPowerShellCompletionWithDesc(out)
default:
return fmt.Errorf("unsupported shell: %s", args[0])
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported shell: %s", args[0]).
WithHint("supported shells: bash, zsh, fish, powershell")
}
},
}

View File

@@ -212,10 +212,7 @@ func finalizeSource(opts *BindOptions) (string, error) {
if opts.IsTUI && !opts.langExplicit {
lang, err := promptLangSelection()
if err != nil {
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
return "", langSelectionError(err)
}
opts.Lang = string(lang)
opts.UILang = lang

View File

@@ -20,35 +20,29 @@ import (
"github.com/larksuite/cli/internal/output"
)
// assertExitError checks the full structured error in one assertion. It
// accepts both *output.ExitError (used by output.ErrWithHint) and the
// typed errors (ValidationError, ConfigError) — they normalize to the same
// wantDetail fields. The wantDetail.Type is matched against the typed error's
// Category string ("validation", "config", etc.).
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
// wantErrDetail is the normalized comparison shape for a typed error's wire
// fields: Type is the error's Category string ("validation", "config", ...),
// alongside Message and Hint.
type wantErrDetail struct {
Type string
Message string
Hint string
}
// assertExitError checks the full structured error in one assertion against a
// typed error (ValidationError or ConfigError), normalizing its Category /
// Message / Hint to wantDetail.
func assertExitError(t *testing.T, err error, wantCode int, wantDetail wantErrDetail) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
if exitErr.Code != wantCode {
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
}
if exitErr.Detail == nil {
t.Fatal("expected non-nil error detail")
}
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
}
return
}
var ve *errs.ValidationError
if errors.As(err, &ve) {
if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", got, wantCode)
}
gotDetail := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
gotDetail := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
}
@@ -59,13 +53,13 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.Er
if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", got, wantCode)
}
gotDetail := output.ErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
gotDetail := wantErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
}
return
}
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
t.Fatalf("error type = %T, want *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
}
// assertEnvelope decodes stdout and checks it matches want exactly — every key
@@ -179,15 +173,21 @@ func TestConfigBindRun_InvalidLang(t *testing.T) {
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
if valErr.Param != "--lang" {
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
}
})
}
@@ -365,7 +365,7 @@ func TestConfigBindRun_InvalidSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "invalid"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`,
})
@@ -382,7 +382,7 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
// TestFactory has IsTerminal=false by default
err := configBindRun(&BindOptions{Factory: f, Source: ""})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context",
@@ -421,7 +421,7 @@ func TestConfigBindRun_SourceEnvMismatch_OpenClawFlagInHermesEnv(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--source "openclaw" does not match detected Agent environment (hermes)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -437,7 +437,7 @@ func TestConfigBindRun_SourceEnvMismatch_HermesFlagInOpenClawEnv(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--source "hermes" does not match detected Agent environment (openclaw)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -566,7 +566,7 @@ func TestConfigBindRun_HermesMissingEnvFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
Hint: "verify Hermes is installed and configured at " + envPath,
@@ -584,7 +584,7 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify OpenClaw is installed and configured",
@@ -731,7 +731,7 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -750,7 +750,7 @@ func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify lark-channel-bridge is installed and configured",
@@ -770,7 +770,7 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "accounts.app.id missing in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
@@ -789,7 +789,7 @@ func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "accounts.app.secret is empty in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
@@ -835,17 +835,19 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
t.Fatal("expected error for unbound workspace")
}
// Should be a structured ConfigError suggesting config bind, not config init.
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
// Config errors share ExitAuth (3); the workspace is detected but no
// binding exists yet, which is a config error.
if cfgErr.Code != output.ExitAuth {
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
// The workspace name stays out of the wire subtype; it only appears in
// the message.
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
@@ -1187,7 +1189,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
// iterates a map — ordering is non-deterministic. DeepEqual inline against
// each accepted variant so every ErrDetail field (Type, Code, Message,
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
base := output.ErrDetail{
base := wantErrDetail{
Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
}
@@ -1203,7 +1205,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err)
}
got := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
got := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) {
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
got, wantWorkFirst, wantPersonalFirst)
@@ -1230,7 +1232,7 @@ func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only_one",
@@ -1250,7 +1252,7 @@ func TestConfigBindRun_InvalidIdentity(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes", Identity: "invalid"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `invalid --identity "invalid"; valid values: bot-only, user-default`,
})
@@ -1536,7 +1538,7 @@ func TestConfigBindRun_HermesMissingAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "FEISHU_APP_ID not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials",
@@ -1556,7 +1558,7 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "FEISHU_APP_SECRET not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials",
@@ -1582,7 +1584,7 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "openclaw.json missing channels.feishu section",
Hint: "configure Feishu in OpenClaw first",
@@ -1610,7 +1612,7 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) {
openclawPath := filepath.Join(openclawDir, "openclaw.json")
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
Hint: "configure channels.feishu.appSecret in openclaw.json",
@@ -1672,7 +1674,7 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",

View File

@@ -51,7 +51,7 @@ func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",
@@ -64,7 +64,7 @@ func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
// even before it has a bespoke error message.
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "hermes: no app configured",
})
@@ -100,7 +100,7 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
{AppID: "cli_home", Label: "home"},
}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
@@ -117,7 +117,7 @@ func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
{AppID: "cli_home", Label: "home"},
}
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
@@ -152,7 +152,7 @@ func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{{AppID: "cli_only"}}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only",

View File

@@ -12,6 +12,7 @@ import (
"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"
@@ -92,16 +93,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Fatal("expected error")
}
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
// Config errors share ExitAuth (3), not ExitValidation.
if cfgErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
}
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
if cfgErr.Subtype != errs.SubtypeNotConfigured || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want not_configured/not configured", cfgErr)
}
}
@@ -233,15 +234,21 @@ func TestConfigInitCmd_InvalidLang(t *testing.T) {
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
if valErr.Param != "--lang" {
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
}
})
}
@@ -385,8 +392,38 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
if err == nil {
t.Fatal("expected conflict error")
}
if !strings.Contains(err.Error(), "conflicts with existing appId") {
t.Fatalf("error = %v, want conflict with existing appId", err)
// A name/appId conflict is user input — a typed validation error naming the
// offending flag, not a system storage failure.
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if verr.Param != "--name" {
t.Errorf("param = %q, want --name", verr.Param)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", output.ExitCodeOf(err), output.ExitValidation)
}
if !strings.Contains(verr.Message, "conflicts with existing appId") {
t.Errorf("message = %q, want conflict description", verr.Message)
}
}
// TestWrapSaveConfigError_PassesTypedValidationThrough pins that a user-input
// validation error (e.g. the --name conflict) is not reclassified as an
// internal storage failure on its way up through the save call sites.
func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
conflict := errs.NewValidationError(errs.SubtypeInvalidArgument, "name conflict").WithParam("--name")
var verr *errs.ValidationError
if !errors.As(wrapSaveConfigError(conflict), &verr) {
t.Fatalf("typed validation must pass through unchanged, got %T", wrapSaveConfigError(conflict))
}
var ierr *errs.InternalError
if !errors.As(wrapSaveConfigError(errors.New("disk full")), &ierr) || ierr.Subtype != errs.SubtypeStorage {
t.Fatalf("untyped failure must become internal/storage")
}
}

View File

@@ -6,13 +6,11 @@ package config
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
@@ -127,12 +125,9 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
if ws.IsLocal() {
return nil
}
return &core.ConfigError{
Code: 2,
Type: ws.Display(),
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
Hint: "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.",
}
return errs.NewConfigError(errs.SubtypeNotConfigured,
"config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()).
WithHint("see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.")
}
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
@@ -183,6 +178,20 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
}
// wrapSaveConfigError passes an already-typed error (e.g. the --name conflict
// validation error from saveAsProfile) through unchanged, and classifies any
// other failure as an internal storage error. Without the passthrough a user
// input error would surface to agents as a system storage failure.
func wrapSaveConfigError(err error) error {
if err == nil {
return nil
}
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
// saveAsProfile appends or updates a named profile in the config.
// If a profile with the same name exists, it updates it; otherwise appends.
// When updating, cleans up old keychain secrets if AppId changed.
@@ -207,7 +216,9 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
} else {
if findAppIndexByAppID(multi, profileName) >= 0 {
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"profile name %q conflicts with existing appId", profileName).
WithParam("--name")
}
// Append new profile
multi.Apps = append(multi.Apps, core.AppConfig{
@@ -249,8 +260,8 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
// wrapUpdateExistingProfileErr classifies the error returned by
// updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError
// for blank-input) pass through unchanged so their exit code semantics
// survive; legacy *output.ExitError also passes through; everything else
// (filesystem, keychain, etc.) is wrapped as InternalError.
// survive; everything else (filesystem, keychain, etc.) is wrapped as
// InternalError.
func wrapUpdateExistingProfileErr(err error) error {
if err == nil {
return nil
@@ -258,10 +269,6 @@ func wrapUpdateExistingProfileErr(err error) error {
if errs.IsTyped(err) {
return err
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
}
@@ -336,7 +343,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
@@ -353,10 +360,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
lang, err := promptLangSelection()
if err != nil {
if err == huh.ErrUserAborted {
return output.ErrBare(1)
}
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
return langSelectionError(err)
}
opts.Lang = string(lang)
opts.UILang = lang
@@ -379,7 +383,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return wrapSaveConfigError(err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
@@ -409,7 +413,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return wrapSaveConfigError(err)
}
} else if result.Mode == "existing" && result.AppID != "" {
// Existing app with unchanged secret — update app ID and brand only
@@ -514,7 +518,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)

View File

@@ -8,7 +8,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/errs"
)
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
@@ -26,12 +26,15 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
if err == nil {
t.Fatal("expected refusal in OpenClaw context, got nil")
}
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "openclaw") {
t.Errorf("message must name the openclaw workspace; got %q", cfgErr.Message)
}
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
@@ -48,12 +51,15 @@ func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
if err == nil {
t.Fatal("expected refusal in Hermes context, got nil")
}
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
if cfgErr.Type != "hermes" {
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "hermes") {
t.Errorf("message must name the hermes workspace; got %q", cfgErr.Message)
}
}

View File

@@ -4,10 +4,14 @@
package config
import (
"errors"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
type initMsg struct {
@@ -97,3 +101,12 @@ func promptLangSelection() (i18n.Lang, error) {
}
return lang, nil
}
// langSelectionError maps a promptLangSelection failure to its exit surface:
// user abort exits bare with code 1; any other failure is internal.
func langSelectionError(err error) error {
if errors.Is(err, huh.ErrUserAborted) {
return output.ErrBare(1)
}
return errs.NewInternalError(errs.SubtypeUnknown, "language selection failed: %v", err).WithCause(err)
}

View File

@@ -33,15 +33,16 @@ const probeTimeout = 3 * time.Second
//
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
// returns a typed errs.* error (via the shared classifyTATResponseCode)
// only when the server deterministically rejected the credentials — a
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
// so the root dispatcher renders the canonical envelope and `config init`
// exits non-zero — identical to how every other token-resolving command
// reports the same bad credentials. Ambiguous failures (transport errors,
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
// errors and are swallowed (return nil), so valid configurations are never
// disturbed by upstream noise. errs.IsTyped is the discriminator.
// only when the unified Token Endpoint deterministically rejected the
// credentials — an OAuth2 invalid_client / unauthorized_client classified as
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
// typed error is propagated so the root dispatcher renders the canonical
// envelope and `config init` exits non-zero — identical to how every other
// token-resolving command reports the same bad credentials. Ambiguous
// failures (transport errors, transient 5xx/server_error, JSON parse errors,
// timeouts) come back as raw untyped errors and are swallowed (return nil),
// so valid configurations are never disturbed by upstream noise.
// errs.IsTyped is the discriminator.
//
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
// that call (success, server error, timeout, parse failure) is always

View File

@@ -31,10 +31,10 @@ type fakeRT struct {
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
switch {
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
f.tatCalls++
if f.tatHandler == nil {
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
}
return f.tatHandler(req)
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
@@ -84,14 +84,15 @@ func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.B
}
// assertConfigRejection asserts runProbe propagated a deterministic credential
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
// the expected upstream code. This is the same typed error every other
// token-resolving command returns for the same bad credentials, and nothing is
// written to stderr (the root dispatcher renders the envelope).
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This
// is the same typed error every other token-resolving command returns for the
// same bad credentials, and nothing is written to stderr (the root dispatcher
// renders the envelope). The numeric code is not asserted: the unified v3 Token
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
t.Helper()
if err == nil {
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
t.Fatal("expected *errs.ConfigError, got nil")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
@@ -103,9 +104,6 @@ func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCo
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != wantCode {
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
}
if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
}
@@ -123,11 +121,13 @@ func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
}
}
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
// propagated. The probe endpoint must not be called when TAT fails.
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
@@ -137,28 +137,27 @@ func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
if rt.probeCalls != 0 {
t.Error("probe endpoint must not be called when TAT fails")
}
assertConfigRejection(t, err, errBuf, 10003)
assertConfigRejection(t, err, errBuf)
}
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
// the most common real-world rejection, propagated.
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
// unauthorized_client is treated as the same credential rejection, propagated.
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
// Any non-zero body code is a deterministic rejection and propagates (typed).
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
// typed, so the probe still surfaces it rather than swallowing.
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
// it rather than swallowing — but is not a credential (ConfigError) rejection.
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)

View File

@@ -65,8 +65,8 @@ func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t
// wrapUpdateExistingProfileErr is the caller-side classifier for the error
// returned by updateExistingProfileWithoutSecret. It must preserve typed-error
// exit semantics (regression: typed ValidationError was being downgraded to
// InternalError by the legacy *output.ExitError-only passthrough).
// exit semantics: a typed ValidationError must keep ExitValidation rather than
// being downgraded to InternalError.
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
if got := wrapUpdateExistingProfileErr(nil); got != nil {
@@ -90,18 +90,6 @@ func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T
}
}
func TestWrapUpdateExistingProfileErr_LegacyExitErrorPreserved(t *testing.T) {
in := &output.ExitError{Code: 7, Err: errors.New("legacy")}
got := wrapUpdateExistingProfileErr(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected *output.ExitError to pass through, got %T: %v", got, got)
}
if exitErr.Code != 7 {
t.Errorf("Code = %d, want 7", exitErr.Code)
}
}
func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) {
in := fmt.Errorf("disk full")
got := wrapUpdateExistingProfileErr(in)

View File

@@ -14,6 +14,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -94,7 +95,7 @@ func doctorRun(opts *DoctorOptions) error {
// underlying problem is still visible.
msg, hint := err.Error(), ""
if errors.Is(err, os.ErrNotExist) {
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
msg, hint = cfgErr.Message, cfgErr.Hint
}
@@ -108,7 +109,7 @@ func doctorRun(opts *DoctorOptions) error {
cfg, err := f.Config()
if err != nil {
hint := ""
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
hint = cfgErr.Hint
}

View File

@@ -11,10 +11,10 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/apicatalog"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
@@ -48,32 +48,6 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
authErr.Hint += "\n" + scopeHint
}
// enrichMissingScopeError appends a "current command requires scope(s): X"
// hint to a legacy *output.ExitError when the underlying error carries the
// need_user_authorization marker AND the current command declares scopes
// locally.
//
// Deprecated: enrichment for the legacy envelope; the typed path is
// applyNeedAuthorizationHint above.
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr == nil || exitErr.Detail == nil {
return
}
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
return
}
scopes := resolveDeclaredScopesForCurrentCommand(f)
if len(scopes) == 0 {
return
}
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
if exitErr.Detail.Hint == "" {
exitErr.Detail.Hint = scopeHint
return
}
exitErr.Detail.Hint += "\n" + scopeHint
}
// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
// current command for the resolved identity, checking shortcuts first and then
// service methods from local registry metadata.
@@ -118,38 +92,37 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
}
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
// service/resource/method command from the embedded from_meta registry.
// service/resource/method command. It reconstructs the catalog path from the
// command ancestry and resolves it through the same navigation Module the
// command tree is built from (apicatalog), so it stays correct for nested
// resources instead of hard-coding a root->service->resource->method depth.
// Non-method commands (services, resources, shortcuts) resolve to a non-method
// target and yield no scopes.
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
// Service-method scope lookup only applies to commands mounted as
// root -> service -> resource -> method. Non-resource/method commands
// intentionally return no scopes here so auth-hint enrichment does not
// change runtime semantics for other command shapes.
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
if cmd == nil || strings.HasPrefix(cmd.Name(), "+") {
return nil
}
if strings.HasPrefix(cmd.Name(), "+") {
path := commandCatalogPath(cmd)
if len(path) == 0 {
return nil
}
target, err := registry.RuntimeCatalog().Resolve(path)
if err != nil || target.Kind != apicatalog.TargetMethod {
return nil
}
return registry.DeclaredScopesForMethod(target.Method.Method, identity)
}
service := cmd.Parent().Parent().Name()
resource := cmd.Parent().Name()
method := cmd.Name()
spec := registry.LoadFromMeta(service)
if spec == nil {
return nil
// commandCatalogPath reconstructs the catalog path [service, resource..., method]
// from a command's ancestry, excluding the root command. It is the inverse of
// the service command tree's construction, so any depth (flat or nested)
// round-trips through apicatalog.Resolve.
func commandCatalogPath(cmd *cobra.Command) []string {
var path []string
for c := cmd; c != nil && c.Parent() != nil; c = c.Parent() {
path = append([]string{c.Name()}, path...)
}
resources, _ := spec["resources"].(map[string]interface{})
resMap, _ := resources[resource].(map[string]interface{})
if resMap == nil {
return nil
}
methods, _ := resMap["methods"].(map[string]interface{})
methodMap, _ := methods[method].(map[string]interface{})
if methodMap == nil {
return nil
}
return registry.DeclaredScopesForMethod(methodMap, identity)
return path
}
// shortcutSupportsIdentity reports whether a shortcut supports the requested

View File

@@ -8,7 +8,7 @@ import (
"regexp"
)
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen the host alternation when adding brands.
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.

View File

@@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event"
@@ -38,7 +39,8 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
logger, err := bus.SetupBusLogger(eventsDir)
if err != nil {
return err
return errs.NewInternalError(errs.SubtypeFileIO,
"set up bus logger: %s", err).WithCause(err)
}
tr := transport.New()
@@ -58,7 +60,14 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
}
}()
return b.Run(ctx)
if err := b.Run(ctx); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"event bus daemon exited: %s", err).WithCause(err)
}
return nil
},
}

45
cmd/event/bus_test.go Normal file
View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// The hidden `event _bus` daemon command must exit with a typed file_io error
// when its log directory cannot be created (the error is only visible in the
// forked process's captured stderr / bus.log).
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Block the events/ root with a regular file so MkdirAll fails.
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
cmd := NewCmdBus(f)
cmd.SetArgs([]string{})
err := cmd.Execute()
if err == nil {
t.Fatal("expected logger setup error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryInternal, errs.SubtypeFileIO)
}
}

View File

@@ -4,21 +4,117 @@
package event
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
host, appID, strings.Join(scopes, ","))
// Landing-page contract for the scan-to-enable deep link, verified against the
// open platform: {open-host}/page/launcher?clientID=<appID>&addons=<encoded>.
// Note the param is camelCase "clientID" (not snake_case), and the value is the
// consuming app's own ID. Centralized so it can be corrected in one place.
const (
addonsLandingPath = "/page/launcher"
addonsClientIDParam = "clientID"
)
// ManifestAddons mirrors the 5 public manifest sections the launcher page accepts.
// Encoded form: JSON -> gzip -> base64url(no padding).
type ManifestAddons struct {
Scopes *AddonsScopes `json:"scopes,omitempty"`
Events *AddonsEvents `json:"events,omitempty"`
Callbacks *AddonsCallbacks `json:"callbacks,omitempty"`
}
// consoleEventSubscriptionURL points at the app's event subscription console page.
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s/app/%s/event", host, appID)
type AddonsScopes struct {
Tenant []string `json:"tenant"`
User []string `json:"user"`
}
type AddonsEvents struct {
Items AddonsEventItems `json:"items"`
}
type AddonsEventItems struct {
Tenant []string `json:"tenant"`
User []string `json:"user"`
}
type AddonsCallbacks struct {
Items []string `json:"items"`
}
// encodeAddons: JSON -> gzip -> base64url(no padding). Matches the front-end decode chain.
func encodeAddons(a ManifestAddons) (string, error) {
raw, err := json.Marshal(a)
if err != nil {
return "", err
}
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
if _, err := gw.Write(raw); err != nil {
return "", err
}
if err := gw.Close(); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil
}
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
encoded, err := encodeAddons(a)
if err != nil {
return "", err
}
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
}
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
func consoleLandingURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
}
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
url, err := consoleAddonsURL(brand, appID, a)
if err != nil {
return consoleLandingURL(brand, appID)
}
return url
}
// missingScopeAddons routes missing scopes into the identity-appropriate section.
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
// the addons spec treats a missing tenant/user as an empty array.
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
if identity.IsBot() {
s.Tenant = missing
} else {
s.User = missing
}
return ManifestAddons{Scopes: s}
}
// missingSubscriptionAddons routes missing events/callbacks into the right section.
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
if subType == eventlib.SubTypeCallback {
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
}
ev := &AddonsEvents{Items: AddonsEventItems{Tenant: []string{}, User: []string{}}}
if identity.IsBot() {
ev.Items.Tenant = missing
} else {
ev.Items.User = missing
}
return ManifestAddons{Events: ev}
}

View File

@@ -4,33 +4,109 @@
package event
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"io"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
"im:message:readonly",
"im:message.group_at_msg",
})
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
t.Helper()
gz, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("base64url decode: %v", err)
}
zr, err := gzip.NewReader(bytes.NewReader(gz))
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
raw, err := io.ReadAll(zr)
if err != nil {
t.Fatalf("gunzip: %v", err)
}
var a ManifestAddons
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatalf("json: %v", err)
}
return a
}
func TestEncodeAddons_RoundTrip(t *testing.T) {
in := ManifestAddons{Scopes: &AddonsScopes{Tenant: []string{"im:message"}}}
encoded, err := encodeAddons(in)
if err != nil {
t.Fatalf("encode: %v", err)
}
for _, r := range encoded {
if !(r == '-' || r == '_' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
t.Fatalf("encoded contains non-base64url char %q in %q", r, encoded)
}
}
out := decodeAddons(t, encoded)
if out.Scopes == nil || len(out.Scopes.Tenant) != 1 || out.Scopes.Tenant[0] != "im:message" {
t.Errorf("roundtrip mismatch: %+v", out)
}
}
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
if err != nil {
t.Fatalf("url: %v", err)
}
host := core.ResolveEndpoints(core.BrandFeishu).Open
prefix := host + "/page/launcher?clientID=cli_x&addons="
if !strings.HasPrefix(url, prefix) {
t.Errorf("url = %q, want prefix %q", url, prefix)
}
out := decodeAddons(t, strings.TrimPrefix(url, prefix))
if out.Callbacks == nil || len(out.Callbacks.Items) != 1 || out.Callbacks.Items[0] != "card.action.trigger" {
t.Errorf("decoded callbacks mismatch: %+v", out)
}
}
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
t.Errorf("unexpected url: %s", got)
func TestMissingScopeAddons_ByIdentity(t *testing.T) {
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
}
user := missingScopeAddons(core.AsUser, []string{"im:message"})
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
}
}
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
}
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
}
}
func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
// Unused identity sides must encode as [] (not null) so the launcher page's
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
cases := []ManifestAddons{
missingScopeAddons(core.AsBot, []string{"im:message"}),
missingScopeAddons(core.AsUser, []string{"im:message"}),
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
}
for i, a := range cases {
raw, err := json.Marshal(a)
if err != nil {
t.Fatalf("case %d marshal: %v", i, err)
}
if bytes.Contains(raw, []byte("null")) {
t.Errorf("case %d encodes a null array, want []: %s", i, raw)
}
}
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
@@ -64,8 +65,8 @@ Use 'event schema <EventKey>' for parameter details.`,
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
@@ -101,11 +102,10 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
if o.jqExpr != "" {
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
return output.ErrWithHint(
output.ExitValidation, "validation",
err.Error(),
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
WithParam("--jq").
WithCause(err).
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey)
}
}
@@ -146,14 +146,28 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
}
// Callback subscriptions live in application/get, not app_versions; fetch the
// callback 底账 only for callback-type EventKeys. Weak dependency: on error,
// leave subscribedCallbacks nil so the callback precheck skips.
var subscribedCallbacks []string
if keyDef.SubscriptionType == eventlib.SubTypeCallback {
cbs, cbErr := appmeta.FetchSubscribedCallbacks(cmd.Context(), botRuntime, cfg.AppID)
if cbErr != nil {
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(cbErr))
} else {
subscribedCallbacks = cbs
}
}
pf := &preflightCtx{
factory: f,
appID: cfg.AppID,
brand: cfg.Brand,
eventKey: eventKey,
identity: identity,
keyDef: keyDef,
appVer: appVer,
factory: f,
appID: cfg.AppID,
brand: cfg.Brand,
eventKey: eventKey,
identity: identity,
keyDef: keyDef,
appVer: appVer,
subscribedCallbacks: subscribedCallbacks,
}
if err := preflightEventTypes(pf); err != nil {
return err
@@ -184,8 +198,9 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
errOut = io.Discard
}
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
if !f.IOStreams.IsTerminal {
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers.
// Bounded runs already have --max-events/--timeout as their lifecycle control.
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
watchStdinEOF(os.Stdin, cancel, errOut)
}
@@ -228,6 +243,9 @@ type preflightCtx struct {
identity core.Identity
keyDef *eventlib.KeyDefinition
appVer *appmeta.AppVersion
// subscribedCallbacks is the application/get 底账 for callback-type EventKeys;
// nil means "not fetched / unavailable" → callback precheck skips (weak dependency).
subscribedCallbacks []string
}
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
@@ -260,63 +278,87 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
if len(missing) == 0 {
return nil
}
return output.ErrWithHint(
output.ExitAuth, "auth",
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
)
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scopes for EventKey %s (as %s): %s",
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
WithIdentity(string(pf.identity)).
WithMissingScopes(missing...).
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
}
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
// Bot: the scan-to-enable link adds the scopes to the app manifest, after which
// the tenant token carries them. User: the scan link only updates the app
// manifest — the user's own token still lacks the scopes until it is
// re-authorized — so direct the user to re-login instead.
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
if identity.IsBot() {
return fmt.Sprintf(
"grant these scopes and publish a new app version at: %s",
consoleScopeGrantURL(brand, appID, missing),
)
return fmt.Sprintf("grant these scopes by scanning: %s",
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
}
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, " "),
)
strings.Join(missing, " "))
}
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed
// in the app's console 底账 — published app_versions for event subscriptions,
// application/get subscribed_callbacks for callback subscriptions.
func preflightEventTypes(pf *preflightCtx) error {
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
if len(pf.keyDef.RequiredConsoleEvents) == 0 {
return nil
}
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
for _, t := range pf.appVer.EventTypes {
subscribed[t] = true
var subscribed []string
noun := "event types"
if pf.keyDef.SubscriptionType == eventlib.SubTypeCallback {
if pf.subscribedCallbacks == nil {
return nil
}
subscribed = pf.subscribedCallbacks
noun = "callbacks"
} else {
if pf.appVer == nil {
return nil
}
subscribed = pf.appVer.EventTypes
}
have := make(map[string]bool, len(subscribed))
for _, t := range subscribed {
have[t] = true
}
var missing []string
for _, t := range pf.keyDef.RequiredConsoleEvents {
if !subscribed[t] {
if !have[t] {
missing = append(missing, t)
}
}
if len(missing) == 0 {
return nil
}
return output.ErrWithHint(
output.ExitValidation, "validation",
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
pf.keyDef.Key, strings.Join(missing, ", ")),
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
consoleEventSubscriptionURL(pf.brand, pf.appID)),
)
url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing))
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"EventKey %s requires %s not subscribed in console: %s",
pf.keyDef.Key, noun, strings.Join(missing, ", ")).
WithHint("subscribe these %s by scanning: %s", noun, url)
}
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
func sanitizeOutputDir(dir string) (string, error) {
if strings.HasPrefix(dir, "~") {
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s; use a relative path like ./output instead", errOutputDirTilde).
WithParam("--output-dir").
WithCause(errOutputDirTilde)
}
safe, err := validate.SafeOutputPath(dir)
if err != nil {
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q: %s", errOutputDirUnsafe, dir, err).
WithParam("--output-dir").
WithCause(errOutputDirUnsafe)
}
return safe, nil
}
@@ -328,22 +370,25 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
}
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
if err != nil {
return "", output.ErrAuth("resolve tenant access token: %s", err)
if _, ok := errs.ProblemOf(err); ok {
return "", err
}
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"resolve tenant access token: %s", err).WithCause(err)
}
if result == nil || result.Token == "" {
return "", output.ErrWithHint(
output.ExitAuth, "auth",
fmt.Sprintf("no tenant access token available for app %s", appID),
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
)
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"no tenant access token available for app %s", appID).
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
}
return result.Token, nil
}
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
var (
errInvalidParamFormat = errors.New("invalid --param format")
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
errOutputDirUnsafe = errors.New("unsafe --output-dir")
errInvalidParamFormat = errors.New("invalid --param format") //nolint:forbidigo // sentinel, typed at call sites
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion") //nolint:forbidigo // sentinel, typed at call sites
errOutputDirUnsafe = errors.New("unsafe --output-dir") //nolint:forbidigo // sentinel, typed at call sites
)
func parseParams(raw []string) (map[string]string, error) {
@@ -351,7 +396,10 @@ func parseParams(raw []string) (map[string]string, error) {
for _, kv := range raw {
k, v, ok := strings.Cut(kv, "=")
if !ok || k == "" {
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q: expected key=value", errInvalidParamFormat, kv).
WithParam("--param").
WithCause(errInvalidParamFormat)
}
m[k] = v
}
@@ -370,3 +418,8 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
cancel()
}()
}
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
return !isTerminal && maxEvents <= 0 && timeout <= 0
}

View File

@@ -61,3 +61,70 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
}
}
func TestShouldWatchStdinEOF(t *testing.T) {
tests := []struct {
name string
isTerminal bool
maxEvents int
timeout time.Duration
want bool
}{
{
name: "terminal",
isTerminal: true,
want: false,
},
{
name: "non terminal unbounded",
want: true,
},
{
name: "non terminal negative max events is unbounded",
maxEvents: -1,
want: true,
},
{
name: "non terminal negative timeout is unbounded",
timeout: -1 * time.Second,
want: true,
},
{
name: "non terminal max events bounded",
maxEvents: 1,
want: false,
},
{
name: "non terminal timeout bounded",
timeout: 10 * time.Minute,
want: false,
},
{
name: "non terminal both bounds positive",
maxEvents: 1,
timeout: 10 * time.Minute,
want: false,
},
{
name: "non terminal bounded max events with negative timeout",
maxEvents: 1,
timeout: -1 * time.Second,
want: false,
},
{
name: "non terminal bounded timeout with negative max events",
maxEvents: -1,
timeout: 10 * time.Minute,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
if got != tt.want {
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -4,9 +4,14 @@
package event
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
)
func TestParseParams(t *testing.T) {
@@ -73,6 +78,7 @@ func TestParseParams(t *testing.T) {
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
}
assertInvalidArgumentParam(t, err, "--param")
return
}
if err != nil {
@@ -90,6 +96,77 @@ func TestParseParams(t *testing.T) {
}
}
// emptyTokenResolver resolves to a result that carries no token.
type emptyTokenResolver struct{}
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{}, nil
}
// failingTokenResolver fails outright with an untyped error.
type failingTokenResolver struct{}
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return nil, errors.New("backend unavailable")
}
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
}
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
}
var malformed *credential.MalformedTokenResultError
if !errors.As(err, &malformed) {
t.Error("empty-token failure should preserve the credential-layer cause")
}
}
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
}
if errors.Unwrap(err) == nil {
t.Error("resolver failure should preserve its cause")
}
}
// assertInvalidArgumentParam verifies err is a typed validation error with
// subtype invalid_argument naming the given flag in its param field.
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != param {
t.Errorf("param = %q, want %q", ve.Param, param)
}
}
func TestSanitizeOutputDir(t *testing.T) {
cases := []struct {
name string
@@ -130,6 +207,7 @@ func TestSanitizeOutputDir(t *testing.T) {
if !errors.Is(err, tc.wantSentry) {
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
}
assertInvalidArgumentParam(t, err, "--output-dir")
return
}
if err != nil {

View File

@@ -143,6 +143,79 @@ func TestWriteStatusText_CoversAllStates(t *testing.T) {
}
}
func TestWriteStatusText_ShowsSubColumn(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 2,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0},
{PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB column header: %s", out)
}
if !strings.Contains(out, "alice") {
t.Errorf("missing alice suffix in SUB column: %s", out)
}
if !strings.Contains(out, "bob") {
t.Errorf("missing bob suffix in SUB column: %s", out)
}
}
func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 1,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB header: %s", out)
}
if !strings.Contains(out, "-") {
t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out)
}
}
func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 1,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB header: %s", out)
}
if !strings.Contains(out, "-") {
t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out)
}
}
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
var buf bytes.Buffer
if err := writeStatusJSON(&buf, []appStatus{
@@ -197,15 +270,15 @@ func TestExitForOrphan(t *testing.T) {
if err == nil {
t.Fatal("flag on + orphan → expected error, got nil")
}
var exit *output.ExitError
var exit *output.BareError
if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
t.Errorf("exit code = %v, want ExitValidation", err)
}
}
func errorAs(err error, target interface{}) bool {
if e, ok := err.(*output.ExitError); ok {
if t, ok := target.(**output.ExitError); ok {
if e, ok := err.(*output.BareError); ok {
if t, ok := target.(**output.BareError); ok {
*t = e
return true
}

View File

@@ -8,10 +8,10 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
)
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
@@ -89,19 +89,17 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
t.Errorf("error should name the missing event type, got: %v", err)
}
var exit *output.ExitError
if !errors.As(err, &exit) {
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if exit.Code != output.ExitValidation {
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
}
if exit.Detail == nil {
t.Fatal("expected Detail with hint")
}
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
if !strings.Contains(exit.Detail.Hint, wantURL) {
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
wantURL := "https://open.feishu.cn/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons="
if !strings.Contains(p.Hint, wantURL) {
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.Hint)
}
}
@@ -145,21 +143,22 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
t.Errorf("error should name missing scope, got: %v", err)
}
var exit *output.ExitError
if !errors.As(err, &exit) {
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
if exit.Code != output.ExitAuth {
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope {
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype,
errs.CategoryAuthorization, errs.SubtypeMissingScope)
}
if exit.Detail == nil {
t.Fatal("expected Detail with hint, got nil Detail")
wantMissing := []string{"im:message.group_at_msg"}
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] {
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
}
hint := exit.Detail.Hint
hint := permErr.Hint
wantSubstrings := []string{
"https://open.feishu.cn/app/cli_x/auth?q=",
"im:message.group_at_msg",
"token_type=tenant",
"grant these scopes by scanning: ",
"https://open.feishu.cn/page/launcher?clientID=cli_x&addons=",
}
for _, want := range wantSubstrings {
if !strings.Contains(hint, want) {
@@ -174,3 +173,109 @@ func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
}
}
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{"profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
err := preflightEventTypes(pf)
if err == nil {
t.Fatal("expected error for missing callback")
}
if !strings.Contains(err.Error(), "callbacks not subscribed") {
t.Errorf("error = %q, want mention of 'callbacks not subscribed'", err.Error())
}
if !strings.Contains(err.Error(), "card.action.trigger") {
t.Errorf("error should name the missing callback, got: %q", err.Error())
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("problem = %v, want validation/failed_precondition", p)
}
}
func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
if err := preflightEventTypes(pf); err != nil {
t.Errorf("expected skip (nil), got %v", err)
}
}
func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
// fetched but zero callbacks subscribed (non-nil empty) is a definitive
// console state: a required callback IS missing and must be reported,
// not skipped as a weak dependency.
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{}, // fetched, none subscribed
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
err := preflightEventTypes(pf)
if err == nil {
t.Fatal("expected error for missing callback when none are subscribed")
}
if !strings.Contains(err.Error(), "card.action.trigger") {
t.Errorf("error should name the missing callback, got: %q", err.Error())
}
}
func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
if err := preflightEventTypes(pf); err != nil {
t.Errorf("all callbacks subscribed, unexpected error: %v", err)
}
}
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
// bot: scan-to-enable link (adds scopes to app manifest)
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
t.Errorf("bot hint should give the scan link, got: %s", bot)
}
// user: re-login (scan link cannot grant scopes to the user's own token)
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
if !strings.Contains(user, "auth login --scope") {
t.Errorf("user hint should direct to auth login, got: %s", user)
}
if strings.Contains(user, "/page/launcher") {
t.Errorf("user hint must NOT use the scan link, got: %s", user)
}
}

View File

@@ -6,8 +6,8 @@ package event
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)
@@ -26,7 +26,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
As: r.accessIdentity,
})
if err != nil {
return nil, err
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
"api %s %s: %s", method, path, err).WithCause(err)
}
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
ct := resp.Header.Get("Content-Type")
@@ -36,11 +40,20 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
if len(body) > maxBodyEcho {
body = body[:maxBodyEcho] + "…(truncated)"
}
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
if resp.StatusCode >= 500 {
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
}
result, err := client.ParseJSONResponse(resp)
if err != nil {
return nil, err
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"api %s %s: %s", method, path, err).WithCause(err)
}
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
return json.RawMessage(resp.RawBody), apiErr

147
cmd/event/runtime_test.go Normal file
View File

@@ -0,0 +1,147 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"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"
"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 calls.
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) }
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
sdk := lark.NewClient("test-app", "test-secret",
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(&http.Client{Transport: rt}),
)
return &consumeRuntime{
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},
},
accessIdentity: core.AsBot,
}
}
func stubResponse(status int, contentType, 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{contentType}},
Body: io.NopCloser(strings.NewReader(body)),
Request: r,
}, nil
}
}
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype {
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
}
}
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
if !strings.Contains(err.Error(), "returned 404") {
t.Errorf("error should echo the HTTP status, got: %v", err)
}
}
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
long := strings.Repeat("x", 300)
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
p, _ := errs.ProblemOf(err)
if !p.Retryable {
t.Fatal("5xx non-JSON response should be marked retryable")
}
if !strings.Contains(err.Error(), "…(truncated)") {
t.Errorf("long body should be truncated in the message, got: %v", err)
}
}
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
}
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
return nil, errors.New("connection refused")
}})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork {
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
}
}
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
`{"code":99991663,"msg":"app not found"}`)})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
}
}
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
`{"code":0,"data":{"ok":true}}`)})
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(string(raw), `"code":0`) {
t.Errorf("raw body should pass through, got: %s", raw)
}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
@@ -39,12 +40,14 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
if len(def.Schema.FieldOverrides) > 0 {
var parsed map[string]interface{}
if err := json.Unmarshal(base, &parsed); err != nil {
return nil, nil, err
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
"parse base schema for field overrides: %s", err).WithCause(err)
}
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
out, err := json.Marshal(parsed)
if err != nil {
return nil, nil, err
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
"serialize schema with field overrides: %s", err).WithCause(err)
}
return out, orphans, nil
}
@@ -73,7 +76,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
copy(buf, s.Raw)
return buf, nil
}
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
}
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
@@ -131,12 +134,16 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
if len(def.Params) > 0 {
fmt.Fprintf(out, "\nParameters:\n")
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tSUB-KEY\tDEFAULT\tDESCRIPTION\n")
for _, p := range def.Params {
required := "no"
if p.Required {
required = "yes"
}
subKey := "no"
if p.SubscriptionKey {
subKey = "yes"
}
defaultVal := p.Default
if defaultVal == "" {
defaultVal = "-"
@@ -145,7 +152,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
if desc == "" {
desc = "-"
}
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, subKey, defaultVal, desc)
}
w.Flush()
@@ -165,7 +172,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
return err
}
if resolved != nil {
fmt.Fprintf(out, "\nOutput Schema:\n")

View File

@@ -10,6 +10,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
@@ -95,6 +96,79 @@ func TestRunSchema_JSONOutput(t *testing.T) {
}
}
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{
{Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"},
{Name: "folders", Description: "filter only"},
},
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, false); err != nil {
t.Fatalf("runSchema: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "SUB-KEY") {
t.Errorf("missing SUB-KEY column header in:\n%s", out)
}
// Find the mailbox row and verify "yes" is present
var mailboxRow string
for _, ln := range strings.Split(out, "\n") {
if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") {
mailboxRow = ln
break
}
}
if !strings.Contains(mailboxRow, "yes") {
t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow)
}
// Find the folders row and verify "no" is present
var foldersRow string
for _, ln := range strings.Split(out, "\n") {
if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") {
foldersRow = ln
break
}
}
if !strings.Contains(foldersRow, "no") {
t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow)
}
}
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
const syntheticKey = "test.evt_json"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
if !strings.Contains(stdout.String(), `"subscription_key"`) {
t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `true`) {
t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
}
}
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
const syntheticKey = "t.custom.overlay"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
@@ -129,3 +203,38 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
t.Errorf("overlay format = %v, want open_id", got)
}
}
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
_, err := renderSpec(&eventlib.SchemaSpec{})
if err == nil {
t.Fatal("expected error for spec with neither Type nor Raw")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
}
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "synthetic.invalid.base",
Schema: eventlib.SchemaDef{
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
},
}
_, _, err := resolveSchemaJSON(def)
if err == nil {
t.Fatal("expected error for unparsable base schema")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"sort"
"strings"
"sync"
"time"
@@ -242,12 +243,17 @@ func writeStatusText(out io.Writer, statuses []appStatus) {
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
if len(s.Consumers) > 0 {
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
headers := []string{"CONSUMER", "EVENT KEY", "SUB", "RECEIVED", "DROPPED"}
rows := make([][]string, 0, len(s.Consumers))
for _, c := range s.Consumers {
subDisplay := "-"
if c.SubscriptionID != "" && c.SubscriptionID != c.EventKey {
subDisplay = strings.TrimPrefix(c.SubscriptionID, c.EventKey+":")
}
rows = append(rows, []string{
fmt.Sprintf("pid=%d", c.PID),
c.EventKey,
subDisplay,
fmt.Sprintf("%d", c.Received),
fmt.Sprintf("%d", c.Dropped),
})

View File

@@ -19,12 +19,12 @@ func TestExitForOrphan_Orphan(t *testing.T) {
if err == nil {
t.Fatal("expected error when failOnOrphan=true and orphan present")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation)
if bareErr.Code != output.ExitValidation {
t.Errorf("Code = %d, want %d", bareErr.Code, output.ExitValidation)
}
}

View File

@@ -8,8 +8,9 @@ import (
"sort"
"strings"
"github.com/larksuite/cli/errs"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/suggest"
)
const maxSuggestions = 3
@@ -28,7 +29,7 @@ func suggestEventKeys(input string) []string {
hits = append(hits, match{def.Key, 0})
continue
}
if d := levenshtein(input, def.Key); d <= threshold {
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
hits = append(hits, match{def.Key, d})
}
}
@@ -63,40 +64,6 @@ func unknownEventKeyErr(key string) error {
if guesses := suggestEventKeys(key); len(guesses) > 0 {
msg += " — did you mean " + formatSuggestions(guesses) + "?"
}
return output.ErrWithHint(
output.ExitValidation, "validation",
msg,
"Run 'lark-cli event list' to see available keys.",
)
}
// levenshtein computes classic edit distance (two-row DP).
func levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
WithHint("Run 'lark-cli event list' to see available keys.")
}

View File

@@ -10,27 +10,6 @@ import (
_ "github.com/larksuite/cli/events"
)
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "", 1},
{"", "abc", 3},
{"kitten", "kitten", 0},
{"kitten", "sitten", 1},
{"kitten", "sitting", 3},
{"飞书", "飞书", 0},
{"飞书", "飞s", 1},
}
for _, tc := range cases {
if got := levenshtein(tc.a, tc.b); got != tc.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestSuggestEventKeys(t *testing.T) {
cases := []struct {
name string

104
cmd/flag_suggest_test.go Normal file
View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
func TestUnknownFlagName(t *testing.T) {
cases := []struct {
in string
name string
ok bool
}{
{"unknown flag: --query", "query", true},
{"unknown flag: --with-styles", "with-styles", true},
{"unknown shorthand flag: 'z' in -z", "", false},
{"flag needs an argument: --find", "", false},
{`invalid argument "x" for "--count"`, "", false},
}
for _, c := range cases {
name, ok := unknownFlagName(errors.New(c.in))
if name != c.name || ok != c.ok {
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
}
}
}
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().String("find", "", "")
c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// The offending flag is carried structurally on Params (replaces the
// legacy detail map) and named in the message.
if len(verr.Params) != 1 || verr.Params[0].Name != "--rang" {
t.Errorf("Params = %v, want one entry named --rang", verr.Params)
}
if len(verr.Params) == 1 && verr.Params[0].Reason == "" {
t.Error("Params[0].Reason must explain the rejection")
}
if !strings.Contains(verr.Message, "--rang") {
t.Errorf("message should name the offending flag, got %q", verr.Message)
}
// The ranked candidate rides on the param as a machine-readable suggestion
// so an agent can retry without parsing prose.
if len(verr.Params) == 1 {
found := false
for _, s := range verr.Params[0].Suggestions {
if s == "--range" {
found = true
}
}
if !found {
t.Errorf("Params[0].Suggestions should include --range, got %v", verr.Params[0].Suggestions)
}
}
// The same candidate is also carried in the human-facing hint.
if !strings.Contains(verr.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", verr.Hint)
}
}
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
// structured param, generic --help hint (no "did you mean" suggestion).
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if verr.Param != "" || len(verr.Params) != 0 {
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
}
}

61
cmd/notice_test.go Normal file
View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/deprecation"
)
// composePendingNotice must surface a deprecated-command alias under the
// "deprecated_command" key, with the migration target and a skill-update hint,
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
// without ever reading --help.
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(&deprecation.Notice{
Command: "+read",
Replacement: "+cells-get",
Skill: "lark-sheets",
})
got := composePendingNotice()
if got == nil {
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
}
entry, ok := got["deprecated_command"].(map[string]interface{})
if !ok {
t.Fatalf("missing deprecated_command key: %#v", got)
}
if entry["command"] != "+read" {
t.Errorf("command = %v, want +read", entry["command"])
}
if entry["replacement"] != "+cells-get" {
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
}
if entry["skill"] != "lark-sheets" {
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
}
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
t.Errorf("message missing skill-update hint: %q", msg)
}
}
// With nothing pending, the provider returns nil so no "_notice" field is
// emitted on a clean run.
func TestComposePendingNoticeEmpty(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
if got := composePendingNotice(); got != nil {
// update/skills pending are process-global; only assert the absence of
// our own key to stay robust against unrelated pending state.
if _, ok := got["deprecated_command"]; ok {
t.Fatalf("deprecated_command present after clear: %#v", got)
}
}
}

View File

@@ -9,10 +9,12 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -102,7 +104,7 @@ func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Comma
}
// Happy path: a valid policy.yml denies one specific command. The denied
// command's RunE returns a typed ExitError envelope; allowed commands are
// command's RunE returns a typed error envelope; allowed commands are
// untouched.
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
cfgDir := tmpHome(t)
@@ -127,13 +129,27 @@ max_risk: write
if err == nil {
t.Fatalf("+delete-doc RunE should return an error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok || detail["reason_code"] != "command_denylisted" {
t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// The denial taxonomy (reason_code, layer, rule) is preserved on the
// wrapped *platform.CommandDeniedError cause and folded into the hint.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("error chain should expose *platform.CommandDeniedError")
}
if cd.ReasonCode != "command_denylisted" {
t.Errorf("CommandDeniedError.ReasonCode = %q, want command_denylisted", cd.ReasonCode)
}
if !strings.Contains(verr.Hint, "command_denylisted") {
t.Errorf("hint should surface reason_code command_denylisted, got %q", verr.Hint)
}
// im/+send must be denied (domain not in Allow).

View File

@@ -8,9 +8,9 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
internalplatform "github.com/larksuite/cli/internal/platform"
)
@@ -34,16 +34,8 @@ import (
// lands directly on their RunE, which now carries the guard.
//
// makeErr is called for every guarded dispatch; it must return a fresh
// *output.ExitError each time (the envelope writer mutates a few fields
// as it serialises).
// Deprecated: installFatalGuard accepts a *output.ExitError-producing lambda,
// which is part of the legacy error surface that predates the typed error
// contract introduced by errs/. New code MUST NOT add new callers — the
// platform-extension fatal-guard plumbing will switch to typed errs.* errors
// when the platform-extension framework migrates. This wrapper is retained
// only for the existing in-tree call sites; it will be removed once they
// have moved to the typed surface.
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
// typed error each time.
func installFatalGuard(rootCmd *cobra.Command, makeErr func() error) {
// Two cobra subcommands are injected lazily at Execute() time and
// would otherwise slip past walkGuard. We pre-register both so
// walkGuard catches them.
@@ -80,120 +72,65 @@ func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError)
}
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
// failure as a structured plugin_install envelope before any command
// runs.
// Deprecated: installPluginInstallErrorGuard produces a legacy
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
// such producers — plugin install failures should surface as a typed
// *errs.XxxError once the platform-extension framework migrates. This
// helper is retained only while existing call sites are migrated; it will
// be removed once they have moved to the typed surface.
// failure as a typed validation error (failed_precondition) before any
// command runs.
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
makeErr := func() *output.ExitError {
makeErr := func() error {
var pi *internalplatform.PluginInstallError
if errors.As(installErr, &pi) {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_install",
Message: pi.Error(),
Detail: map[string]any{
"plugin": pi.PluginName,
"reason_code": pi.ReasonCode,
"reason": pi.Reason,
},
},
Err: installErr,
}
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_install",
Message: installErr.Error(),
Detail: map[string]any{
"reason_code": internalplatform.ReasonInstallFailed,
},
},
Err: installErr,
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", pi.Error()).
WithHint("plugin %q failed to install (reason_code %s); fix or remove the plugin before running commands", pi.PluginName, pi.ReasonCode).
WithCause(installErr)
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", installErr.Error()).
WithHint("a plugin failed to install (reason_code %s); fix or remove the plugin before running commands", internalplatform.ReasonInstallFailed).
WithCause(installErr)
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration
// error (single plugin invalid Rule or multiple plugins each contributing
// Restrict). The design separates the envelope type:
// Restrict). The hint separates the two failure modes by reason code:
//
// - "plugin_install" with reason_code "invalid_rule" - single bad rule
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
// - "invalid_rule" - single bad rule
// - "multiple_restrict_plugins" - multiple Restrict plugins conflict
//
// Either way the CLI must NOT silently continue with a broken policy.
// Deprecated: installPluginConflictGuard produces a legacy *output.ExitError
// via its internal makeErr lambda. New code MUST NOT add such producers —
// plugin conflict failures should surface as a typed *errs.XxxError once the
// platform-extension framework migrates. This helper is retained only while
// existing call sites are migrated; it will be removed once they have moved
// to the typed surface.
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
makeErr := func() *output.ExitError {
envelopeType := "plugin_install"
makeErr := func() error {
reasonCode := internalplatform.ReasonInvalidRule
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
envelopeType = "plugin_conflict"
reasonCode = internalplatform.ReasonMultipleRestricts
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: envelopeType,
Message: err.Error(),
Detail: map[string]any{
"reason_code": reasonCode,
},
},
Err: err,
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
WithHint("plugin policy configuration is broken (reason_code %s); fix the plugin's Restrict rule or remove the conflicting plugin", reasonCode).
WithCause(err)
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
// failure as a plugin_lifecycle envelope. The reason_code splits
// returned-error vs panic so consumers (audit / on-call) can tell the
// two failure modes apart.
// Deprecated: installPluginLifecycleErrorGuard produces a legacy
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
// such producers — plugin lifecycle failures should surface as a typed
// *errs.XxxError once the platform-extension framework migrates. This
// helper is retained only while existing call sites are migrated; it will
// be removed once they have moved to the typed surface.
// failure as a typed validation error (failed_precondition). The hint's
// reason code splits returned-error vs panic so consumers (audit /
// on-call) can tell the two failure modes apart.
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
makeErr := func() *output.ExitError {
makeErr := func() error {
reasonCode := "lifecycle_failed"
detail := map[string]any{
"reason_code": reasonCode,
}
hookName := ""
var le *hook.LifecycleError
if errors.As(err, &le) {
if le.Panic {
reasonCode = "lifecycle_panic"
}
detail = map[string]any{
"reason_code": reasonCode,
"hook_name": le.HookName,
"event": "startup",
}
hookName = le.HookName
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_lifecycle",
Message: err.Error(),
Detail: detail,
},
Err: err,
typed := errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
WithCause(err)
if hookName != "" {
return typed.WithHint("plugin startup hook %q failed (reason_code %s); fix or remove the plugin before running commands", hookName, reasonCode)
}
return typed.WithHint("a plugin startup hook failed (reason_code %s); fix or remove the plugin before running commands", reasonCode)
}
installFatalGuard(rootCmd, makeErr)
}
@@ -219,14 +156,7 @@ func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
//
// This way the very first non-nil step in cobra's chain is always our
// guard, regardless of which leaf the user invoked.
// Deprecated: walkGuard accepts a *output.ExitError-producing lambda, part
// of the legacy error surface that predates the typed error contract
// introduced by errs/. New code MUST NOT add new callers — the platform-
// extension guard plumbing will switch to typed errs.* errors when the
// platform-extension framework migrates. This wrapper is retained only for
// the existing in-tree call sites; it will be removed once they have moved
// to the typed surface.
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
func walkGuard(cmd *cobra.Command, makeErr func() error) {
if cmd == nil {
return
}

View File

@@ -6,12 +6,14 @@ package cmd
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
@@ -32,7 +34,7 @@ func (failClosedAbortingPlugin) Install(platform.Registrar) error {
}
// When a FailClosed plugin fails to install, buildInternal must
// install a PersistentPreRunE that returns a structured *output.ExitError.
// install a PersistentPreRunE that returns a typed *errs.ValidationError.
// The user must NEVER see a silent partial-install state.
//
// This pins the build.go fix for codex's NEW ISSUE about
@@ -93,26 +95,31 @@ func TestBuildInternal_failClosedAbortsCLI(t *testing.T) {
checkGuardError(t, leaf.RunE(leaf, nil))
}
// checkGuardError asserts that err is the structured plugin_install
// ExitError the guard produces.
// checkGuardError asserts that err is the typed validation error the
// install guard produces: a failed_precondition *errs.ValidationError
// (exit 2) whose message + hint preserve the plugin name and the
// install_failed reason code (the recovery info that lived in the legacy
// detail map).
func checkGuardError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatalf("PersistentPreRunE must surface the install error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_install" {
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
detail := exitErr.Detail.Detail.(map[string]any)
if detail["plugin"] != "policy" {
t.Errorf("detail.plugin = %v, want policy", detail["plugin"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if detail["reason_code"] != internalplatform.ReasonInstallFailed {
t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"])
if !strings.Contains(verr.Hint, "policy") {
t.Errorf("hint should name the failing plugin %q, got %q", "policy", verr.Hint)
}
if !strings.Contains(verr.Hint, internalplatform.ReasonInstallFailed) {
t.Errorf("hint should surface reason_code %q, got %q", internalplatform.ReasonInstallFailed, verr.Hint)
}
}

View File

@@ -8,11 +8,13 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -156,19 +158,23 @@ func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
}
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
detail := exitErr.Detail.Detail.(map[string]any)
if detail["reason_code"] != "aborted" {
t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if detail["hook_name"] != "policy-plugin.policy" {
t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"])
// The namespaced hook name and the abort semantics are preserved in the
// message so a caller can identify which plugin hook rejected the call.
if !strings.Contains(verr.Message, "policy-plugin.policy") {
t.Errorf("message should name the aborting hook policy-plugin.policy, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "aborted") {
t.Errorf("message should describe the abort, got %q", verr.Message)
}
// errors.As must still reach the original AbortError so consumers
@@ -409,15 +415,20 @@ func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) {
t.Fatalf("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_conflict" {
t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" {
t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// reason_code multiple_restrict_plugins is folded into the hint so the
// operator can distinguish a multi-Restrict conflict from a bad rule.
if !strings.Contains(verr.Hint, "multiple_restrict_plugins") {
t.Errorf("hint should surface reason_code multiple_restrict_plugins, got %q", verr.Hint)
}
}
@@ -447,15 +458,20 @@ func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) {
t.Fatalf("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_install" {
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" {
t.Errorf("reason_code = %v, want invalid_rule", rc)
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// reason_code invalid_rule is folded into the hint, distinct from the
// multiple_restrict_plugins conflict path.
if !strings.Contains(verr.Hint, "invalid_rule") {
t.Errorf("hint should surface reason_code invalid_rule, got %q", verr.Hint)
}
}
@@ -484,19 +500,24 @@ func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) {
leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_lifecycle" {
t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "lifecycle_failed" {
t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if d["hook_name"] != "lc.start" {
t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
// reason_code lifecycle_failed (vs lifecycle_panic) and the failing
// hook name are folded into the hint so audit / on-call can tell the
// failure mode and which hook failed.
if !strings.Contains(verr.Hint, "lifecycle_failed") {
t.Errorf("hint should surface reason_code lifecycle_failed, got %q", verr.Hint)
}
if !strings.Contains(verr.Hint, "lc.start") {
t.Errorf("hint should name the failing hook lc.start, got %q", verr.Hint)
}
}
@@ -520,12 +541,20 @@ func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) {
}
leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" {
t.Errorf("reason_code = %v, want lifecycle_panic", rc)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// A panicking startup hook is distinguished from a returned error by
// reason_code lifecycle_panic in the hint.
if !strings.Contains(verr.Hint, "lifecycle_panic") {
t.Errorf("hint should surface reason_code lifecycle_panic, got %q", verr.Hint)
}
}
@@ -579,19 +608,24 @@ func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) {
}()
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "panic" {
t.Errorf("reason_code = %v, want panic", d["reason_code"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if d["hook_name"] != "p.boom" {
t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"])
// The recovered panic surfaces as a structured error naming the
// namespaced hook (p.boom) and describing the panic, so the process
// never crashes and the caller can attribute the failure.
if !strings.Contains(verr.Message, "p.boom") {
t.Errorf("message should name the namespaced hook p.boom, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "panic") {
t.Errorf("message should describe the panic, got %q", verr.Message)
}
}
@@ -653,19 +687,24 @@ func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) {
}()
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "panic" {
t.Errorf("reason_code = %v, want panic", d["reason_code"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if d["hook_name"] != "fac.bad-factory" {
t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
// A panic in the wrapper FACTORY (not just the inner handler) is
// recovered into the same structured panic error, naming the
// namespaced hook fac.bad-factory.
if !strings.Contains(verr.Message, "fac.bad-factory") {
t.Errorf("message should name the namespaced hook fac.bad-factory, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "panic") {
t.Errorf("message should describe the panic, got %q", verr.Message)
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
@@ -53,7 +54,9 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error {
if err := core.ValidateProfileName(name); err != nil {
return output.ErrValidation("%v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).
WithCause(err).
WithParam("--name")
}
langPref, err := cmdutil.ParseLangFlag(lang)
@@ -64,46 +67,57 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
// Read secret from stdin
if !appSecretStdin {
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret must be provided via stdin").
WithHint("use --app-secret-stdin and pipe the secret").
WithParam("--app-secret-stdin")
}
scanner := bufio.NewScanner(f.IOStreams.In)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return output.ErrValidation("failed to read secret from stdin: %v", err)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to read secret from stdin: %v", err).
WithCause(err).
WithParam("--app-secret-stdin")
}
return output.ErrValidation("stdin is empty, expected app secret")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret").
WithHint("pipe the app secret to stdin").
WithParam("--app-secret-stdin")
}
appSecret := strings.TrimSpace(scanner.Text())
if appSecret == "" {
return output.ErrValidation("app secret read from stdin is empty")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty").
WithHint("pipe a non-empty app secret to stdin").
WithParam("--app-secret-stdin")
}
// Load or create config
multi, err := core.LoadMultiAppConfig()
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return output.Errorf(output.ExitInternal, "internal", "failed to load config: %v", err)
return errs.NewInternalError(errs.SubtypeFileIO, "failed to load config: %v", err).WithCause(err)
}
multi = &core.MultiAppConfig{}
}
// Check name uniqueness
if multi.FindApp(name) != nil {
return output.ErrValidation("profile %q already exists", name)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", name).
WithHint("choose a different name, or remove the existing profile first").
WithParam("--name")
}
// Check app-id uniqueness — keychain stores secrets by appId, so
// multiple profiles sharing the same appId would collide on credentials.
for _, a := range multi.Apps {
if a.AppId == appID {
return output.ErrValidation("app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName())
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName()).
WithParam("--app-id")
}
}
// Store secret securely
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
return errs.NewInternalError(errs.SubtypeStorage, "%v", err).WithCause(err)
}
parsedBrand := core.ParseBrand(brand)
@@ -134,7 +148,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q added (%s, %s)", name, appID, parsedBrand))

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -45,7 +46,7 @@ func profileListRun(f *cmdutil.Factory) error {
output.PrintJson(f.IOStreams.Out, []profileListItem{})
return nil
}
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to load config: %v", err).WithCause(err)
}
if multi == nil || len(multi.Apps) == 0 {
output.PrintJson(f.IOStreams.Out, []profileListItem{})

View File

@@ -11,6 +11,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
@@ -50,6 +51,16 @@ func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
if !strings.Contains(err.Error(), "failed to load config") {
t.Fatalf("error = %v, want failed to load config", err)
}
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err)
}
if internalErr.Subtype != errs.SubtypeFileIO {
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
}
if code := output.ExitCodeOf(err); code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
}
}
// TestProfileAddRun_Lang covers the unified --lang contract on profile add:
@@ -95,9 +106,9 @@ func TestProfileAddRun_Lang(t *testing.T) {
if err == nil {
t.Fatal("expected validation error for --lang ZH, got nil")
}
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Code != output.ExitValidation {
t.Fatalf("expected ExitValidation, got %T: %v", err, err)
var valErr *errs.ValidationError
if !errors.As(err, &valErr) || output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("expected typed validation error with ExitValidation, got %T: %v", err, err)
}
})
}
@@ -406,17 +417,226 @@ func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
func assertInternalExitError(t *testing.T, err error, wantMsg string) {
t.Helper()
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; err=%v", err, err)
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err)
}
if exitErr.Code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
if internalErr.Subtype != errs.SubtypeStorage {
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeStorage)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "internal" {
t.Fatalf("detail = %#v, want internal detail", exitErr.Detail)
if internalErr.Cause == nil {
t.Fatalf("cause = nil, want wrapped underlying error")
}
if !strings.Contains(exitErr.Detail.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", exitErr.Detail.Message, wantMsg)
if !strings.Contains(internalErr.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", internalErr.Message, wantMsg)
}
if code := output.ExitCodeOf(err); code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
}
}
// assertValidationError asserts err is a typed *errs.ValidationError with the
// given subtype, message fragment, and exit code 2.
func assertValidationError(t *testing.T, err error, wantSubtype errs.Subtype, wantMsg string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
}
if valErr.Subtype != wantSubtype {
t.Fatalf("subtype = %q, want %q", valErr.Subtype, wantSubtype)
}
if !strings.Contains(valErr.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", valErr.Message, wantMsg)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
return valErr
}
func saveTwoProfiles(t *testing.T) {
t.Helper()
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
func TestProfileAddRun_ValidationErrors(t *testing.T) {
t.Run("invalid profile name", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "bad name!", "app-x", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "")
if valErr.Param != "--name" {
t.Fatalf("param = %q, want %q", valErr.Param, "--name")
}
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped validation error")
}
})
t.Run("missing app-secret-stdin flag", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileAddRun(f, "p", "app-x", false, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret must be provided via stdin")
if valErr.Param != "--app-secret-stdin" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin")
}
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
t.Run("empty stdin", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("")
err := profileAddRun(f, "p", "app-x", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "stdin is empty")
if valErr.Param != "--app-secret-stdin" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin")
}
})
t.Run("blank secret on stdin", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader(" \n")
err := profileAddRun(f, "p", "app-x", true, "feishu", "", false)
assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret read from stdin is empty")
})
t.Run("duplicate profile name", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "default", "app-new", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "default" already exists`)
if valErr.Param != "--name" {
t.Fatalf("param = %q, want %q", valErr.Param, "--name")
}
})
t.Run("duplicate app-id", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "fresh", "app-default", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "already used by profile")
if valErr.Param != "--app-id" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-id")
}
})
}
func TestProfileUseRun_ValidationErrors(t *testing.T) {
t.Run("no previous profile for toggle", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileUseRun(f, "-")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "no previous profile to switch back to")
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
t.Run("profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileUseRun(f, "ghost")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
}
func TestProfileRenameRun_ValidationErrors(t *testing.T) {
t.Run("invalid new name", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "default", "bad name!")
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "")
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped validation error")
}
})
t.Run("old profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "ghost", "fresh")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
t.Run("new name already exists", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "default", "target")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "target" already exists`)
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
}
func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
t.Run("profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRemoveRun(f, "ghost")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
t.Run("cannot remove the only profile", func(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "solo",
Apps: []core.AppConfig{
{Name: "solo", AppId: "app-solo", AppSecret: core.PlainSecret("secret-solo"), Brand: core.BrandFeishu},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRemoveRun(f, "solo")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "cannot remove the only profile")
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
}
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
dir := setupProfileConfigDir(t)
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileListRun(f)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "failed to load config")
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped load error")
}
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -40,11 +41,12 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
idx := multi.FindAppIndex(name)
if idx < 0 {
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
}
if len(multi.Apps) == 1 {
return output.ErrValidation("cannot remove the only profile")
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile").
WithHint("add another profile first: lark-cli profile add")
}
app := &multi.Apps[idx]
@@ -65,7 +67,7 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
// Best-effort credential cleanup after config commit

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
@@ -30,7 +31,7 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
if err := core.ValidateProfileName(newName); err != nil {
return output.ErrValidation("%v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
}
multi, err := core.LoadOrNotConfigured()
@@ -40,7 +41,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
idx := multi.FindAppIndex(oldName)
if idx < 0 {
return output.ErrValidation("profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
}
// Check new name uniqueness across other profiles, allowing renames to this
@@ -50,7 +51,8 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
continue
}
if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName {
return output.ErrValidation("profile %q already exists", newName)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", newName).
WithHint("choose a different name")
}
}
@@ -66,7 +68,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName))

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
@@ -40,14 +41,15 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
// Handle "-" for toggle-back
if name == "-" {
if multi.PreviousApp == "" {
return output.ErrValidation("no previous profile to switch back to")
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "no previous profile to switch back to").
WithHint("switch to a profile by name first: lark-cli profile use <name>")
}
name = multi.PreviousApp
}
app := multi.FindApp(name)
if app == nil {
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
}
targetName := app.ProfileName()
@@ -66,7 +68,7 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
multi.CurrentApp = targetName
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q (%s, %s)", targetName, app.AppId, app.Brand))

View File

@@ -9,10 +9,10 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// pruneForStrictMode removes commands incompatible with the active strict mode.
@@ -65,10 +65,10 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
// pick auth's instead of our denial. A leaf-level no-op makes
// cobra stop here and proceed to the wrapped RunE.
//
// strict-mode keeps its short Message + independent Hint and
// composes the shared detail.* / wrapped-CommandDeniedError shape
// by hand; BuildDenialError would override Message with the
// CommandDeniedError.Error() long form.
// strict-mode keeps its short Message + independent Hint and wraps
// the CommandDeniedError as the Cause by hand; BuildDenialError
// would override Message with the CommandDeniedError.Error() long
// form.
stubMessage := fmt.Sprintf(
"strict mode is %q, only %s-identity commands are available",
mode, mode.ForcedIdentity())
@@ -105,20 +105,9 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
},
RunE: func(c *cobra.Command, _ []string) error {
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
// Legacy *output.ExitError producer: this literal predates the
// typed error contract introduced by errs/. New denial sites MUST
// NOT construct *output.ExitError directly — they should return a
// typed *errs.XxxError once the cmdpolicy framework migrates.
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "command_denied",
Message: stubMessage,
Hint: stubHint,
Detail: cmdpolicy.DenialDetailMap(cd),
},
Err: cd,
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
WithCause(cd)
},
}
}

View File

@@ -8,6 +8,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -247,9 +248,12 @@ func TestStrictModeStub_BypassesArgsValidator(t *testing.T) {
}
}
// Pins the strict-mode envelope shape: structured detail.* / wrapped
// CommandDeniedError for external agents, AND the historical short
// Message + independent Hint for existing consumers.
// Pins the strict-mode typed envelope: a failed_precondition
// *errs.ValidationError (exit 2) carrying the short historical Message,
// a Hint that still surfaces the policy layer + reason code (the
// safety-critical recovery info that lived in the legacy detail map),
// and the wrapped *platform.CommandDeniedError so external agents can
// still inspect the structured denial taxonomy via errors.As.
func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
@@ -262,30 +266,33 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
t.Fatalf("strict-mode stub RunE should return error")
}
var ee *output.ExitError
if !errors.As(err, &ee) {
t.Fatalf("err is not *output.ExitError: %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("err is not *errs.ValidationError: %T", err)
}
if ee.Detail == nil {
t.Fatalf("ExitError.Detail is nil; envelope writer cannot emit JSON")
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if ee.Detail.Type != "command_denied" {
t.Errorf("Detail.Type = %q, want command_denied", ee.Detail.Type)
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
dm, ok := ee.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("Detail.Detail = %T, want map[string]any", ee.Detail.Detail)
// Short historical Message is preserved verbatim.
if verr.Message != `strict mode is "bot", only bot-identity commands are available` {
t.Errorf("Message = %q, want short historical form", verr.Message)
}
if got, _ := dm["layer"].(string); got != cmdpolicy.LayerStrictMode {
t.Errorf("Detail.Detail[layer] = %q, want %q", got, cmdpolicy.LayerStrictMode)
// The denial layer + reason code remain user-readable in the hint, and
// the historical switch-policy guidance is still appended.
if !strings.Contains(verr.Hint, cmdpolicy.LayerStrictMode) {
t.Errorf("Hint = %q, want substring %q (policy layer)", verr.Hint, cmdpolicy.LayerStrictMode)
}
if got, _ := dm["reason_code"].(string); got != "identity_not_supported" {
t.Errorf("Detail.Detail[reason_code] = %q, want identity_not_supported", got)
if !strings.Contains(verr.Hint, "identity_not_supported") {
t.Errorf("Hint = %q, want substring identity_not_supported (reason code)", verr.Hint)
}
if got, _ := dm["policy_source"].(string); got != "strict-mode" {
t.Errorf("Detail.Detail[policy_source] = %q, want strict-mode", got)
if !strings.Contains(verr.Hint, "if the user explicitly wants to switch policy") {
t.Errorf("Hint = %q, want historical switch-policy guidance", verr.Hint)
}
// The structured denial taxonomy survives on the wrapped cause.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("err does not unwrap to *platform.CommandDeniedError")
@@ -296,15 +303,12 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
if cd.ReasonCode != "identity_not_supported" {
t.Errorf("CommandDeniedError.ReasonCode = %q, want identity_not_supported", cd.ReasonCode)
}
if cd.PolicySource != "strict-mode" {
t.Errorf("CommandDeniedError.PolicySource = %q, want strict-mode", cd.PolicySource)
}
if !strings.Contains(cd.Reason, `strict mode is "bot"`) {
t.Errorf("CommandDeniedError.Reason = %q, want substring 'strict mode is \"bot\"'", cd.Reason)
}
if ee.Detail.Message != `strict mode is "bot", only bot-identity commands are available` {
t.Errorf("Detail.Message = %q, want short historical form", ee.Detail.Message)
}
if !strings.HasPrefix(ee.Detail.Hint, "if the user explicitly wants to switch policy") {
t.Errorf("Detail.Hint = %q, want historical hint", ee.Detail.Hint)
}
}
// strictModeStubFrom must write the denial annotations so the hook

View File

@@ -13,19 +13,17 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const rootLong = `lark-cli — Lark/Feishu CLI tool.
@@ -33,7 +31,7 @@ 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> [--format pretty]
lark-cli schema <service.resource.method>
EXAMPLES:
# View upcoming events
@@ -69,7 +67,15 @@ COMMUNITY:
More help: lark-cli <command> --help`
// Execute runs the root command and returns the process exit code.
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
// before they reach a group's RunE, so unknownSubcommandRunE re-derives them
// from here. It stays nil in unit tests that invoke a RunE directly with
// explicit args — correct, since those don't exercise the whitelist path.
var rawInvocationArgs []string
func Execute() int {
rawInvocationArgs = os.Args[1:]
inv, err := BootstrapInvocationContext(os.Args[1:])
if err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
@@ -133,29 +139,49 @@ func setupNotices() {
skillscheck.Init(build.Version)
// Composed notice provider — emits keys only when each pending is set.
output.PendingNotice = func() map[string]interface{} {
notice := map[string]interface{}{}
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
}
output.PendingNotice = composePendingNotice
}
// composePendingNotice merges all process-level pending notices (available
// update, skills/binary drift, deprecated-command alias) into the map surfaced
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
// Extracted from Execute so the composition is unit-testable.
func composePendingNotice() map[string]interface{} {
notice := map[string]interface{}{}
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
}
if stale := skillscheck.GetPending(); stale != nil {
notice["skills"] = map[string]interface{}{
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if len(notice) == 0 {
return nil
}
return notice
}
if stale := skillscheck.GetPending(); stale != nil {
notice["skills"] = map[string]interface{}{
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if dep := deprecation.GetPending(); dep != nil {
entry := map[string]interface{}{
"command": dep.Command,
"message": dep.Message(),
"action": "lark-cli update",
}
if dep.Replacement != "" {
entry["replacement"] = dep.Replacement
}
if dep.Skill != "" {
entry["skill"] = dep.Skill
}
notice["deprecated_command"] = entry
}
if len(notice) == 0 {
return nil
}
return notice
}
// isCompletionCommand returns true if args indicate a shell completion request.
@@ -186,56 +212,37 @@ func configureFlagCompletions(args []string) {
// and returns the process exit code.
//
// Dispatch order:
// 1. Legacy shapes (*core.ConfigError, *internalauth.NeedAuthorizationError)
// are promoted via errcompat to their typed errs/ counterparts, with the
// original preserved in the Cause chain.
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
// *errs.SecurityPolicyError, *errs.AuthenticationError): render via the
// typed envelope writer, which lifts extension fields (missing_scopes,
// console_url, challenge_url, ...) to the top level. Routed by
// errs.CategoryOf via ExitCodeOf.
// 3. Legacy *output.ExitError: asExitError adapts it to the legacy
// envelope, written via WriteErrorEnvelope.
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
// 1. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
// *errs.SecurityPolicyError, *errs.AuthenticationError, *errs.ConfigError):
// render via the typed envelope writer, which lifts extension fields
// (missing_scopes, console_url, challenge_url, ...) to the top level.
// Routed by errs.CategoryOf via ExitCodeOf. Auth and config errors are
// constructed typed at their origin (internal/auth, internal/core), so the
// dispatcher no longer promotes any legacy shape here.
// 2. PartialFailure / BareError signals: the result envelope is already on
// stdout; honor the exit code and write nothing to stderr.
// 3. Residual cobra usage errors (missing required flag, unknown command,
// argument validation): typed as an invalid_argument envelope (exit 2),
// matching the explicit flag/subcommand guards. Flag parse errors are
// already typed upstream by the root FlagErrorFunc.
func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut
// Promote legacy error shapes into typed errs/ before envelope marshal.
// NeedAuthorizationError check is first because it is the more specific
// shape; *core.ConfigError check follows. errors.As preserves the original
// in the Cause chain, so external errors.As(&core.ConfigError{}) consumers
// (cmd/auth/list.go, cmd/doctor/doctor.go, ...) still match.
//
// Outer-typed short-circuit: if err is already a typed *errs.* error,
// skip PromoteXxxError so the producer's Subtype / Hint / extension
// fields are not overwritten by a coarser promoted shape derived from a
// legacy error buried in its Cause chain. Promotion is only for legacy
// untyped entry points.
if !isOuterTypedError(err) {
var needAuthErr *internalauth.NeedAuthorizationError
if errors.As(err, &needAuthErr) {
err = errcompat.PromoteAuthError(needAuthErr)
} else {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
err = errcompat.PromoteConfigError(cfgErr)
}
}
}
// When the typed error is a need_user_authorization signal, fold in the
// current command's declared scopes as a Hint so the user/AI sees the
// concrete scope(s) to re-auth with. The hint is computed on the fly from
// local shortcut/service metadata — it never depends on server state.
applyNeedAuthorizationHint(f, err)
if !errs.IsRaw(err) {
applyNeedAuthorizationHint(f, err)
}
// Staged dispatch: capture the typed exit code BEFORE attempting the
// envelope write. WriteTypedErrorEnvelope is best-effort on the wire
// (partial-write still returns true) so the exit code we read here is
// preserved even if stderr is torn — torn stderr must not downgrade
// typed exits 3/4/6/10 to the legacy "Error:" path with exit 1.
// typed exits 3/4/6/10 to the plain "Error:" path with exit 1.
// WriteTypedErrorEnvelope still returns false when err carries no
// Problem; in that case we fall through to the legacy bridge below.
// Problem; in that case we fall through to the signal / plain-text paths.
typedExit := output.ExitCodeOf(err)
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
return typedExit
@@ -248,45 +255,63 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return pfErr.Code
}
if exitErr := asExitError(err); exitErr != nil {
if !exitErr.Raw {
// Raw errors (e.g. from `api` command via output.MarkRaw)
// preserve the original API error detail; skip enrichment
// which would clear it.
enrichMissingScopeError(f, exitErr)
enrichPermissionError(f, exitErr)
// Silent-exit signal (e.g. `auth check` predicate, or `update --json`):
// stdout already carries the result; honor the requested exit code and
// write nothing to stderr.
var bareErr *output.BareError
if errors.As(err, &bareErr) {
return bareErr.Code
}
// Errors reaching here are untyped: every RunE returns a typed errs.* error
// and flag-parse errors are typed by the root FlagErrorFunc. The remainder
// is either a cobra usage mistake (missing required flag, unknown command,
// wrong arg count), which cobra surfaces as a plain error identified by its
// stable text — the same external contract unknownFlagName relies on — or an
// untyped error that leaked past the typed boundary. Classify the former as
// invalid_argument (exit 2, like the explicit guards); treat the latter as an
// internal fault (exit 5) rather than blaming the user's input. The message
// is preserved either way, and the typed envelope still carries any pending
// deprecation notice.
var fallback error
if isCobraUsageError(err) {
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error())
} else {
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
}
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
return output.ExitCodeOf(fallback)
}
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
// not a typed value we can match on, so the dispatcher recognizes them by text;
// this is the same external contract unknownFlagName already depends on. A
// residual error matching none of these has leaked the typed boundary and is
// treated as an internal fault, not a user error.
var cobraUsageErrorMarkers = []string{
"unknown command ",
"unknown flag: ",
"unknown shorthand",
"required flag(s) ",
"flag needs an argument",
"bad flag syntax:",
"no such flag ",
"invalid argument ",
"arg(s), ", // accepts / requires N arg(s), received / only received M
}
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
// identified by the stable error text of the pinned cobra version.
func isCobraUsageError(err error) bool {
msg := err.Error()
for _, m := range cobraUsageErrorMarkers {
if strings.Contains(msg, m) {
return true
}
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
return exitErr.Code
}
fmt.Fprintln(errOut, "Error:", err)
return 1
}
// isOuterTypedError returns true if err is a typed *errs.* error AT THE
// TOP OF THE CHAIN (not buried inside Unwrap). Used by handleRootError
// to gate PromoteXxxError so a producer's outer typed envelope is never
// overwritten by a coarser shape derived from its legacy Cause.
func isOuterTypedError(err error) bool {
_, ok := err.(errs.TypedError)
return ok
}
// asExitError converts known structured error types to *output.ExitError.
// Returns nil for unrecognized errors (e.g. cobra flag errors).
//
// Deprecated: legacy *output.ExitError bridge.
func asExitError(err error) *output.ExitError {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return exitErr
}
return nil
return false
}
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
@@ -301,6 +326,12 @@ func asExitError(err error) *output.ExitError {
func installUnknownSubcommandGuard(cmd *cobra.Command) {
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
cmd.RunE = unknownSubcommandRunE
// Route an unknown subcommand to unknownSubcommandRunE even when flags
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
// consumes no flags itself, so unknown flags belong to the (missing)
// subcommand; whitelisting them here prevents cobra from erroring on the
// flag first and printing usage instead of our structured suggestion.
cmd.FParseErrWhitelist.UnknownFlags = true
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
@@ -311,41 +342,174 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
}
}
// Deprecated: unknownSubcommandRunE produces a legacy *output.ExitError that
// predates the typed error contract introduced by errs/. New code MUST NOT
// add producers of this shape — unknown-subcommand signals should move to
// a typed *errs.ValidationError (or a dedicated typed error) carrying the
// agent-protocol metadata as typed extension fields. This helper is retained
// only while existing dispatch sites are migrated; it will be removed once
// they have moved to the typed surface.
// unknownSubcommandRunE replaces cobra's silent help fallback on group commands
// with a typed *errs.ValidationError: a flag that belongs to a missing
// subcommand, a misplaced subcommand-only flag, or an unknown subcommand name
// each fail structured (exit 2) instead of degrading to help + exit 0.
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Help()
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
// like the global --profile, legitimately prints help. But a flag that
// belongs to a (missing) subcommand is a user error: the guard's
// FParseErrWhitelist swallows such flags and leaves args empty, so without
// the checks below they would silently fall through to help + exit 0 —
// letting an agent mistake a malformed call (`im --format json`,
// `sheets --badflag`) for success. Recover the swallowed tokens from the
// raw invocation and fail structured instead.
flags := flagTokensInArgs(rawInvocationArgs)
if len(flags) == 0 {
return cmd.Help()
}
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()).
WithHint("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath())
for _, flag := range unknown {
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "unknown flag before a subcommand"})
}
return verr
}
// The remaining flags are all defined somewhere in the tree. Those valid
// on the group itself or inherited (e.g. the global --profile) do not
// require a subcommand, so a bare group carrying only those still prints
// help. Anything left belongs to a subcommand that was omitted
// (e.g. `im --format json`): distinct from unknown_flag — the flags are
// real, the subcommand is what's missing.
misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs)
if len(misplaced) == 0 {
return cmd.Help()
}
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")).
WithHint("run `%s --help` to list subcommands and their flags", cmd.CommandPath())
for _, flag := range misplaced {
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "flag belongs to a subcommand, not the group"})
}
return verr
}
unknown := args[0]
available := availableSubcommandNames(cmd)
available, deprecated := availableSubcommandNames(cmd)
// Rank suggestions across both current and deprecated names so a mistyped
// legacy command (e.g. +raed → +read) still resolves; the alias stays
// runnable and self-flags via the _notice on execution.
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
if len(available) > 0 {
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_subcommand",
Message: msg,
Hint: hint,
Detail: map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"available": available,
},
},
if len(suggestions) > 0 {
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
strings.Join(suggestions, ", "), cmd.CommandPath())
}
// Record the offending subcommand and its ranked candidates as a param with
// machine-readable Suggestions so an agent can retry without parsing the
// hint; the hint carries the same candidates as prose.
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
WithParams(errs.InvalidParam{Name: unknown, Reason: "unknown subcommand", Suggestions: suggestions}).
WithHint("%s", hint)
}
func availableSubcommandNames(cmd *cobra.Command) []string {
subs := make([]string, 0, len(cmd.Commands()))
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
// rawArgs, stopping at the "--" positional terminator. Whether a flag is
// defined is not considered (see unknownFlagTokens for that). A pure group
// with any flag token but no subcommand is a user error — a pure group
// consumes no flags of its own, so the flag must belong to a subcommand — so
// the caller fails structured instead of falling through to help.
func flagTokensInArgs(rawArgs []string) []string {
var toks []string
for _, a := range rawArgs {
if a == "--" {
break // everything after -- is positional
}
if len(a) < 2 || a[0] != '-' {
continue
}
toks = append(toks, a)
}
return toks
}
// unknownFlagTokens returns the flag tokens in rawArgs that cmd does not define
// (on itself, inherited, or any direct subcommand). installUnknownSubcommandGuard
// whitelists unknown flags on pure groups so a mistyped subcommand still reaches
// the suggestion path; the side effect is that flags before a subcommand are
// swallowed. This recovers the genuinely-unknown ones so the caller can name
// them in a "did you mean" envelope.
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
var unknown []string
for _, a := range flagTokensInArgs(rawArgs) {
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
if name != "" && !flagDefinedInTree(cmd, name) {
unknown = append(unknown, a)
}
}
return unknown
}
// flagKnownOnGroup reports whether name is a flag defined on cmd itself or
// inherited (a global persistent flag like --profile) — i.e. valid on the bare
// group and therefore not requiring a subcommand.
func flagKnownOnGroup(cmd *cobra.Command, name string) bool {
short := len(name) == 1
lookup := func(fs *pflag.FlagSet) bool {
if short {
return fs.ShorthandLookup(name) != nil
}
return fs.Lookup(name) != nil
}
return lookup(cmd.Flags()) || lookup(cmd.InheritedFlags())
}
// subcommandOnlyFlagTokens returns the flag tokens in rawArgs that are valid on
// a subcommand of cmd but not on cmd itself/inherited — flags supplied while
// omitting the subcommand they belong to (`im --format json`). Global flags
// valid on the bare group (e.g. --profile) are excluded so
// `lark-cli --profile p im` still prints help rather than erroring.
func subcommandOnlyFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
var misplaced []string
for _, a := range flagTokensInArgs(rawArgs) {
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
if name == "" || flagKnownOnGroup(cmd, name) {
continue
}
if flagDefinedInTree(cmd, name) {
misplaced = append(misplaced, a)
}
}
return misplaced
}
// flagDefinedInTree reports whether name is defined on cmd, its inherited
// (persistent) flags, or any direct subcommand. The subcommand case covers a
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
// --format is injected on every leaf shortcut, not on the group — so only a
// genuinely unknown flag like `sheets --badflag` is reported.
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
short := len(name) == 1
known := func(c *cobra.Command, inherited bool) bool {
fs := c.Flags()
if inherited {
fs = c.InheritedFlags()
}
if short {
return fs.ShorthandLookup(name) != nil
}
return fs.Lookup(name) != nil
}
if known(cmd, false) || known(cmd, true) {
return true
}
for _, c := range cmd.Commands() {
if known(c, false) {
return true
}
}
return false
}
// availableSubcommandNames returns the invokable subcommand names of cmd, split
// into current commands and backward-compatibility aliases (those tagged into
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
// sorted; hidden commands plus help/completion are omitted.
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
for _, c := range cmd.Commands() {
if c.Hidden || !c.IsAvailableCommand() {
continue
@@ -354,10 +518,82 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
if name == "help" || name == "completion" {
continue
}
subs = append(subs, name)
if cmdutil.IsDeprecatedCommand(c) {
deprecated = append(deprecated, name)
} else {
available = append(available, name)
}
}
sort.Strings(subs)
return subs
sort.Strings(available)
sort.Strings(deprecated)
return available, deprecated
}
// 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
// the typo is semantic, e.g. --query vs --find, where edit distance alone finds
// nothing) and the offending flag in `params`. Other flag errors stay typed
// but generic.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if len(suggestions) > 0 {
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
// The ranked candidates ride on the param as machine-readable Suggestions so
// an agent can retry without parsing the hint; the hint carries the same
// candidates as prose. The full valid-flag list stays recoverable via --help.
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
WithHint("%s", hint)
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
// those structured but generic — hallucinated flags are essentially always long.
//
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
// silently fails and unknown flags degrade to a generic flag_error — re-verify
// this prefix when bumping cobra.
func unknownFlagName(err error) (string, bool) {
const p = "unknown flag: --"
msg := err.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
}
rest := msg[i+len(p):]
if j := strings.IndexAny(rest, " \t"); j >= 0 {
rest = rest[:j]
}
return rest, true
}
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
// the valid_flags detail).
func visibleFlagNames(c *cobra.Command) []string {
var names []string
c.Flags().VisitAll(func(f *pflag.Flag) {
if !f.Hidden {
names = append(names, f.Name)
}
})
sort.Strings(names)
return names
}
// installTipsHelpFunc wraps the default help function to append a TIPS section
@@ -391,56 +627,3 @@ func installTipsHelpFunc(root *cobra.Command) {
}
})
}
// enrichPermissionError rewrites the legacy *output.ExitError envelope so its
// Message + Hint match the per-subtype canonical text produced by the typed
// dispatcher path (errclass.CanonicalPermissionMessage / errclass.PermissionHint).
// This guarantees a caller observing the wire envelope cannot tell whether
// the error reached the dispatcher via the legacy *ExitError bridge or via
// the typed *errs.PermissionError fast path.
//
// Deprecated: legacy *output.ExitError enrichment; typed PermissionError
// values produced by errclass.BuildAPIError already carry MissingScopes +
// ConsoleURL directly.
func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr.Detail == nil {
return
}
// Only the legacy permission-class envelope types route here. "app_status"
// covers 99991662 (app_disabled) / 99991673 (app_unavailable); "permission"
// covers the four scope-class codes (99991672 / 99991676 / 99991679 / 230027).
if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "app_status" {
return
}
larkCode := exitErr.Detail.Code
meta, ok := errclass.LookupCodeMeta(larkCode)
if !ok || meta.Category != errs.CategoryAuthorization {
return
}
// Extract required scopes from API error detail (shared helper). May be
// empty for app-status codes — canonical message + hint still apply.
missing := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
cfg, err := f.Config()
if err != nil {
return
}
// Reuse the same console URL builder as the typed path so both wire
// envelopes carry identical console_url values for the same input.
consoleURL := errclass.ConsoleURL(string(cfg.Brand), cfg.AppID, missing)
// Clear raw API detail — useful info is now in message/hint/console_url.
exitErr.Detail.Detail = nil
identity := string(f.ResolvedIdentity)
if identity == "" {
identity = "user"
}
exitErr.Detail.Message = errclass.CanonicalPermissionMessage(meta.Subtype, cfg.AppID, missing, exitErr.Detail.Message)
exitErr.Detail.Hint = errclass.PermissionHint(missing, identity, meta.Subtype, consoleURL)
exitErr.Detail.ConsoleURL = consoleURL
}

View File

@@ -8,7 +8,6 @@ import (
"context"
"encoding/json"
"os"
"reflect"
"strings"
"testing"
@@ -27,12 +26,12 @@ import (
"github.com/spf13/cobra"
)
// Canonical strict-mode envelope strings shared across fixtures
// (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom).
// Canonical strict-mode envelope messages shared across fixtures. The
// switch-policy hint text is asserted by substring in
// assertStrictModeDenialEnvelope.
const (
strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available`
strictModeUserMessage = `strict mode is "user", only user-identity commands are available`
strictModeHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)"
)
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
@@ -63,37 +62,46 @@ func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Com
return 0
}
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
// typedErrorEnvelope mirrors the typed wire shape produced by
// WriteTypedErrorEnvelope: the inner error marshals an errs.Problem
// directly, so "type" is the category, "subtype" is top-level, and there
// is no nested "detail" object. Recovery info (policy source, reason
// code, suggestions) is folded into "hint".
type typedErrorEnvelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Error struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
Message string `json:"message"`
Hint string `json:"hint"`
Param string `json:"param,omitempty"`
} `json:"error"`
}
// parseTypedEnvelope decodes stderr as the typed envelope and fails if the
// legacy nested "detail" object is present (the migration removed it).
func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
t.Helper()
if stderr.Len() == 0 {
t.Fatal("expected non-empty stderr, got empty")
}
var env output.ErrorEnvelope
var raw map[string]any
if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil {
t.Fatalf("failed to parse stderr as JSON: %v\nstderr: %s", err, stderr.String())
}
if errObj, ok := raw["error"].(map[string]any); ok {
if _, hasDetail := errObj["detail"]; hasDetail {
t.Errorf("typed envelope must not carry a nested 'detail' object, got: %s", stderr.String())
}
}
var env typedErrorEnvelope
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
t.Fatalf("failed to parse stderr as typed envelope: %v\nstderr: %s", err, stderr.String())
}
return env
}
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
// expected ErrorEnvelope exactly via reflect.DeepEqual.
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
t.Helper()
if code != wantCode {
t.Errorf("exit code: got %d, want %d", code, wantCode)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
got := parseEnvelope(t, stderr)
if !reflect.DeepEqual(got, want) {
gotJSON, _ := json.MarshalIndent(got, "", " ")
wantJSON, _ := json.MarshalIndent(want, "", " ")
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
}
}
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"}
@@ -205,23 +213,71 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
// auth login is user-only, so it gets pruned in strict-mode-bot and the
// stub error fires (not login.go's inline check, which is shadowed by
// pruning).
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeBotMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "auth/login",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeBotMessage,
},
},
})
// pruning). The typed envelope is a failed_precondition validation
// error (exit 2); the strict-mode layer + reason code are folded into
// the hint.
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage)
}
// assertStrictModeDenialEnvelope pins the shared strict-mode denial shape:
// a validation/failed_precondition envelope whose message is the short
// historical strict-mode line and whose hint still names the strict_mode
// layer + identity_not_supported reason code (the safety-critical recovery
// info), plus the historical switch-policy guidance.
func assertStrictModeDenialEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) {
t.Helper()
if env.OK {
t.Errorf("envelope ok = true, want false")
}
if env.Error.Type != "validation" {
t.Errorf("error.type = %q, want validation", env.Error.Type)
}
if env.Error.Subtype != "failed_precondition" {
t.Errorf("error.subtype = %q, want failed_precondition", env.Error.Subtype)
}
if env.Error.Message != wantMessage {
t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage)
}
if !strings.Contains(env.Error.Hint, "strict_mode") {
t.Errorf("error.hint = %q, want substring strict_mode (policy layer)", env.Error.Hint)
}
if !strings.Contains(env.Error.Hint, "identity_not_supported") {
t.Errorf("error.hint = %q, want substring identity_not_supported (reason code)", env.Error.Hint)
}
if !strings.Contains(env.Error.Hint, "config strict-mode --help") {
t.Errorf("error.hint = %q, want historical switch-policy guidance", env.Error.Hint)
}
}
// assertCheckStrictModeEnvelope pins the typed envelope produced by
// cmdutil.Factory.CheckStrictMode (the identity-guard path for explicit
// --as on shortcuts / service methods / api): a *errs.ValidationError with
// subtype invalid_argument, the canonical strict-mode message, and the
// switch-policy hint.
func assertCheckStrictModeEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) {
t.Helper()
if env.OK {
t.Errorf("envelope ok = true, want false")
}
if env.Error.Type != "validation" {
t.Errorf("error.type = %q, want validation", env.Error.Type)
}
if env.Error.Subtype != "invalid_argument" {
t.Errorf("error.subtype = %q, want invalid_argument", env.Error.Subtype)
}
if env.Error.Message != wantMessage {
t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage)
}
if !strings.Contains(env.Error.Hint, "config strict-mode --help") {
t.Errorf("error.hint = %q, want switch-policy guidance", env.Error.Hint)
}
}
func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) {
@@ -232,22 +288,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
"im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeBotMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "im/+messages-search",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeBotMessage,
},
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage)
}
func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) {
@@ -277,15 +325,14 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
"im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "user", only user-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeUserMessage)
}
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
@@ -296,15 +343,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage)
}
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
@@ -315,22 +361,14 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeUserMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "im/images/create",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeUserMessage,
},
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeUserMessage)
}
func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) {
@@ -341,15 +379,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage)
}
// --- shortcut command ---
@@ -372,16 +409,43 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
})
// shortcut: typed error via DoAPIJSON path
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api_error",
Code: 230002,
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
},
})
// shortcut: typed errs.APIError via the CallAPITyped → BuildAPIError path.
if code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", code, output.ExitAPI)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
if stderr.Len() == 0 {
t.Fatal("expected non-empty stderr, got empty")
}
var raw struct {
OK bool `json:"ok"`
Identity string `json:"identity"`
Error struct {
Type string `json:"type"`
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil {
t.Fatalf("failed to parse typed envelope: %v\nstderr: %s", err, stderr.String())
}
if raw.OK {
t.Errorf("envelope ok = true, want false")
}
if raw.Identity != "bot" {
t.Errorf("identity = %q, want bot", raw.Identity)
}
if raw.Error.Type != "api" {
t.Errorf("error.type = %q, want api", raw.Error.Type)
}
if raw.Error.Code != 230002 {
t.Errorf("error.code = %d, want 230002", raw.Error.Code)
}
if raw.Error.Message != "Bot/User can NOT be out of the chat." {
t.Errorf("error.message = %q, want %q", raw.Error.Message, "Bot/User can NOT be out of the chat.")
}
}
// TestSetupNotices_ColdStart_NoNotice verifies that missing state

View File

@@ -21,6 +21,7 @@ import (
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
)
@@ -136,9 +137,6 @@ func TestIsCompletionCommand(t *testing.T) {
}
}
// TestPromoteConfigError_* lives with the implementation in
// internal/errcompat/promote_test.go.
// TestHandleRootError_SecurityPolicyCanonicalEnvelope verifies that
// *errs.SecurityPolicyError flows through the canonical typed envelope
// (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype,
@@ -268,6 +266,173 @@ func (f *failingWriter) Write(p []byte) (int, error) {
return len(p), nil
}
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins that a
// backward-compat alias failing on a cobra-level required flag (which
// short-circuits before RunE) routes through the structured envelope, so the
// deprecation notice OnInvoke records in PreRunE is carried on the wire instead
// of being dropped on a plain "Error:" line.
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
deprecation.SetPending(&deprecation.Notice{
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
})
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
// errs.* error, so it reaches the deprecation fallback.
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out)
}
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
}
// The envelope is typed validation, so the exit code must derive from that
// category (2) — the wire type and the exit code must not disagree.
if exit != int(output.ExitValidation) {
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
}
}
// TestHandleRootError_AuthConfigWireGolden is the wire-consistency regression
// baseline for auth/config errors: it pins the typed envelope and exit code the
// dispatcher produces for the two source-of-truth shapes, which are constructed
// typed at their origin in internal/auth and internal/core.
func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Run("token missing exits 3 with token_missing authentication envelope", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"))
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "authentication" {
t.Errorf("error.type = %v, want %q", got, "authentication")
}
if got := errObj["subtype"]; got != "token_missing" {
t.Errorf("error.subtype = %v, want %q", got, "token_missing")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "need_user_authorization") {
t.Errorf("error.message = %q, must keep the need_user_authorization marker", got)
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "u_golden") {
t.Errorf("error.message = %q, must carry the user open id", got)
}
if got, _ := errObj["hint"].(string); !strings.Contains(got, "auth login") {
t.Errorf("error.hint = %q, must point at auth login", got)
}
if got := errObj["user_open_id"]; got != "u_golden" {
t.Errorf("error.user_open_id = %v, want %q", got, "u_golden")
}
})
t.Run("not configured exits 3 with not_configured config envelope", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, core.NotConfiguredError())
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (config shares ExitAuth)", exit, int(output.ExitAuth))
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "config" {
t.Errorf("error.type = %v, want %q", got, "config")
}
if got := errObj["subtype"]; got != "not_configured" {
t.Errorf("error.subtype = %v, want %q", got, "not_configured")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "not configured") {
t.Errorf("error.message = %q, want the not-configured message", got)
}
if got, _ := errObj["hint"].(string); !strings.Contains(got, "config init") {
t.Errorf("error.hint = %q, must point at config init", got)
}
})
}
// decodeErrorEnvelope unmarshals a typed error envelope and returns its
// top-level "error" object, failing the test if the shape is unexpected.
func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any {
t.Helper()
var env map[string]any
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("envelope is not valid JSON: %v\n%s", err, raw)
}
errObj, ok := env["error"].(map[string]any)
if !ok {
t.Fatalf("envelope missing top-level error object: %s", raw)
}
return errObj
}
// TestHandleRootError_NoDeprecationTypesUsageError pins that a residual cobra
// usage error (missing required flag) is typed as invalid_argument with exit 2
// even with no deprecation pending — never cobra's plain "Error:" line.
func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
t.Fatalf("want a structured envelope, got a plain Error: line:\n%s", out)
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "validation" {
t.Errorf("error.type = %v, want %q", got, "validation")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "values") {
t.Errorf("error.message = %q, must carry the failing flag name", got)
}
if exit != int(output.ExitValidation) {
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
}
}
// TestHandleRootError_LeakedUntypedErrorBecomesInternal pins that an untyped
// error that does NOT match a cobra usage shape (i.e. one that leaked past the
// typed boundary from a helper) is classified as an internal fault (exit 5),
// not blamed on the user's input as a validation error.
func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF))
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "internal" {
t.Errorf("error.type = %v, want %q (leaked untyped error must not be mislabeled validation)", got, "internal")
}
if exit != int(output.ExitInternal) {
t.Errorf("exit = %d, want %d (internal envelope → category-derived exit)", exit, int(output.ExitInternal))
}
}
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
// stderr write fails mid-envelope, handleRootError still returns the typed
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the
@@ -288,12 +453,32 @@ func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) {
}
}
// TestHandleRootError_TypedOuterShortCircuitsPromote pins that when a typed
// *errs.AuthenticationError carries a legacy *NeedAuthorizationError in its
// Cause chain, the dispatcher does NOT run PromoteAuthError — doing so
// would replace the producer's TokenExpired subtype + custom hint with the
// promoted shape's TokenMissing.
func TestHandleRootError_TypedOuterShortCircuitsPromote(t *testing.T) {
// TestHandleRootError_BareErrorExitCodeNoStderr pins the silent-exit
// contract: a *output.BareError is honored for its exit code while stderr stays
// empty (stdout already carries the result, so the dispatcher must not layer a
// second envelope on top).
func TestHandleRootError_BareErrorExitCodeNoStderr(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, output.ErrBare(output.ExitAuth))
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (BareError code propagated)", exit, int(output.ExitAuth))
}
if errOut.Len() != 0 {
t.Errorf("stderr must stay empty for a bare predicate signal, got:\n%s", errOut.String())
}
}
// TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved pins that a typed
// *errs.AuthenticationError carrying a legacy *NeedAuthorizationError in its
// Cause chain renders the producer's TokenExpired subtype + custom hint
// verbatim — the legacy sentinel in the Cause chain never coarsens the wire
// shape.
func TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -445,136 +630,3 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
t.Errorf("expected appended hint %q, got %q", want, authErr.Hint)
}
}
// TestEnrichPermissionError_CanonicalConvergence pins that the legacy
// *output.ExitError dispatch path produces the same canonical Message + Hint
// + ConsoleURL as the typed *errs.PermissionError dispatch path. Both paths
// share errclass.CanonicalPermissionMessage / errclass.PermissionHint /
// errclass.ConsoleURL — so a wire consumer cannot tell which path produced
// the envelope.
func TestEnrichPermissionError_CanonicalConvergence(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
larkCode int
legacyErrType string
wantMsgSubstrs []string
wantHintSubstrs []string
wantConsoleURL bool
wantNoAuthLogin bool // hint must not suggest `auth login`
}{
{
name: "99991672 app_scope_not_applied",
larkCode: 99991672,
legacyErrType: "permission",
wantMsgSubstrs: []string{"access denied", "app cli_test", "drive:drive:read"},
wantHintSubstrs: []string{"developer console", "open.feishu.cn"},
wantConsoleURL: true,
wantNoAuthLogin: true,
},
{
name: "99991679 missing_scope",
larkCode: 99991679,
legacyErrType: "permission",
wantMsgSubstrs: []string{"unauthorized", "user authorization"},
wantHintSubstrs: []string{"lark-cli auth login"},
},
{
name: "99991673 app_unavailable",
larkCode: 99991673,
legacyErrType: "app_status",
wantMsgSubstrs: []string{"unauthorized app", "app cli_test", "not properly installed"},
wantHintSubstrs: []string{"tenant admin", "install status"},
},
{
name: "99991662 app_disabled",
larkCode: 99991662,
legacyErrType: "app_status",
wantMsgSubstrs: []string{"app cli_test", "not in use", "currently disabled"},
wantHintSubstrs: []string{"tenant admin", "re-enable"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
// Mimic the wire shape ErrAPI produces: legacy *ExitError with
// Detail.Type populated by ClassifyLarkError, Detail.Detail
// carrying the permission_violations block so ExtractRequiredScopes
// can recover the missing scope.
scopeForDetail := "drive:drive:read"
exitErr := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: tc.legacyErrType,
Code: tc.larkCode,
Message: "upstream raw message — must be replaced",
Detail: map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": scopeForDetail},
},
},
},
}
enrichPermissionError(f, exitErr)
for _, sub := range tc.wantMsgSubstrs {
if !strings.Contains(exitErr.Detail.Message, sub) {
t.Errorf("Message %q missing substring %q", exitErr.Detail.Message, sub)
}
}
if exitErr.Detail.Message == "upstream raw message — must be replaced" {
t.Errorf("Message must be rewritten to canonical text; got upstream verbatim")
}
for _, sub := range tc.wantHintSubstrs {
if !strings.Contains(exitErr.Detail.Hint, sub) {
t.Errorf("Hint %q missing substring %q", exitErr.Detail.Hint, sub)
}
}
if tc.wantNoAuthLogin && strings.Contains(exitErr.Detail.Hint, "auth login") {
t.Errorf("Hint must not suggest `auth login` for this subtype; got %q", exitErr.Detail.Hint)
}
if tc.wantConsoleURL && exitErr.Detail.ConsoleURL == "" {
t.Error("ConsoleURL should be populated when missing scopes are present")
}
})
}
}
// TestEnrichPermissionError_SkipsUnrelatedTypes pins that an ExitError whose
// Detail.Type is neither "permission" nor "app_status" is left untouched —
// no Message rewrite, no Hint rewrite, no ConsoleURL injection.
func TestEnrichPermissionError_SkipsUnrelatedTypes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
for _, ty := range []string{"api_error", "validation", "rate_limit", "auth"} {
exitErr := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: ty,
Code: 99991400,
Message: "untouched",
Hint: "original hint",
},
}
enrichPermissionError(f, exitErr)
if exitErr.Detail.Message != "untouched" {
t.Errorf("type=%q: Message was rewritten unexpectedly: %q", ty, exitErr.Detail.Message)
}
if exitErr.Detail.Hint != "original hint" {
t.Errorf("type=%q: Hint was rewritten unexpectedly: %q", ty, exitErr.Detail.Hint)
}
if exitErr.Detail.ConsoleURL != "" {
t.Errorf("type=%q: ConsoleURL should not be injected; got %q", ty, exitErr.Detail.ConsoleURL)
}
}
}

View File

@@ -5,17 +5,17 @@ package schema
import (
"context"
"fmt"
"errors"
"io"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/schema"
"github.com/larksuite/cli/internal/util"
"github.com/spf13/cobra"
)
@@ -24,336 +24,10 @@ type SchemaOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
// Positional args
Path string // first positional, when only one is given
ExtraArgs []string // 2nd+ positional args (space-separated form)
// Flags
Format string
}
func printServices(w io.Writer) {
services := registry.ListFromMetaProjects()
fmt.Fprintf(w, "%sAvailable services:%s\n\n", output.Bold, output.Reset)
for _, s := range services {
spec := registry.LoadFromMeta(s)
title := registry.GetStrFromMap(spec, "title")
if title == "" {
title = registry.GetStrFromMap(spec, "description")
}
fmt.Fprintf(w, " %s%s%s %s%s%s\n", output.Cyan, s, output.Reset, output.Dim, title, output.Reset)
}
fmt.Fprintf(w, "\n%sUsage: lark-cli schema <service>.<resource>.<method>%s\n", output.Dim, output.Reset)
}
func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) {
name := registry.GetStrFromMap(spec, "name")
version := registry.GetStrFromMap(spec, "version")
title := registry.GetStrFromMap(spec, "title")
if title == "" {
title = registry.GetStrFromMap(spec, "description")
}
servicePath := registry.GetStrFromMap(spec, "servicePath")
fmt.Fprintf(w, "%s%s%s (%s) — %s\n\n", output.Bold, name, output.Reset, version, title)
fmt.Fprintf(w, "%sBase path: %s%s\n\n", output.Dim, servicePath, output.Reset)
resources, _ := spec["resources"].(map[string]interface{})
for _, resName := range sortedKeys(resources) {
resMap, _ := resources[resName].(map[string]interface{})
methods, _ := resMap["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
if len(methods) == 0 {
continue
}
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
for _, methodName := range sortedKeys(methods) {
m, _ := methods[methodName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
desc := registry.GetStrFromMap(m, "description")
danger := ""
if d, _ := m["danger"].(bool); d {
danger = fmt.Sprintf(" %s[danger]%s", output.Red, output.Reset)
}
fmt.Fprintf(w, " %-7s %s%s%s %s%s%s%s\n", httpMethod, output.Bold, methodName, output.Reset, output.Dim, desc, output.Reset, danger)
}
fmt.Fprintln(w)
}
fmt.Fprintf(w, "%sUsage: lark-cli schema %s.<resource>.<method>%s\n", output.Dim, name, output.Reset)
}
// hasFileFields returns true if any requestBody field has type "file".
func hasFileFields(method map[string]interface{}) (bool, []string) {
names := cmdutil.DetectFileFields(method)
return len(names) > 0, names
}
func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) {
servicePath := registry.GetStrFromMap(spec, "servicePath")
specName := registry.GetStrFromMap(spec, "name")
methodPath := registry.GetStrFromMap(method, "path")
fullPath := servicePath + "/" + methodPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
desc := registry.GetStrFromMap(method, "description")
isFileUpload, fileFieldNames := hasFileFields(method)
fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset)
httpColor := output.Yellow
if httpMethod == "GET" {
httpColor = output.Green
} else if httpMethod == "DELETE" {
httpColor = output.Red
}
fmt.Fprintf(w, " %s%s%s %s\n", httpColor, httpMethod, output.Reset, fullPath)
if desc != "" {
fmt.Fprintf(w, " %s\n", desc)
}
fmt.Fprintln(w)
// Parameters
params, _ := method["parameters"].(map[string]interface{})
if len(params) > 0 {
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
fmt.Fprintf(w, " %s--params%s <json> %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
for _, paramName := range sortedParamKeys(params) {
p, _ := params[paramName].(map[string]interface{})
pType := registry.GetStrFromMap(p, "type")
if pType == "" {
pType = "string"
}
location := registry.GetStrFromMap(p, "location")
required, _ := p["required"].(bool)
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
if required {
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
}
locColor := output.Dim
if location == "path" {
locColor = output.Yellow
}
// Options (enum values)
optStr := formatOptions(p)
fmt.Fprintf(w, " - %s%s%s (%s, %s%s%s, %s)%s\n", output.Cyan, paramName, output.Reset, pType, locColor, location, output.Reset, reqStr, optStr)
if pdesc := registry.GetStrFromMap(p, "description"); pdesc != "" {
pdesc = util.TruncateStrWithEllipsis(pdesc, 100)
fmt.Fprintf(w, " %s%s%s\n", output.Dim, pdesc, output.Reset)
}
if ex := registry.GetStrFromMap(p, "example"); ex != "" {
fmt.Fprintf(w, " %se.g. %s%s\n", output.Dim, ex, output.Reset)
}
if rangeStr := formatRange(p); rangeStr != "" {
fmt.Fprintf(w, " %srange: %s%s\n", output.Dim, rangeStr, output.Reset)
}
}
fmt.Fprintln(w)
}
// --data for write methods
if httpMethod == "POST" || httpMethod == "PUT" || httpMethod == "PATCH" || httpMethod == "DELETE" {
if len(params) == 0 {
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
}
fileUploadTag := ""
if isFileUpload {
fileUploadTag = fmt.Sprintf(" %s[file upload]%s", output.Yellow, output.Reset)
}
fmt.Fprintf(w, " %s--data%s <json> %soptional%s%s\n", output.Cyan, output.Reset, output.Dim, output.Reset, fileUploadTag)
requestBody, _ := method["requestBody"].(map[string]interface{})
if len(requestBody) > 0 {
printNestedFields(w, requestBody, " ", "")
}
if isFileUpload {
if len(fileFieldNames) == 1 {
fmt.Fprintf(w, "\n %s--file%s <[field=]path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
fmt.Fprintf(w, " Upload file as multipart/form-data. Default field: %q\n", fileFieldNames[0])
} else {
fmt.Fprintf(w, "\n %s--file%s <field=path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
fmt.Fprintf(w, " Upload file as multipart/form-data. Fields: %s\n", strings.Join(fileFieldNames, ", "))
}
}
fmt.Fprintln(w)
}
// Response
responseBody, _ := method["responseBody"].(map[string]interface{})
if len(responseBody) > 0 {
fmt.Fprintf(w, "%sResponse:%s\n\n", output.Bold, output.Reset)
printNestedFields(w, responseBody, " ", "")
fmt.Fprintln(w)
}
// Identity
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
var identities []string
for _, t := range tokens {
if s, ok := t.(string); ok {
switch s {
case "user":
identities = append(identities, "user")
case "tenant":
identities = append(identities, "bot")
}
}
}
if len(identities) > 0 {
fmt.Fprintf(w, "%sIdentity:%s %s\n", output.Bold, output.Reset, strings.Join(identities, ", "))
}
}
// Scopes (all)
if scopes, ok := method["scopes"].([]interface{}); ok && len(scopes) > 0 {
var scopeStrs []string
for _, s := range scopes {
if str, ok := s.(string); ok {
scopeStrs = append(scopeStrs, str)
}
}
fmt.Fprintf(w, "%sScopes:%s %s\n", output.Bold, output.Reset, strings.Join(scopeStrs, ", "))
}
// CLI example
if isFileUpload && len(fileFieldNames) == 1 {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <path>\n", output.Bold, output.Reset, specName, resName, methodName)
} else if isFileUpload {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <field=path>\n", output.Bold, output.Reset, specName, resName, methodName)
} else {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
}
// Docs
if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" {
fmt.Fprintf(w, "%sDocs:%s %s\n", output.Bold, output.Reset, docUrl)
}
}
func printNestedFields(w io.Writer, fields map[string]interface{}, indent, prefix string) {
for _, fieldName := range sortedFieldKeys(fields) {
f, _ := fields[fieldName].(map[string]interface{})
fullName := fieldName
if prefix != "" {
fullName = prefix + "." + fieldName
}
fType := registry.GetStrFromMap(f, "type")
required, _ := f["required"].(bool)
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
if required {
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
}
optStr := formatOptions(f)
fmt.Fprintf(w, "%s- %s%s%s (%s, %s)%s\n", indent, output.Cyan, fullName, output.Reset, fType, reqStr, optStr)
desc := registry.GetStrFromMap(f, "description")
if desc != "" {
desc = util.TruncateStrWithEllipsis(desc, 100)
fmt.Fprintf(w, "%s %s%s%s\n", indent, output.Dim, desc, output.Reset)
}
if ex := registry.GetStrFromMap(f, "example"); ex != "" {
fmt.Fprintf(w, "%s %se.g. %s%s\n", indent, output.Dim, ex, output.Reset)
}
if rangeStr := formatRange(f); rangeStr != "" {
fmt.Fprintf(w, "%s %srange: %s%s\n", indent, output.Dim, rangeStr, output.Reset)
}
if props, ok := f["properties"].(map[string]interface{}); ok && len(props) > 0 {
printNestedFields(w, props, indent+" ", fullName)
}
}
}
// formatOptions returns " — val1 | val2 | ..." if field has options, else "".
func formatOptions(f map[string]interface{}) string {
opts, ok := f["options"].([]interface{})
if !ok || len(opts) == 0 {
return ""
}
var vals []string
for _, o := range opts {
if om, ok := o.(map[string]interface{}); ok {
if v := registry.GetStrFromMap(om, "value"); v != "" {
vals = append(vals, v)
}
}
}
if len(vals) == 0 {
return ""
}
return fmt.Sprintf(" %s— %s%s", output.Dim, strings.Join(vals, " | "), output.Reset)
}
// formatRange returns "min..max" if field has min/max, else "".
func formatRange(f map[string]interface{}) string {
minVal := registry.GetStrFromMap(f, "min")
maxVal := registry.GetStrFromMap(f, "max")
if minVal == "" && maxVal == "" {
return ""
}
if minVal != "" && maxVal != "" {
return minVal + ".." + maxVal
}
if minVal != "" {
return ">=" + minVal
}
return "<=" + maxVal
}
// sortedKeys returns map keys in alphabetical order.
func sortedKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// sortedParamKeys returns parameter keys sorted: required first, then alphabetical.
func sortedParamKeys(params map[string]interface{}) []string {
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
pi, _ := params[keys[i]].(map[string]interface{})
pj, _ := params[keys[j]].(map[string]interface{})
ri, _ := pi["required"].(bool)
rj, _ := pj["required"].(bool)
if ri != rj {
return ri
}
return keys[i] < keys[j]
})
return keys
}
// sortedFieldKeys returns field keys sorted: required first, then alphabetical.
func sortedFieldKeys(fields map[string]interface{}) []string {
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
fi, _ := fields[keys[i]].(map[string]interface{})
fj, _ := fields[keys[j]].(map[string]interface{})
ri, _ := fi["required"].(bool)
rj, _ := fj["required"].(bool)
if ri != rj {
return ri
}
return keys[i] < keys[j]
})
return keys
}
func findResourceByPath(resources map[string]interface{}, parts []string) (map[string]interface{}, string, []string) {
for i := len(parts); i >= 1; i-- {
candidateName := strings.Join(parts[:i], ".")
if res, ok := resources[candidateName]; ok {
if resMap, ok := res.(map[string]interface{}); ok {
return resMap, candidateName, parts[i:]
}
}
}
return nil, "", nil
// Args are the positional path segments, in either the dotted single-arg
// form ("im.messages.reply") or the space-separated form ("im messages
// reply"); apicatalog.ParsePath normalizes both.
Args []string
}
// NewCmdSchema creates the schema command. If runF is non-nil it is called instead of schemaRun (test hook).
@@ -365,12 +39,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
Short: "View API method parameters, types, and scopes",
Args: cobra.MaximumNArgs(8),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
opts.Path = args[0]
}
if len(args) > 1 {
opts.ExtraArgs = args[1:]
}
opts.Args = append([]string(nil), args...)
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
@@ -380,433 +49,89 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
}
cmdutil.DisableAuthCheck(cmd)
// Tolerated for agent compatibility; ignored — schema only emits the JSON
// envelope, and its output is identity-independent (strict-mode filtering
// comes from ResolveStrictMode, never from --as).
cmd.Flags().String("format", "json", "")
cmd.Flags().Bool("json", true, "")
cmd.Flags().String("as", "", "")
_ = cmd.Flags().MarkHidden("format")
_ = cmd.Flags().MarkHidden("json")
_ = cmd.Flags().MarkHidden("as")
cmd.ValidArgsFunction = completeSchemaPath(f)
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// completeSchemaPath provides tab-completion for the schema path argument.
// It handles both legacy dotted resource names (e.g. app.table.fields) and the
// newer space-separated form (e.g. `schema im messages reply`).
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
// It uses the embedded source so completion candidates match what `schema`
// execution can resolve (both overlay-free).
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
mode := f.ResolveStrictMode(cmd.Context())
// Case 1: legacy "single dotted arg" path — no previous args yet
if len(args) == 0 {
parts := strings.Split(toComplete, ".")
if len(parts) <= 1 {
var completions []string
for _, s := range registry.ListFromMetaProjects() {
if strings.HasPrefix(s, toComplete) {
completions = append(completions, s+".")
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
spec = filterSpecByStrictMode(spec, mode)
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
afterService := strings.Join(parts[1:], ".")
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
allTrailingDot := len(completions) > 0
for _, c := range completions {
if !strings.HasSuffix(c, ".") {
allTrailingDot = false
break
}
}
directive := cobra.ShellCompDirectiveNoFileComp
if allTrailingDot {
directive |= cobra.ShellCompDirectiveNoSpace
}
return completions, directive
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
directive := cobra.ShellCompDirectiveNoFileComp
if noSpace {
directive |= cobra.ShellCompDirectiveNoSpace
}
// Case 2: space-form, args already has segments
// Walk down service -> resource(s) -> method based on existing args
serviceName := args[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
spec = filterSpecByStrictMode(spec, mode)
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// args[1:] are resource path segments (possibly partial); current
// toComplete is the next segment under cursor.
consumed := args[1:]
resource, _, remaining := findResourceByPath(resources, consumed)
if resource == nil {
// Suggest top-level resource names that match toComplete
var completions []string
for resName := range resources {
if strings.HasPrefix(resName, toComplete) {
completions = append(completions, resName)
}
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
}
if len(remaining) > 0 {
// Already typed past the resource — suggest methods
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
var completions []string
for mName := range methods {
if strings.HasPrefix(mName, toComplete) {
completions = append(completions, mName)
}
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
}
// Resource matched exactly, suggest methods
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
var completions []string
for mName := range methods {
if strings.HasPrefix(mName, toComplete) {
completions = append(completions, mName)
}
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
return completions, directive
}
}
func completeSchemaPathForSpec(serviceName string, resources map[string]interface{}, afterService string) []string {
var completions []string
for resName, resVal := range resources {
if strings.HasPrefix(resName, afterService) {
completions = append(completions, serviceName+"."+resName+".")
continue
}
if !strings.HasPrefix(afterService, resName+".") {
continue
}
methodPrefix := afterService[len(resName)+1:]
resMap, _ := resVal.(map[string]interface{})
if resMap == nil {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
for methodName := range methods {
if strings.HasPrefix(methodName, methodPrefix) {
completions = append(completions, serviceName+"."+resName+"."+methodName)
}
}
}
sort.Strings(completions)
return completions
}
func schemaRun(opts *SchemaOptions) error {
out := opts.Factory.IOStreams.Out
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
// args may have arrived as a single string (legacy single-arg path) or
// split into multiple — normalize to a single args slice.
var rawArgs []string
if opts.Path != "" {
rawArgs = []string{opts.Path}
}
if len(opts.ExtraArgs) > 0 {
if opts.Path != "" {
rawArgs = append([]string{opts.Path}, opts.ExtraArgs...)
} else {
rawArgs = append([]string(nil), opts.ExtraArgs...)
}
}
parts := schema.ParsePath(rawArgs)
if opts.Format == "pretty" {
return runPrettyMode(out, parts, mode)
}
return runJSONMode(out, parts, mode)
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
}
// runJSONMode dispatches list/single envelope output based on parts.
// JSON mode uses embedded data only (bypasses remote overlay) so envelope
// output is deterministic across machines.
func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error {
filter := strictModeFilter(mode)
switch len(parts) {
case 0:
envs := schema.AssembleAll(filter)
output.PrintJson(out, envs)
return nil
case 1:
spec := registry.EmbeddedSpec(parts[0])
if spec == nil {
return errUnknownEmbeddedService(parts[0])
// runSchema resolves the path through the embedded catalog and renders the
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
// output shape — a single resolved method renders as one envelope object,
// anything broader as an array — and maps resolve failures to hints.
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
catalog := registry.EmbeddedCatalog()
target, err := catalog.Resolve(parts)
if err != nil {
return resolveError(err)
}
refs := catalog.MethodRefs(target, registry.FilterForStrictMode(mode))
if target.Kind == apicatalog.TargetMethod {
if len(refs) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"Method %s not available in current identity mode", target.Method.SchemaPath()).
WithHint("strict mode hides methods the active account identity cannot call; it is shown for an identity (user or bot) that has the required access token")
}
envs := schema.AssembleService(parts[0], spec, filter)
output.PrintJson(out, envs)
return nil
default:
return runJSONForPath(out, parts, filter)
}
}
// runJSONForPath handles len(parts) >= 2: try resource match first, fallback
// to single-method match. Uses embedded data only.
func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error {
serviceName := parts[0]
spec := registry.EmbeddedSpec(serviceName)
if spec == nil {
return errUnknownEmbeddedService(serviceName)
}
resources, _ := spec["resources"].(map[string]interface{})
resource, resName, remaining := findResourceByPath(resources, parts[1:])
if resource == nil {
var names []string
for k := range resources {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) == 0 {
// Resource-scoped envelope array
envs := assembleResource(serviceName, resName, resource, filter)
output.PrintJson(out, envs)
output.PrintJson(out, schema.EnvelopeOf(refs[0]))
return nil
}
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var names []string
for k := range methods {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) > 1 {
// Method exists but caller appended extra segments — reject so they
// don't silently get this method's schema when they typo'd the path.
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown path: %s.%s.%s",
serviceName, resName, strings.Join(remaining, ".")),
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
methodName, strings.Join(remaining[1:], ".")))
}
if filter != nil && !filter(method) {
// Method exists in spec but filtered out by strict mode
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName),
"Use --as user / --as bot to switch")
}
env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method)
output.PrintJson(out, env)
output.PrintJson(out, schema.Envelopes(refs))
return nil
}
func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope {
methods, _ := resource["methods"].(map[string]interface{})
resourcePath := []string{resName}
var envs []schema.Envelope
for methodName, raw := range methods {
method, ok := raw.(map[string]interface{})
if !ok {
continue
}
if filter != nil && !filter(method) {
continue
}
envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method))
// resolveError maps a catalog *ResolveError to a typed *errs.ValidationError
// (CategoryValidation drives the exit code; Hint promotes to the envelope),
// preserving the historical message + hint text.
func resolveError(err error) error {
var re *apicatalog.ResolveError
if !errors.As(err, &re) {
return err
}
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
return envs
}
// runPrettyMode preserves the existing legacy pretty rendering verbatim.
// All printServices/printResourceList/printMethodDetail calls stay unchanged.
func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
if len(parts) == 0 {
printServices(out)
return nil
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return errUnknownService(serviceName)
}
if len(parts) == 1 {
printResourceList(out, spec, mode)
return nil
}
resources, _ := spec["resources"].(map[string]interface{})
resource, resName, remaining := findResourceByPath(resources, parts[1:])
if resource == nil {
var names []string
for k := range resources {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) == 0 {
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
for _, mName := range sortedKeys(methods) {
m, _ := methods[mName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
desc := registry.GetStrFromMap(m, "description")
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
}
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
return nil
}
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var names []string
for k := range methods {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) > 1 {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown path: %s.%s.%s",
serviceName, resName, strings.Join(remaining, ".")),
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
methodName, strings.Join(remaining[1:], ".")))
}
printMethodDetail(out, spec, resName, methodName, method)
return nil
}
// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns
// nil if strict mode is not active.
func strictModeFilter(mode core.StrictMode) schema.MethodFilter {
if !mode.IsActive() {
return nil
}
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
return func(method map[string]interface{}) bool {
tokens, _ := method["accessTokens"].([]interface{})
if tokens == nil {
return true // permissive when meta_data lacks accessTokens
}
for _, t := range tokens {
if s, _ := t.(string); s == token {
return true
}
}
return false
}
}
func errUnknownService(name string) error {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown service: %s", name),
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
}
// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded
// services (no overlay) because JSON mode itself bypasses overlay; suggesting
// overlay-only services would mislead callers when those services subsequently
// fail to resolve in envelope output.
func errUnknownEmbeddedService(name string) error {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown service: %s", name),
fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", ")))
}
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
// filtered by strict mode. Returns the original spec when strict mode is off.
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
if !mode.IsActive() {
return spec
}
result := make(map[string]interface{}, len(spec))
for k, v := range spec {
result[k] = v
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return result
}
filteredRes := make(map[string]interface{}, len(resources))
for resName, resVal := range resources {
resMap, ok := resVal.(map[string]interface{})
if !ok {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
filtered := filterMethodsByStrictMode(methods, mode)
if len(filtered) == 0 {
continue
}
resCopy := make(map[string]interface{}, len(resMap))
for k, v := range resMap {
resCopy[k] = v
}
resCopy["methods"] = filtered
filteredRes[resName] = resCopy
}
result["resources"] = filteredRes
return result
}
// filterMethodsByStrictMode removes methods incompatible with the active strict mode.
// Returns the original map unmodified when strict mode is off.
func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} {
if !mode.IsActive() || methods == nil {
return methods
}
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
filtered := make(map[string]interface{}, len(methods))
for name, val := range methods {
m, ok := val.(map[string]interface{})
if !ok {
continue
}
tokens, _ := m["accessTokens"].([]interface{})
if tokens == nil {
filtered[name] = val
continue
}
for _, t := range tokens {
if ts, ok := t.(string); ok && ts == token {
filtered[name] = val
break
}
}
}
return filtered
switch re.Kind {
case apicatalog.ErrService:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown service: %s", re.Subject).
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
case apicatalog.ErrResource:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown resource: %s", re.Subject).
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
case apicatalog.ErrMethod:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown method: %s", re.Subject).
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
case apicatalog.ErrPath:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown path: %s", re.Subject).
WithHint("Method %q exists but the trailing segments %q do not resolve", re.Method, re.Trailing)
}
return err
}

View File

@@ -4,11 +4,12 @@
package schema
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
@@ -21,29 +22,46 @@ func TestSchemaCmd_FlagParsing(t *testing.T) {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"calendar.events.list", "--format", "pretty"})
cmd.SetArgs([]string{"calendar.events.list"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Path != "calendar.events.list" {
t.Errorf("expected path calendar.events.list, got %s", gotOpts.Path)
}
if gotOpts.Format != "pretty" {
t.Errorf("expected Format=pretty, got %s", gotOpts.Format)
if len(gotOpts.Args) != 1 || gotOpts.Args[0] != "calendar.events.list" {
t.Errorf("expected args [calendar.events.list], got %v", gotOpts.Args)
}
}
func TestSchemaCmd_NoArgs_Pretty(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
func TestSchemaCmd_OutputFlagsAcceptedForCompat(t *testing.T) {
// Agents are habituated to --format/--json/--as from api/service commands.
// schema must accept them without erroring and always emit the JSON envelope —
// its output is structured JSON and identity-independent, so the values have
// no effect.
argSets := [][]string{
{"--format", "json"},
{"--format", "pretty"},
{"--format", "table"}, // no table rendering for a nested schema -> JSON
{"--format", "csv"},
{"--json"},
{"--json", "--format", "ndjson"},
{"--as", "user"},
{"--as", "bot"},
{"--as", "user", "--json"},
}
if !strings.Contains(stdout.String(), "Available services") {
t.Error("expected service list in pretty mode")
for _, extra := range argSets {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs(append([]string{"im.images.create"}, extra...))
if err := cmd.Execute(); err != nil {
t.Fatalf("args %v should be accepted, got error: %v", extra, err)
}
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("args %v: output is not a JSON envelope: %v\n%s", extra, err, stdout.String())
}
if env["name"] != "im images create" {
t.Errorf("args %v: expected the im images create envelope, got name=%v", extra, env["name"])
}
}
}
@@ -51,7 +69,7 @@ func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{}) // default --format json
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -76,7 +94,7 @@ func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im.images.create", "--format", "json"})
cmd.SetArgs([]string{"im.images.create"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -179,23 +197,6 @@ func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
}
}
func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im.images.create", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Existing pretty rendering surfaces these markers — they must still appear
for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} {
if !strings.Contains(out, want) {
t.Errorf("pretty output missing marker %q", want)
}
}
}
func TestSchemaCmd_UnknownService(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -210,170 +211,47 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
if !strings.Contains(err.Error(), "Unknown service") {
t.Errorf("expected 'Unknown service' error, got: %v", err)
}
}
func TestPrintMethodDetail_FileUpload(t *testing.T) {
spec := map[string]interface{}{
"name": "im",
"servicePath": "/open-apis/im/v1",
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
method := map[string]interface{}{
"path": "images",
"httpMethod": "POST",
"description": "Upload an image",
"requestBody": map[string]interface{}{
"image_type": map[string]interface{}{
"type": "string",
"required": true,
},
"image": map[string]interface{}{
"type": "file",
"required": true,
},
},
"accessTokens": []interface{}{"user", "tenant"},
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
var buf bytes.Buffer
printMethodDetail(&buf, spec, "images", "create", method)
out := buf.String()
if !strings.Contains(out, "file upload") {
t.Errorf("expected 'file upload' marker in output, got:\n%s", out)
}
if !strings.Contains(out, "--file") {
t.Errorf("expected '--file' in output, got:\n%s", out)
}
if !strings.Contains(out, `"image"`) {
t.Errorf("expected default field name 'image' in output, got:\n%s", out)
}
if !strings.Contains(out, "--file <path>") {
t.Errorf("expected CLI example with --file <path>, got:\n%s", out)
if !strings.Contains(ve.Hint, "Available:") {
t.Errorf("expected hint listing available services, got: %q", ve.Hint)
}
}
func TestPrintMethodDetail_NoFileUpload(t *testing.T) {
spec := map[string]interface{}{
"name": "calendar",
"servicePath": "/open-apis/calendar/v4",
}
method := map[string]interface{}{
"path": "events",
"httpMethod": "POST",
"description": "Create an event",
"requestBody": map[string]interface{}{
"summary": map[string]interface{}{
"type": "string",
"required": true,
},
},
}
// TestSchemaCmd_UnknownMethod_TypedValidation pins the typed envelope for the
// JSON-mode unknown-method path: *errs.ValidationError with
// subtype invalid_argument and a hint listing the available methods.
func TestSchemaCmd_UnknownMethod_TypedValidation(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var buf bytes.Buffer
printMethodDetail(&buf, spec, "events", "create", method)
out := buf.String()
if strings.Contains(out, "file upload") {
t.Errorf("did not expect 'file upload' marker for non-file method, got:\n%s", out)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"calendar.events.nonexistent_method"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for unknown method")
}
if strings.Contains(out, "--file") {
t.Errorf("did not expect '--file' for non-file method, got:\n%s", out)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(err.Error(), "Unknown method") {
t.Errorf("expected 'Unknown method' error, got: %v", err)
}
if !strings.Contains(ve.Hint, "Available:") {
t.Errorf("expected hint listing available methods, got: %q", ve.Hint)
}
}
func TestHasFileFields(t *testing.T) {
tests := []struct {
name string
method map[string]interface{}
wantBool bool
wantFields []string
}{
{
name: "has file field",
method: map[string]interface{}{
"requestBody": map[string]interface{}{
"image": map[string]interface{}{"type": "file"},
"name": map[string]interface{}{"type": "string"},
},
},
wantBool: true,
wantFields: []string{"image"},
},
{
name: "no file field",
method: map[string]interface{}{
"requestBody": map[string]interface{}{
"name": map[string]interface{}{"type": "string"},
},
},
wantBool: false,
wantFields: nil,
},
{
name: "no requestBody",
method: map[string]interface{}{},
wantBool: false,
wantFields: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, names := hasFileFields(tt.method)
if got != tt.wantBool {
t.Errorf("hasFileFields() = %v, want %v", got, tt.wantBool)
}
if tt.wantFields == nil && names != nil {
t.Errorf("expected nil names, got %v", names)
}
if tt.wantFields != nil && len(names) != len(tt.wantFields) {
t.Errorf("expected %d field names, got %d", len(tt.wantFields), len(names))
}
})
}
}
func TestCompleteSchemaPathForSpec(t *testing.T) {
resources := map[string]interface{}{
"records": map[string]interface{}{
"methods": map[string]interface{}{
"create": map[string]interface{}{},
"list": map[string]interface{}{},
},
},
"record_permissions": map[string]interface{}{
"methods": map[string]interface{}{
"get": map[string]interface{}{},
},
},
}
got := completeSchemaPathForSpec("base", resources, "records.cr")
if len(got) != 1 || got[0] != "base.records.create" {
t.Fatalf("completions = %v, want [base.records.create]", got)
}
got = completeSchemaPathForSpec("base", resources, "record")
if len(got) != 2 || got[0] != "base.record_permissions." || got[1] != "base.records." {
t.Fatalf("resource completions = %v", got)
}
}
func TestFilterSpecByStrictMode_RemovesIncompatibleMethodsFromCompletionSource(t *testing.T) {
spec := map[string]interface{}{
"resources": map[string]interface{}{
"records": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{"accessTokens": []interface{}{"tenant"}},
"create": map[string]interface{}{"accessTokens": []interface{}{"user"}},
},
},
},
}
filtered := filterSpecByStrictMode(spec, core.StrictModeBot)
resources, _ := filtered["resources"].(map[string]interface{})
got := completeSchemaPathForSpec("base", resources, "records.")
if len(got) != 1 || got[0] != "base.records.list" {
t.Fatalf("filtered completions = %v, want [base.records.list]", got)
}
}
// Completion candidate generation (dotted + space forms, strict-mode filtering,
// dotted-resource handling) now lives in internal/apicatalog and is covered by
// apicatalog's TestComplete. cmd/schema only adapts catalog.Complete to cobra.

80
cmd/service/affordance.go Normal file
View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/meta"
)
// 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 {
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)
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.
func renderAffordance(m meta.Method) string {
a, ok := m.ParsedAffordance()
if !ok {
return ""
}
var b strings.Builder
bullets := func(title string, items []string) {
var nonEmpty []string
for _, it := range items {
if strings.TrimSpace(it) != "" {
nonEmpty = append(nonEmpty, it)
}
}
if len(nonEmpty) == 0 {
return
}
fmt.Fprintf(&b, "%s:\n", title)
for _, it := range nonEmpty {
fmt.Fprintf(&b, " • %s\n", it)
}
}
bullets("When to use", a.UseWhen)
bullets("Avoid when", a.DoNotUseWhen)
bullets("Prerequisites", a.Prerequisites)
if len(a.Examples) > 0 {
var lines []string
for _, ex := range a.Examples {
if ex.Command == "" {
continue
}
if ex.Description != "" {
lines = append(lines, fmt.Sprintf(" • %s\n %s", ex.Description, ex.Command))
} else {
lines = append(lines, fmt.Sprintf(" • %s", ex.Command))
}
}
if len(lines) > 0 {
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
}
}
bullets("Related", a.Related)
return strings.TrimRight(b.String(), "\n")
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
)
func TestRenderAffordance(t *testing.T) {
raw := json.RawMessage(`{
"use_when": ["发送文本消息"],
"do_not_use_when": ["群已解散"],
"prerequisites": ["已获取 chat_id"],
"examples": [
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
{"command":"lark-cli im messages list"},
{"description":"no command, skipped","command":""}
],
"related": ["im.messages.list"]
}`)
out := renderAffordance(meta.Method{Affordance: raw})
for _, want := range []string{
"When to use:", "发送文本消息",
"Avoid when:", "群已解散",
"Prerequisites:", "已获取 chat_id",
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
"lark-cli im messages list", // example with no description -> bare command line
"Related:", "im.messages.list",
} {
if !strings.Contains(out, want) {
t.Errorf("renderAffordance missing %q in:\n%s", want, out)
}
}
if strings.Contains(out, "no command, skipped") {
t.Errorf("example with empty command should be skipped:\n%s", out)
}
// Absent or empty affordance renders nothing (so methods without an overlay
// add nothing to their help).
if renderAffordance(meta.Method{}) != "" || renderAffordance(meta.Method{Affordance: json.RawMessage(`{}`)}) != "" {
t.Error("empty affordance should render nothing")
}
}
func TestServiceMethod_AffordanceInLong(t *testing.T) {
withAff := map[string]interface{}{
"path": "messages", "httpMethod": "POST", "description": "发送消息",
"affordance": map[string]interface{}{
"examples": []interface{}{
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
},
},
}
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)
}
// 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)
}
}

211
cmd/service/flaggroups.go Normal file
View File

@@ -0,0 +1,211 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// Flag annotations the grouped service-method help renderer reads.
const (
flagGroupAnnotation = "lark_flag_group" // display group key
flagSubAnnotation = "lark_flag_sub" // "required" | "optional" within API Parameters
flagNoteAnnotation = "lark_flag_note" // extra lines shown indented under a flag
groupParams = "params" // typed path/query flags
groupBody = "body" // --data, --file
groupRaw = "raw" // --params
groupExecution = "execution" // --as/--dry-run/--page-*/--yes
groupOutput = "output" // --output/--format/--jq
subRequired = "required"
subOptional = "optional"
)
// serviceFlagGroupOrder is the display order + titles of the flag groups. API
// Parameters carries only typed path/query flags; raw --params, request body and
// execution/output controls each get their own group so an agent can tell the
// distinct input kinds apart.
var serviceFlagGroupOrder = []struct{ key, title string }{
{groupParams, "API Parameters"},
{groupBody, "Request Body"},
{groupRaw, "Raw Parameter Input"},
{groupExecution, "Execution"},
{groupOutput, "Output"},
}
// applyGroupedUsage installs the grouped usage renderer on a service method
// cmd: local flags via the grouped renderer instead of cobra's flat Flags:
// list; global (inherited) flags and the Risk/Tips sections appended by the
// root help func are unaffected. Rendered by hand rather than via
// cmd.SetUsageTemplate: cobra lazy-links text/template on the first
// SetUsageTemplate call, whose executor reaches reflect.Value.MethodByName —
// that disables the linker's method-level dead-code elimination and costs
// ~19 MB of binary size.
func applyGroupedUsage(cmd *cobra.Command) {
cmd.SetUsageFunc(func(c *cobra.Command) error {
w := c.OutOrStderr()
fmt.Fprintf(w, "Usage:\n %s\n", c.UseLine())
if c.HasAvailableLocalFlags() {
fmt.Fprintf(w, "\n%s\n", renderServiceFlagGroups(c))
}
if c.HasAvailableInheritedFlags() {
fmt.Fprintf(w, "\nGlobal Flags:\n%s\n", strings.TrimRight(c.InheritedFlags().FlagUsages(), " \t\n"))
}
return nil
})
}
func annotate(f *pflag.Flag, key string, vals []string) {
if f.Annotations == nil {
f.Annotations = map[string][]string{}
}
f.Annotations[key] = vals
}
// tagFlagGroup records a flag's display group (no-op if the flag is absent).
func tagFlagGroup(fs *pflag.FlagSet, name, group string) {
if f := fs.Lookup(name); f != nil {
annotate(f, flagGroupAnnotation, []string{group})
}
}
func annotationOf(f *pflag.Flag, key string) []string {
if f.Annotations != nil {
return f.Annotations[key]
}
return nil
}
func flagGroupOf(f *pflag.Flag) string {
if v := annotationOf(f, flagGroupAnnotation); len(v) > 0 {
return v[0]
}
return ""
}
func flagSubOf(f *pflag.Flag) string {
if v := annotationOf(f, flagSubAnnotation); len(v) > 0 {
return v[0]
}
return ""
}
// renderServiceFlagGroups renders the command's local flags into ordered,
// titled groups; the API Parameters group is further split into Required /
// Optional. It is the body of the usage func applyGroupedUsage installs.
func renderServiceFlagGroups(cmd *cobra.Command) string {
var b strings.Builder
seen := map[*pflag.Flag]bool{}
for _, g := range serviceFlagGroupOrder {
flags := groupFlags(cmd, g.key, seen)
if len(flags) == 0 {
continue
}
fmt.Fprintf(&b, "%s:\n", g.title)
if g.key == groupParams {
writeSection(&b, " Required:", subFlags(flags, subRequired))
writeSection(&b, " Optional:", subFlags(flags, subOptional))
} else {
writeSection(&b, "", flags)
}
fmt.Fprintln(&b)
}
// Anything untagged (e.g. -h/--help) goes last under "Other".
var other []*pflag.Flag
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
if f.Hidden || seen[f] {
return
}
other = append(other, f)
})
if len(other) > 0 {
fmt.Fprintln(&b, "Other:")
writeSection(&b, "", other)
}
return strings.TrimRight(b.String(), "\n")
}
// groupFlags returns the visible local flags tagged with group key, marking them
// seen so the trailing "Other" bucket only catches genuinely untagged flags.
func groupFlags(cmd *cobra.Command, key string, seen map[*pflag.Flag]bool) []*pflag.Flag {
var flags []*pflag.Flag
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
if f.Hidden || flagGroupOf(f) != key {
return
}
flags = append(flags, f)
seen[f] = true
})
return flags
}
func subFlags(flags []*pflag.Flag, sub string) []*pflag.Flag {
var out []*pflag.Flag
for _, f := range flags {
s := flagSubOf(f)
// Untagged subgroup defaults to Optional so nothing is dropped.
if s == sub || (s == "" && sub == subOptional) {
out = append(out, f)
}
}
return out
}
// writeSection prints an optional (sub)header and the flags, aligned in a
// column, each flag row followed by its note lines indented under the usage.
func writeSection(b *strings.Builder, header string, flags []*pflag.Flag) {
if len(flags) == 0 {
return
}
if header != "" {
fmt.Fprintf(b, "%s\n", header)
}
specs := make([]string, len(flags))
maxSpec := 0
for i, f := range flags {
specs[i] = flagSpec(f)
if len(specs[i]) > maxSpec {
maxSpec = len(specs[i])
}
}
for i, f := range flags {
_, usage := pflag.UnquoteUsage(f)
if showsDefault(f) {
usage += fmt.Sprintf(" (default %s)", f.DefValue)
}
fmt.Fprintf(b, "%-*s %s\n", maxSpec, specs[i], strings.TrimSpace(usage))
for _, note := range annotationOf(f, flagNoteAnnotation) {
fmt.Fprintf(b, "%*s%s\n", maxSpec+3+4, "", note)
}
}
}
// flagSpec is pflag's " --name type" / " -x, --name type" left column.
func flagSpec(f *pflag.Flag) string {
typeName, _ := pflag.UnquoteUsage(f)
spec := " --" + f.Name
if f.Shorthand != "" && f.ShorthandDeprecated == "" {
spec = " -" + f.Shorthand + ", --" + f.Name
}
if typeName != "" {
spec += " " + typeName
}
return spec
}
// showsDefault mirrors pflag's "non-zero default" rule for the flag types these
// commands use, so the grouped rendering shows the same "(default x)" hints as
// cobra's flat list.
func showsDefault(f *pflag.Flag) bool {
switch f.DefValue {
case "", "0", "false", "[]":
return false
}
return true
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
)
func TestServiceFlagGroups_AgentContract(t *testing.T) {
method := map[string]interface{}{
"path": "chats/:chat_id/members",
"httpMethod": "POST",
"parameters": map[string]interface{}{
"chat_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
"member_id_type": map[string]interface{}{
"type": "string", "location": "query",
"options": []interface{}{
map[string]interface{}{"value": "open_id", "description": "以 open_id 标识用户"},
map[string]interface{}{"value": "user_id", "description": "以 user_id 标识用户"},
},
},
},
// Documented body field -> --data belongs under Request Body.
"requestBody": map[string]interface{}{
"id_list": map[string]interface{}{"type": "list", "required": true},
},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "create", "chat.members", nil)
out := renderServiceFlagGroups(cmd)
idx := func(s string) int { return strings.Index(out, s) }
// Section order: API Parameters → Request Body → Raw Parameter Input → Execution → Output.
iParams, iBody, iRaw, iExec, iOut := idx("API Parameters:"), idx("Request Body:"), idx("Raw Parameter Input:"), idx("Execution:"), idx("Output:")
for name, i := range map[string]int{"API Parameters": iParams, "Request Body": iBody, "Raw Parameter Input": iRaw, "Execution": iExec, "Output": iOut} {
if i < 0 {
t.Fatalf("missing section %q in:\n%s", name, out)
}
}
if !(iParams < iBody && iBody < iRaw && iRaw < iExec && iExec < iOut) {
t.Errorf("section order wrong:\n%s", out)
}
// Required/Optional subsections under API Parameters.
if i := idx(" Required:"); i < iParams || i > iBody {
t.Errorf("Required subsection misplaced:\n%s", out)
}
if i := idx(" Optional:"); i < iParams || i > iBody {
t.Errorf("Optional subsection misplaced:\n%s", out)
}
// Typed flags are API Parameters; required path flag under Required, enum
// flag under Optional with an inline "enum: ..." (not multi-line meanings).
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)
}
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
t.Errorf("expected compact enum value=meaning inline:\n%s", out)
}
// --data is Request Body; --params is Raw Parameter Input (NOT API Parameters)
// and carries the precedence rule.
if i := idx("--data"); i < iBody || i > iRaw {
t.Errorf("--data not under Request Body:\n%s", out)
}
if i := idx("--params"); i < iRaw || i > iExec {
t.Errorf("--params not under Raw Parameter Input:\n%s", out)
}
if !strings.Contains(out, "typed flags override matching keys in --params") {
t.Errorf("missing --params precedence rule:\n%s", out)
}
// Control flags land in Execution/Output.
if i := idx("--dry-run"); i < iExec || i > iOut {
t.Errorf("--dry-run not under Execution:\n%s", out)
}
if idx("--format") < iOut {
t.Errorf("--format not under Output:\n%s", out)
}
// The usage template is wired to the grouped renderer (no flat Flags: list).
if u := cmd.UsageString(); !strings.Contains(u, "API Parameters:") || strings.Contains(u, "\nFlags:\n") {
t.Errorf("usage template not grouped:\n%s", u)
}
}
// TestServiceFlagGroups_UndocumentedBodyIsRaw: a POST with no documented body
// fields still offers --data (escape hatch) but must NOT imply a declared body —
// it goes under Raw Parameter Input, not "Request Body".
func TestServiceFlagGroups_UndocumentedBodyIsRaw(t *testing.T) {
method := map[string]interface{}{"path": "things/do", "httpMethod": "POST"} // POST, no requestBody, no params
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "do", "things", nil)
out := renderServiceFlagGroups(cmd)
if strings.Contains(out, "Request Body:") {
t.Errorf("undocumented body must not render a Request Body section:\n%s", out)
}
iRaw, iData := strings.Index(out, "Raw Parameter Input:"), strings.Index(out, "--data")
if iRaw < 0 || iData < iRaw {
t.Errorf("--data not under Raw Parameter Input:\n%s", out)
}
if !strings.Contains(out, "no documented fields") {
t.Errorf("--data should be labeled a raw escape hatch:\n%s", out)
}
}

166
cmd/service/paramflags.go Normal file
View File

@@ -0,0 +1,166 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
type boundParamFlag struct {
field meta.Field
read func() interface{}
}
// paramsOnlyField is a path/query parameter that got no typed flag because its
// kebab name is already taken by another flag (a standard flag like --format, or
// a root persistent flag). It stays reachable via --params; the binder keeps it,
// with the flag that claimed the name, so --help can show the exact --params form
// and steer the reader off the wrong flag.
type paramsOnlyField struct {
field meta.Field
claimed *pflag.Flag
}
// paramFlagBinder owns one service method's generated typed param flags: it
// registers them (kind, help, enum completion, reserved-name skip) and applies
// the --params overlay, where a changed typed flag overrides its key in the
// --params JSON. Holding the field<->flag binding here keeps the request builder
// from re-deriving which flags map to which param keys.
type paramFlagBinder struct {
bound []boundParamFlag
paramsOnly []paramsOnlyField
}
// newParamFlagBinder registers one typed kebab flag per path/query parameter on
// cmd and returns a binder for the --params overlay. A name already taken by
// another flag is skipped — pflag panics on a local duplicate and a generated
// flag would silently shadow a persistent one — and recorded as paramsOnly so
// the parameter stays reachable (and discoverable) via --params. The taken set
// is derived, not hand-listed: local flags (the standard set, registered before
// this runs) via cmd, the lazily-added --help materialized here, and the root's
// persistent flags via reserved (nil for direct callers that have no root).
func newParamFlagBinder(cmd *cobra.Command, params []meta.Field, reserved *pflag.FlagSet) *paramFlagBinder {
cmd.InitDefaultHelpFlag() // materialize --help/-h so the local guard below sees it
b := &paramFlagBinder{}
for _, f := range params {
name := f.FlagName()
if claimed := flagClaiming(cmd, reserved, name); claimed != nil {
b.paramsOnly = append(b.paramsOnly, paramsOnlyField{field: f, claimed: claimed})
continue
}
read := registerTypedFlag(cmd.Flags(), name, f.CanonicalType(), paramFlagUsage(f))
if values := enumStrings(f.EnumValues()); len(values) > 0 {
cmdutil.RegisterFlagCompletion(cmd, name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return values, cobra.ShellCompDirectiveNoFileComp
})
}
// Group as an API parameter and mark required/optional for the
// Required/Optional subsections of the grouped --help renderer.
if fl := cmd.Flags().Lookup(name); fl != nil {
annotate(fl, flagGroupAnnotation, []string{groupParams})
sub := subOptional
if f.Required {
sub = subRequired
}
annotate(fl, flagSubAnnotation, []string{sub})
}
b.bound = append(b.bound, boundParamFlag{field: f, read: read})
}
return b
}
// flagClaiming returns the flag already occupying name (so a typed param flag
// would collide), or nil when the name is free. It checks the command's own
// flags (the standard set + the materialized --help) and the root's persistent
// flags — so the reserved set is whatever is actually registered, never a
// hand-kept list that drifts when a global flag is added.
func flagClaiming(cmd *cobra.Command, reserved *pflag.FlagSet, name string) *pflag.Flag {
if fl := cmd.Flags().Lookup(name); fl != nil {
return fl
}
if reserved != nil {
return reserved.Lookup(name)
}
return nil
}
// paramsOnlyHelp renders the --help addendum for parameters that have no typed
// flag, or "" when there are none. Per field: a copy-pasteable --params form,
// the same fieldFacts a typed flag would show on its usage line, and what the
// colliding flag actually does — so neither a human nor an agent sets the
// wrong one (e.g. --format, which is the output format, not the API parameter).
func (b *paramFlagBinder) paramsOnlyHelp() string {
if len(b.paramsOnly) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("\nParameters set via --params (no typed flag; the name is taken by another flag):\n")
for _, p := range b.paramsOnly {
name := p.field.Name
fmt.Fprintf(&sb, " %s: --params '{%q: %s}'\n", name, name, paramExample(p.field))
for _, fact := range fieldFacts(p.field) {
fmt.Fprintf(&sb, " %s\n", fact)
}
if p.claimed != nil {
fmt.Fprintf(&sb, " do not use --%s (%s)\n", p.claimed.Name, p.claimed.Usage)
}
}
return sb.String()
}
// hasTypedFlag reports whether the binder registered a typed flag for the
// param named name. False for params-only fields — a flag with the same kebab
// name may exist (that's the collision), but it is not this param's input.
// Nil-safe for direct buildServiceRequest callers that have no binder.
func (b *paramFlagBinder) hasTypedFlag(name string) bool {
if b == nil {
return false
}
for _, pf := range b.bound {
if pf.field.Name == name {
return true
}
}
return false
}
// overlay lets an explicit typed flag override the same key in --params
// (--params is the base). Only changed flags apply, so the --params-only path is
// unchanged. A nil binder or cmd is a no-op.
func (b *paramFlagBinder) overlay(cmd *cobra.Command, params map[string]interface{}) {
if b == nil || cmd == nil {
return
}
for _, pf := range b.bound {
if cmd.Flags().Changed(pf.field.FlagName()) {
params[pf.field.Name] = pf.read()
}
}
}
// registerTypedFlag registers one flag of the given canonical JSON-Schema kind
// and returns a reader for its parsed value; the kind→pflag-type switch lives
// only here.
func registerTypedFlag(fs *pflag.FlagSet, name, kind, usage string) func() interface{} {
switch kind {
case "integer":
return flagReader(fs.Int(name, 0, usage))
case "boolean":
return flagReader(fs.Bool(name, false, usage))
case "array":
return flagReader(fs.StringArray(name, nil, usage))
default:
return flagReader(fs.String(name, "", usage))
}
}
func flagReader[T any](p *T) func() interface{} {
return func() interface{} { return *p }
}

View File

@@ -0,0 +1,626 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// imChatMembersCreate: POST chats/{chat_id}/members with one path param and one
// optional enum query param — the canonical case from the screenshot feedback.
func imChatMembersCreate() meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "chats/{chat_id}/members",
"httpMethod": "POST",
"parameters": map[string]interface{}{
"chat_id": map[string]interface{}{
"type": "string", "location": "path", "required": true,
},
"member_id_type": map[string]interface{}{
"type": "string", "location": "query", "required": false,
"options": []interface{}{
map[string]interface{}{"value": "open_id"},
map[string]interface{}{"value": "user_id"},
},
},
},
})
}
func TestServiceMethod_TypedFlagRegistered(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
if cmd.Flags().Lookup("chat-id") == nil {
t.Error("expected generated --chat-id flag for path param chat_id")
}
if cmd.Flags().Lookup("member-id-type") == nil {
t.Error("expected generated --member-id-type flag for query param member_id_type")
}
}
// A query param literally named "format" kebab-collides with the global
// --format flag. Generation must skip it (never re-register, never panic) and
// leave the standard --format flag intact.
func TestServiceMethod_TypedFlagReservedCollisionSkipped(t *testing.T) {
method := map[string]interface{}{
"path": "messages",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"format": map[string]interface{}{"type": "string", "location": "query"},
},
}
var cmd *cobra.Command
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("flag generation panicked on reserved-name collision: %v", r)
}
}()
cmd = NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "list", "messages", nil)
}()
fl := cmd.Flags().Lookup("format")
if fl == nil || fl.DefValue != "json" {
t.Fatalf("standard --format flag must be preserved, got %+v", fl)
}
}
func TestServiceMethod_TypedFlag_DrivesPathParam(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--data", `{"id_list":["ou_x"]}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
t.Errorf("expected URL with chat_id substituted from --chat-id, got:\n%s", stdout.String())
}
}
func TestServiceMethod_TypedFlag_DrivesQueryParam(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--member-id-type", "open_id", "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "member_id_type") || !strings.Contains(out, "open_id") {
t.Errorf("expected query param member_id_type=open_id from flag, got:\n%s", out)
}
}
func TestServiceMethod_TypedFlag_AgreesWithParams(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("same value via flag and --params should be accepted, got: %v", err)
}
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
t.Errorf("expected URL with chat_id, got:\n%s", stdout.String())
}
}
// --params is the base; an explicit typed flag overrides the same key.
func TestServiceMethod_TypedFlag_OverridesParams(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_flag", "--params", `{"chat_id":"oc_params"}`, "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "chats/oc_flag/members") {
t.Errorf("expected --chat-id to override --params chat_id, got:\n%s", out)
}
if strings.Contains(out, "oc_params") {
t.Errorf("--params value should have been overridden by the flag, got:\n%s", out)
}
}
// Override works for a non-string (integer) param too, exercising the int
// register/read path end to end.
func TestServiceMethod_TypedFlag_IntegerOverridesParams(t *testing.T) {
method := map[string]interface{}{
"path": "messages",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"type": "integer", "location": "query"},
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "messages", nil)
cmd.SetArgs([]string{"--page-size", "100", "--params", `{"page_size":5}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "page_size") || !strings.Contains(out, "100") {
t.Errorf("expected --page-size 100 to override --params page_size=5, got:\n%s", out)
}
}
// Regression: with no typed flags passed, behavior is byte-identical to today.
func TestServiceMethod_TypedFlag_OnlyParamsStillWorks(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
t.Errorf("expected URL with chat_id from --params, got:\n%s", stdout.String())
}
}
// Regression: --params null is valid JSON that unmarshals to a nil map. A typed
// flag overlaying onto it must not panic (assignment to a nil map) — null is
// treated as "no base params", with the flag value applied on top.
func TestServiceMethod_TypedFlag_OverridesNullParams(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", "null", "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with a typed flag should not error, got: %v", err)
}
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
t.Errorf("expected chat_id from --chat-id over null --params, got:\n%s", stdout.String())
}
}
// Startup smoke test: registering every embedded method must not panic on a
// generated-flag name collision (pflag panics on duplicate registration, which
// would crash the whole CLI at startup), and a known path param must surface as
// a typed flag end to end.
func TestRegisterServiceCommands_GeneratesFlagsNoPanic(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
f := &cmdutil.Factory{}
defer func() {
if r := recover(); r != nil {
t.Fatalf("registering all service commands panicked: %v", r)
}
}()
RegisterServiceCommands(root, f)
create, _, err := root.Find([]string{"im", "chat.members", "create"})
if err != nil {
t.Fatalf("im chat.members create not registered: %v", err)
}
if create.Flags().Lookup("chat-id") == nil {
t.Error("expected generated --chat-id flag on im chat.members create")
}
}
// Locks the boolean and array branches of bindParamFlag end to end (string and
// integer are covered above): a bool flag yields true and a repeatable array
// flag yields all its elements in the request.
func TestServiceMethod_TypedFlag_BoolAndArrayKinds(t *testing.T) {
method := map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query"},
"ids": map[string]interface{}{"type": "list", "location": "query"},
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil)
cmd.SetArgs([]string{"--with-deleted", "--ids", "a", "--ids", "b", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"with_deleted", "true", "ids", "\"a\"", "\"b\""} {
if !strings.Contains(out, want) {
t.Errorf("expected dry-run output to contain %q, got:\n%s", want, out)
}
}
}
// Override (--params base, typed flag wins) is covered for string and integer
// above; this locks the same semantics for the boolean and array kinds.
func TestServiceMethod_TypedFlag_BoolAndArrayOverrideParams(t *testing.T) {
method := map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query"},
"ids": map[string]interface{}{"type": "list", "location": "query"},
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil)
cmd.SetArgs([]string{
"--params", `{"with_deleted":false,"ids":["from_params"]}`,
"--with-deleted", "--ids", "a", "--ids", "b",
"--dry-run",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"with_deleted", "true", "\"a\"", "\"b\""} {
if !strings.Contains(out, want) {
t.Errorf("expected flag to override --params (want %q), got:\n%s", want, out)
}
}
if strings.Contains(out, "from_params") {
t.Errorf("--params array value should have been overridden by --ids, got:\n%s", out)
}
}
// A param whose kebab name collides with a global flag (here "format" vs the
// global --format) gets no typed flag, but the collision is no longer silent:
// non-colliding params still get flags, the global --format is untouched, and
// --help shows the exact --params form and steers the reader off --format.
func TestServiceMethod_ParamsOnly_HelpSteersToParams(t *testing.T) {
method := map[string]interface{}{
"path": "things/{thing_id}",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"thing_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
"format": map[string]interface{}{"type": "string", "location": "query", "min": "1", "max": "64", "description": "返回的消息体格式。", "options": []interface{}{
map[string]interface{}{"value": "full"},
map[string]interface{}{"value": "metadata"},
}},
},
}
cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "get", "things", nil)
if cmd.Flags().Lookup("thing-id") == nil {
t.Error("non-colliding param should still get a typed --thing-id flag")
}
if fl := cmd.Flags().Lookup("format"); fl == nil || fl.DefValue != "json" {
t.Fatalf("global --format must be preserved (not shadowed), got %+v", fl)
}
for _, want := range []string{`--params '{"format"`, "返回的消息体格式", "full", "metadata", "min: 1, max: 64", "do not use --format"} {
if !strings.Contains(cmd.Long, want) {
t.Errorf("help should contain %q so the reader uses --params, not --format; got:\n%s", want, cmd.Long)
}
}
}
// The collision guard derives reserved names from the actual flag sets — local
// flags plus the root's persistent flags passed in — so a future persistent
// flag is covered with no hand-maintained list. Here a param named "profile"
// (a root persistent flag) is skipped while a normal param is bound.
func TestParamFlagBinder_PersistentFlagReserved(t *testing.T) {
cmd := &cobra.Command{Use: "x"}
reserved := pflag.NewFlagSet("root", pflag.ContinueOnError)
reserved.String("profile", "", "use a specific profile")
m := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"profile": map[string]interface{}{"type": "string", "location": "query"},
"id": map[string]interface{}{"type": "string", "location": "path"},
}})
b := newParamFlagBinder(cmd, m.Params(), reserved)
if cmd.Flags().Lookup("id") == nil {
t.Error("non-colliding param should get a typed flag")
}
if cmd.Flags().Lookup("profile") != nil {
t.Error("param colliding with a reserved persistent flag must not be registered")
}
found := false
for _, p := range b.paramsOnly {
if p.field.Name == "profile" {
found = true
}
}
if !found {
t.Error("colliding param should be recorded for the --params help note")
}
}
// boolIntQueryMethod is the fixture for the zero-value semantics tests: one
// boolean and one integer query param, where false and 0 are meaningful values.
func boolIntQueryMethod(required bool) meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query", "required": required},
"page_size": map[string]interface{}{"type": "integer", "location": "query"},
},
})
}
// Presence is intent: a typed flag is only overlaid when explicitly Changed,
// so --flag=false / --flag 0 are real values and must be sent — not silently
// dropped as "empty", which would let the API default win over an explicit
// user choice.
func TestServiceMethod_TypedFlag_ExplicitFalseAndZeroAreSent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil)
cmd.SetArgs([]string{"--with-deleted=false", "--page-size", "0", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} {
if !strings.Contains(out, want) {
t.Errorf("explicit zero value must be sent (want %s), got:\n%s", want, out)
}
}
}
// An explicitly provided false satisfies a required query parameter — the
// pre-flight must not report "missing" for a value the user just set.
func TestServiceMethod_TypedFlag_ExplicitFalseSatisfiesRequired(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(true), "list", "items", nil)
cmd.SetArgs([]string{"--with-deleted=false", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("required param explicitly set to false must pass pre-flight, got: %v", err)
}
if !strings.Contains(stdout.String(), `"with_deleted": false`) {
t.Errorf("explicit false must be sent, got:\n%s", stdout.String())
}
}
// The same presence-is-intent rule applies to the --params JSON base: a key
// deliberately written as false/0 is sent. (Zero values used to be silently
// dropped; this locks the corrected semantics as the contract.)
func TestServiceMethod_Params_JSONZeroValuesAreSent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil)
cmd.SetArgs([]string{"--params", `{"with_deleted":false,"page_size":0}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} {
if !strings.Contains(out, want) {
t.Errorf("--params zero value must be sent (want %s), got:\n%s", want, out)
}
}
}
// "" stays unusable: a required parameter fed an empty-string placeholder is
// still caught by the friendly pre-flight error, not sent as an empty value.
func TestServiceMethod_Params_EmptyStringStillMissing(t *testing.T) {
method := meta.FromMap(map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"user_id_type": map[string]interface{}{"type": "string", "location": "query", "required": true},
},
})
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{"user_id_type":""}`, "--dry-run"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "missing required query parameter") {
t.Fatalf("empty string for a required param should still pre-flight error, got: %v", err)
}
}
// A declared optional query param fed "" is dropped (unusable value), not sent
// as an empty query value — the declared-param loop owns the decision and the
// undeclared passthrough must not resurrect it. Undeclared keys stay the
// verbatim raw escape hatch.
func TestServiceMethod_Params_EmptyOptionalDroppedUndeclaredKept(t *testing.T) {
method := meta.FromMap(map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"user_id_type": map[string]interface{}{"type": "string", "location": "query"},
},
})
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{"user_id_type":"","custom_key":"v1"}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if strings.Contains(out, "user_id_type") {
t.Errorf("declared optional param with empty value must be dropped, got:\n%s", out)
}
if !strings.Contains(out, `"custom_key": "v1"`) {
t.Errorf("undeclared key must pass through verbatim, got:\n%s", out)
}
}
// min/max from the metadata surface on the typed flag's help line, in the same
// vocabulary as the envelope's minimum/maximum.
func TestParamFlagUsage_Bounds(t *testing.T) {
cases := []struct{ name, min, max, want string }{
{"both", "1", "100", "min: 1, max: 100"},
{"min only", "1", "", "min: 1"},
{"max only", "", "64", "max: 64"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"type": "integer", "location": "query", "min": tc.min, "max": tc.max},
}}).Params()
if usage := paramFlagUsage(fields[0]); !strings.Contains(usage, tc.want) {
t.Errorf("usage = %q, want contains %q", usage, tc.want)
}
})
}
t.Run("no bounds, no clause", func(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"page_token": map[string]interface{}{"type": "string", "location": "query"},
}}).Params()
if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "min:") || strings.Contains(usage, "max:") {
t.Errorf("usage without bounds should not mention min/max, got %q", usage)
}
})
}
// The sanitized field description rides the help line — a bare name like
// user_mailbox_id carries no meaning. The cut is at note separators (;), NOT
// at sentence ends (。): the later sentence often holds the key affordance.
func TestParamFlagUsage_Description(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"user_mailbox_id": map[string]interface{}{
"type": "string", "location": "path", "required": true,
"description": `用户邮箱地址。当使用用户身份访问时,可以输入"me"代表当前调用接口用户;后续补充说明不该出现`,
},
}}).Params()
usage := paramFlagUsage(fields[0])
if !strings.Contains(usage, `可以输入"me"代表当前调用接口用户`) {
t.Errorf("description must keep full sentences up to the note separator, got %q", usage)
}
if strings.Contains(usage, "补充说明") {
t.Errorf("text after the note separator must be cut, got %q", usage)
}
t.Run("long description truncated", func(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"x": map[string]interface{}{
"type": "string", "location": "query",
"description": strings.Repeat("长", 80),
},
}}).Params()
usage := paramFlagUsage(fields[0])
if !strings.Contains(usage, "...") {
t.Errorf("long description should be truncated with ellipsis, got %q", usage)
}
if strings.Contains(usage, strings.Repeat("长", 61)) {
t.Errorf("description should not exceed the cap, got %q", usage)
}
})
t.Run("trailing sentence punctuation trimmed", func(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"x": map[string]interface{}{
"type": "string", "location": "query", "description": "返回格式。",
},
}}).Params()
if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "。.") {
t.Errorf("clause join must not double the punctuation, got %q", usage)
}
})
}
// Pins the convergence contract: the params-only addendum renders the SAME
// fieldFacts list the typed flag's usage line joins inline — a fact added to
// fieldFacts reaches both surfaces, and neither can drift over what a param's
// help says (the addendum once rendered values-only enums and silently lacked
// the API default).
func TestParamHelp_BothSurfacesRenderFieldFacts(t *testing.T) {
f := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"mode": map[string]interface{}{
"type": "string", "location": "query",
"description": "模式选择。",
"default": "fast",
"min": "1", "max": "8",
"options": []interface{}{
map[string]interface{}{"value": "fast", "description": "快速"},
map[string]interface{}{"value": "full"},
},
},
}}).Params()[0]
facts := fieldFacts(f)
if len(facts) != 4 { // description, enum, bounds, API default
t.Fatalf("fieldFacts = %v, want 4 facts", facts)
}
usage := paramFlagUsage(f)
help := (&paramFlagBinder{paramsOnly: []paramsOnlyField{{field: f}}}).paramsOnlyHelp()
for _, fact := range facts {
if !strings.Contains(usage, fact) {
t.Errorf("usage line missing fact %q: %q", fact, usage)
}
if !strings.Contains(help, fact) {
t.Errorf("params-only addendum missing fact %q:\n%s", fact, help)
}
}
}
// Bounds reach the registered flag's help end to end.
func TestServiceMethod_TypedFlag_HelpShowsBounds(t *testing.T) {
method := meta.FromMap(map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"type": "integer", "location": "query", "min": "1", "max": "100", "default": "20"},
},
})
cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), method, "list", "items", nil)
fl := cmd.Flags().Lookup("page-size")
if fl == nil {
t.Fatal("expected generated --page-size flag")
}
if !strings.Contains(fl.Usage, "min: 1, max: 100") {
t.Errorf("flag usage should carry bounds, got %q", fl.Usage)
}
}
// The missing-required hint must name both recovery paths — the typed flag and
// the --params fallback — so a reader who only knows one input style can
// proceed without a round-trip through schema.
func TestServiceMethod_MissingRequired_HintNamesFlagAndParams(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--data", `{"id_list":["ou_x"]}`, "--dry-run"})
err := cmd.Execute()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
for _, want := range []string{"--chat-id", `--params '{"chat_id": "<value>"}'`, "lark-cli schema im.chat.members.create"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint %q should contain %q", ve.Hint, want)
}
}
}
// A params-only required field (kebab name claimed by the standard --format
// flag) has no typed flag to offer: the hint must give only the --params form,
// never steer the reader to the colliding flag.
func TestServiceMethod_MissingRequired_ParamsOnlyHintSkipsFlag(t *testing.T) {
method := meta.FromMap(map[string]interface{}{
"path": "messages",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"format": map[string]interface{}{"type": "string", "location": "query", "required": true},
},
})
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "messages", nil)
cmd.SetArgs([]string{"--dry-run"})
err := cmd.Execute()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(ve.Hint, `--params '{"format": "<value>"}'`) {
t.Errorf("hint %q should carry the --params form", ve.Hint)
}
if strings.Contains(ve.Hint, "set --format") {
t.Errorf("hint %q must not steer to the colliding --format flag", ve.Hint)
}
}

162
cmd/service/paramhelp.go Normal file
View File

@@ -0,0 +1,162 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Help rendering for generated param flags. fieldFacts is the single list of
// agent-relevant facts a param exposes; every help surface (the typed flag's
// usage line, the params-only --params addendum) renders that one list, so the
// surfaces cannot drift over which facts exist. Values come from the
// meta.Field accessors, so nothing here depends on internal/schema.
package service
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/util"
)
// fieldFacts returns a param field's facts in display order, each as a compact
// one-line clause: the sanitized description, the allowed enum values (with
// meanings), the min/max constraint, and the API default. This is the ONE
// place that decides what a param's help says — add a fact here (e.g. a future
// deprecation marker) and every surface shows it. Unabridged prose and
// per-option detail stay in `lark-cli schema`.
func fieldFacts(f meta.Field) []string {
var facts []string
if d := sanitizeFieldDesc(f.Description); d != "" {
facts = append(facts, d)
}
if opts := f.EnumOptions(); len(opts) > 0 {
facts = append(facts, "enum: "+formatEnumInline(opts))
}
if b := formatBoundsInline(f); b != "" {
facts = append(facts, b)
}
if s := literalStr(f.CoercedDefault()); s != "" {
facts = append(facts, "API default: "+s)
}
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.
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, ". ") + "."
}
// paramExample picks a concrete sample for a params-only field's --help snippet:
// its first allowed enum value, else its example, else a placeholder.
func paramExample(f meta.Field) string {
if vals := enumStrings(f.EnumValues()); len(vals) > 0 {
return fmt.Sprintf("%q", vals[0])
}
if s := literalStr(f.CoercedExample()); s != "" {
return fmt.Sprintf("%q", s)
}
return `"<value>"`
}
var markdownLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`)
// inlineClause compresses metadata prose into one help clause: markdown links
// keep their text, the clause cuts at the first rune in stops, whitespace
// collapses, trailing punctuation goes — sentence enders (the clause join adds
// its own) and connectors a cut can strand, like a colon introducing a list the
// newline cut dropped — and the result caps at max runes. The two policies
// below differ only in where they cut and how much they keep.
func inlineClause(s, stops string, max int) string {
if s == "" {
return ""
}
s = markdownLinkRe.ReplaceAllString(s, "$1")
// Backquotes must go: pflag's UnquoteUsage treats a backquoted word in a
// flag's usage string as the flag's metavar, so a description like wiki
// space_id's "可替换为`my_library`" would render the flag as
// "--space-id my_library" instead of "--space-id string".
s = strings.ReplaceAll(s, "`", "")
if i := strings.IndexAny(s, stops); i >= 0 {
s = s[:i]
}
s = strings.Join(strings.Fields(s), " ")
s = strings.TrimRight(s, "。.:,、")
return util.TruncateStrWithEllipsis(s, max)
}
// sanitizeOptionDesc is the enum-option policy: many values share one line, so
// keep only the first clause (cut at 。 too) and stay ultra-compact.
func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r", 40) }
// 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) }
// formatEnumInline renders allowed values for the help line: "v=meaning" when
// the value carries a (sanitized, truncated) description — so opaque numeric
// enums like succeed_type read as "0=…|1=…|2=…" — else just "v". Full meanings
// live in the envelope's enumDescriptions / `lark-cli schema`.
func formatEnumInline(opts []meta.EnumOption) string {
items := make([]string, len(opts))
for i, o := range opts {
if d := sanitizeOptionDesc(o.Description); d != "" {
items[i] = fmt.Sprintf("%v=%s", o.Value, d)
} else {
items[i] = fmt.Sprintf("%v", o.Value)
}
}
return strings.Join(items, "|")
}
// formatBoundsInline renders the field's min/max constraint ("min: 1, max:
// 100", or the single declared side), or "" when the field declares neither.
// The vocabulary matches the envelope's minimum/maximum, so help and `lark-cli
// schema` state the same constraint.
func formatBoundsInline(f meta.Field) string {
min, max := f.MinBound(), f.MaxBound()
switch {
case min != nil && max != nil:
return fmt.Sprintf("min: %s, max: %s", formatBound(*min), formatBound(*max))
case min != nil:
return "min: " + formatBound(*min)
case max != nil:
return "max: " + formatBound(*max)
}
return ""
}
// formatBound renders a bound without a float artifact (100 not 100.000000).
func formatBound(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
// literalStr renders a coerced literal (default/example) for flag help,
// returning "" for a nil or empty value so the caller can omit the clause.
func literalStr(v interface{}) string {
if v == nil {
return ""
}
return fmt.Sprintf("%v", v)
}
func enumStrings(enum []interface{}) []string {
out := make([]string, 0, len(enum))
for _, e := range enum {
out = append(out, fmt.Sprintf("%v", e))
}
return out
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"strings"
"testing"
)
func TestSanitizeOptionDesc(t *testing.T) {
cases := map[string]string{
"": "",
"以 open_id 标识用户": "以 open_id 标识用户",
"中文。English second clause": "中文", // first clause only (。)
"headtail": "head", // first clause ()
"line one\nline two": "line one", // first clause (newline)
" spaced out ": "spaced out", // whitespace collapsed
"see [飞书后台](https://x/admin) 详情": "see 飞书后台 详情", // markdown link -> text, url dropped
}
for in, want := range cases {
if got := sanitizeOptionDesc(in); got != want {
t.Errorf("sanitizeOptionDesc(%q) = %q, want %q", in, got, want)
}
}
// Truncation: a long single clause is cut to 40 runes with an ellipsis,
// rune-safe (no split mid-character).
long := strings.Repeat("文", 60)
got := sanitizeOptionDesc(long)
if r := []rune(got); len(r) != 40 || !strings.HasSuffix(got, "...") {
t.Errorf("truncation = %q (%d runes), want 40 runes ending in ...", got, len(r))
}
}
func TestSanitizeFieldDesc_TrimsDanglingPunctuation(t *testing.T) {
// A clause cut can strand a connector (e.g. a colon introducing a list the
// newline cut drops, as in im.reactions.list's message_id); the help line
// joiner then renders "…获取方式:." — so dangling punctuation must go too.
cases := map[string]string{
"待查询的消息ID。ID 获取方式:\n- 调用接口获取": "待查询的消息ID。ID 获取方式",
"see the list below:\nitem": "see the list below",
"逗号结尾,\n下一行": "逗号结尾",
}
for in, want := range cases {
if got := sanitizeFieldDesc(in); got != want {
t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want)
}
}
}
func TestSanitizeFieldDesc_StripsBackquotes(t *testing.T) {
// pflag's UnquoteUsage takes a backquoted word in a flag's usage string as
// the flag's metavar: wiki space_id's description rendered the flag as
// "--space-id my_library" instead of "--space-id string".
in := "[知识空间id](https://x/wiki),如果查询我的文档库可替换为`my_library`"
want := "知识空间id如果查询我的文档库可替换为my_library"
if got := sanitizeFieldDesc(in); got != want {
t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want)
}
}

View File

@@ -10,18 +10,21 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// RegisterServiceCommands registers all service commands from from_meta specs.
@@ -30,85 +33,79 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
}
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
for _, project := range registry.ListFromMetaProjects() {
spec := registry.LoadFromMeta(project)
if spec == nil {
RegisterServiceCommandsFromCatalog(ctx, parent, f, registry.RuntimeCatalog())
}
func RegisterServiceCommandsFromCatalog(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory, catalog apicatalog.Catalog) {
// Drive the service list from the same navigation catalog the method walk
// uses, so registration is catalog-sourced end to end. Kept as a per-service
// loop rather than a flat WalkMethods(nil) drive precisely so a service with
// no methods still gets its bare command (WalkMethods yields one ref per
// method, so empty services would vanish).
for _, svc := range catalog.Services() {
if svc.Name == "" || svc.ServicePath == "" {
continue
}
specName := registry.GetStrFromMap(spec, "name")
servicePath := registry.GetStrFromMap(spec, "servicePath")
if specName == "" || servicePath == "" {
continue
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
continue
}
registerServiceWithContext(ctx, parent, spec, resources, f)
registerServiceWithContext(ctx, parent, svc, f)
}
}
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
registerServiceWithContext(context.Background(), parent, spec, resources, f)
func registerService(parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) {
registerServiceWithContext(context.Background(), parent, svc, f)
}
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
specName := registry.GetStrFromMap(spec, "name")
specDesc := registry.GetServiceDescription(specName, "en")
if specDesc == "" {
specDesc = registry.GetStrFromMap(spec, "description")
}
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) {
svcCmd := ensureChildCommand(parent, svc.Name, serviceShort(svc))
// Find existing service command or create one
var svc *cobra.Command
// Build the service's subtree from the catalog's method walk
// (apicatalog.ServiceMethods recurses nested resources), so the command tree
// is sourced from the same navigation Module as schema/scope rather than a
// hand-rolled resource/method walk. Each ref's ResourcePath becomes the
// 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) {
resCmd := svcCmd
for _, seg := range ref.ResourcePath {
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
}
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
}
}
// 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 {
if d := registry.GetServiceDescription(svc.Name, "en"); d != "" {
return d
}
return svc.Description
}
// ensureChildCommand returns the child of parent named name, creating it (with
// short) when absent — so re-registration merges into an existing command tree
// instead of duplicating a level.
func ensureChildCommand(parent *cobra.Command, name, short string) *cobra.Command {
for _, c := range parent.Commands() {
if c.Name() == specName {
svc = c
break
if c.Name() == name {
cmdmeta.SetSource(c, cmdmeta.SourceService, true)
return c
}
}
if svc == nil {
svc = &cobra.Command{
Use: specName,
Short: specDesc,
}
parent.AddCommand(svc)
}
for resName, resource := range resources {
resMap, _ := resource.(map[string]interface{})
if resMap == nil {
continue
}
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
}
}
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
res := &cobra.Command{
Use: name,
Short: name + " operations",
}
parent.AddCommand(res)
methods, _ := resource["methods"].(map[string]interface{})
for methodName, method := range methods {
methodMap, _ := method.(map[string]interface{})
if methodMap == nil {
continue
}
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
}
cmd := &cobra.Command{Use: name, Short: short}
cmdmeta.SetSource(cmd, cmdmeta.SourceService, true)
parent.AddCommand(cmd)
return cmd
}
// ServiceMethodOptions holds all inputs for a dynamically registered service method command.
type ServiceMethodOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Ctx context.Context
Spec map[string]interface{}
Method map[string]interface{}
SchemaPath string
Factory *cmdutil.Factory
Cmd *cobra.Command
Ctx context.Context
ServicePath string
Method meta.Method
SchemaPath string
// Flags
Params string
@@ -123,41 +120,113 @@ type ServiceMethodOptions struct {
DryRun bool
File string // --file flag value
FileFields []string // auto-detected file field names from metadata
// binder owns the generated typed param flags — registration and the
// --params overlay — replacing the raw paramFlags side-channel.
binder *paramFlagBinder
}
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
func detectFileFields(method map[string]interface{}) []string {
return cmdutil.DetectFileFields(method)
}
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
// detectFileFields returns the request-body file-upload field names.
func detectFileFields(m meta.Method) []string {
files := m.Files()
if len(files) == 0 {
return nil
}
names := make([]string, len(files))
for i, f := range files {
names[i] = f.Name
}
return names
}
// NewCmdServiceMethod creates a command for a dynamically registered service method.
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
func NewCmdServiceMethod(f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
return NewCmdServiceMethodWithContext(context.Background(), f, svc, m, name, resName, runF)
}
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
desc := registry.GetStrFromMap(method, "description")
httpMethod := registry.GetStrFromMap(method, "httpMethod")
risk := registry.GetStrFromMap(method, "risk")
specName := registry.GetStrFromMap(spec, "name")
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
// NewCmdServiceMethodWithContext builds the command for one service method from
// its (service, resource, method) coordinates, deriving the methodCommandSpec
// via an apicatalog.MethodRef so direct callers and the catalog-driven
// registration assemble the command identically.
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
m.Name = name
ref := apicatalog.MethodRef{Service: svc, ResourcePath: []string{resName}, Method: m}
// No root in scope here; persistent-flag collisions don't apply to a
// standalone command, and local/standard-flag collisions are still caught.
return buildMethodCommand(ctx, f, newMethodCommandSpec(ref), runF, nil)
}
// methodCommandSpec is the static description of one generated service method
// command, read off an apicatalog.MethodRef — the single place command
// construction gets the method's facts (schema path, HTTP base path, risk,
// identities, params, file fields, request-body support), so the cobra command
// is assembled from a typed spec rather than recomputing paths/flags inline.
type methodCommandSpec struct {
method meta.Method
schemaPath string // "service.resource.method", for the --help hint
servicePath string // service HTTP base path
risk string // RiskRead | RiskWrite | RiskHighRiskWrite
restricts bool // method declares accessTokens (identity-restricted)
identities []string // permitted --as values; empty when unrestricted
params []meta.Field // path/query params -> typed flags
fileFields []string // request-body file-upload field names
// acceptsBody is whether the HTTP method allows a request body at all (so
// --data is offered as a raw escape hatch). declaresBody is whether the
// metadata documents body fields (data or file). They differ for e.g. a POST
// with no documented requestBody: --data still works, but help must not imply
// the API declares a body.
acceptsBody bool
declaresBody bool
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
}
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
m := ref.Method
return methodCommandSpec{
method: m,
schemaPath: ref.SchemaPath(),
servicePath: ref.Service.ServicePath,
risk: m.Risk,
restricts: m.RestrictsIdentity(),
identities: m.Identities(),
params: m.Params(),
fileFields: detectFileFields(m),
acceptsBody: methodTakesBody(m.HTTPMethod),
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
affordance: renderAffordance(m),
}
}
// methodTakesBody reports whether the HTTP method allows a request body, i.e.
// whether --data applies (as a raw escape hatch even when no body is declared).
func methodTakesBody(httpMethod string) bool {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
return true
}
return false
}
// buildMethodCommand assembles the cobra command for a service method from its
// static spec: the standard flags, the conditional --data/--file/--yes flags,
// the generated typed param flags (via paramFlagBinder), and the risk/identity
// policy annotations.
func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodCommandSpec, runF func(*ServiceMethodOptions) error, reserved *pflag.FlagSet) *cobra.Command {
m := spec.method
opts := &ServiceMethodOptions{
Factory: f,
Spec: spec,
Method: method,
SchemaPath: schemaPath,
Factory: f,
ServicePath: spec.servicePath,
Method: m,
SchemaPath: spec.schemaPath,
FileFields: spec.fileFields,
}
var asStr string
cmd := &cobra.Command{
Use: name,
Short: desc,
Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath),
Use: m.Name,
Short: m.Description,
// Long is assembled below, once the binder knows which params got no
// typed flag.
RunE: func(cmd *cobra.Command, args []string) error {
opts.Cmd = cmd
opts.Ctx = cmd.Context()
@@ -168,11 +237,17 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
return serviceMethodRun(opts)
},
}
cmdmeta.SetSource(cmd, cmdmeta.SourceService, true)
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)")
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
cmd.Flags().StringVar(&opts.Params, "params", "", "Raw URL/query params JSON. Supports - and @file.")
if spec.acceptsBody {
dataUsage := "JSON request body. Supports - and @file."
if !spec.declaresBody {
// POST/etc. with no documented body fields: --data is a raw escape
// hatch, not a declared body — say so rather than imply structure.
dataUsage = "Raw JSON request body (no documented fields; see schema). Supports - and @file."
}
cmd.Flags().StringVar(&opts.Data, "data", "", dataUsage)
}
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
@@ -180,29 +255,64 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
if risk == "high-risk-write" {
if spec.risk == cmdutil.RiskHighRiskWrite {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}
// Conditionally register --file for methods with file-type fields.
fileFields := detectFileFields(method)
opts.FileFields = fileFields
if len(fileFields) > 0 {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
}
// --file only for body methods that actually declare file-type fields.
if len(spec.fileFields) > 0 && spec.acceptsBody {
cmd.Flags().StringVar(&opts.File, "file", "", "File upload [field=]path. Supports - and stdin.")
}
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
cmdutil.SetRisk(cmd, risk)
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
// 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())
// 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
// registered above (e.g. --data/--file/--yes only exist for some methods).
// --data sits under Request Body only when the metadata documents body
// fields; otherwise it's a raw escape hatch, grouped with --params so help
// doesn't imply a declared body the API doesn't have.
if fl := cmd.Flags().Lookup("data"); fl != nil {
if spec.declaresBody {
annotate(fl, flagGroupAnnotation, []string{groupBody})
} else {
annotate(fl, flagGroupAnnotation, []string{groupRaw})
}
}
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.
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.",
})
}
}
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
tagFlagGroup(cmd.Flags(), name, groupExecution)
}
for _, name := range []string{"output", "format", "jq"} {
tagFlagGroup(cmd.Flags(), name, groupOutput)
}
applyGroupedUsage(cmd)
cmdutil.SetTips(cmd, m.Tips)
cmdutil.SetRisk(cmd, spec.risk)
if spec.restricts {
cmdutil.SetSupportedIdentities(cmd, spec.identities)
}
return cmd
@@ -217,8 +327,8 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
// Check if this API method supports the resolved identity.
if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
if err := f.CheckIdentity(opts.As, cmdutil.AccessTokensToIdentities(tokens)); err != nil {
if opts.Method.RestrictsIdentity() {
if err := f.CheckIdentity(opts.As, opts.Method.Identities()); err != nil {
return err
}
}
@@ -234,12 +344,10 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if err != nil {
return err
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
// Identity is not printed to stderr here: it is part of the JSON envelope.
scopes, _ := opts.Method["scopes"].([]interface{})
if !opts.As.IsBot() {
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method, scopes); err != nil {
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method); err != nil {
return err
}
}
@@ -256,7 +364,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
return serviceDryRun(f, request, config, opts.Format)
}
if registry.GetStrFromMap(opts.Method, "risk") == "high-risk-write" {
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
if yes, _ := opts.Cmd.Flags().GetBool("yes"); !yes {
return cmdutil.RequireConfirmation(opts.SchemaPath)
}
@@ -279,7 +387,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
checkErr := ac.CheckResponse
if opts.PageAll {
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
}
@@ -301,7 +409,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
// checkServiceScopes pre-checks user scopes before making the API call.
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method meta.Method) error {
if ctx.Err() != nil {
return ctx.Err()
}
@@ -310,23 +418,15 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
return nil //nolint:nilerr // skip scope check when token resolution fails or has no scopes
}
requiredScopes, hasRequired := method["requiredScopes"].([]interface{})
if hasRequired && len(requiredScopes) > 0 {
if len(method.RequiredScopes) > 0 {
// Strict: ALL requiredScopes must be present
required := make([]string, 0, len(requiredScopes))
for _, s := range requiredScopes {
if str, ok := s.(string); ok {
required = append(required, str)
}
}
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
if missing := auth.MissingScopes(result.Scopes, method.RequiredScopes); len(missing) > 0 {
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing)
}
return nil
}
if len(scopes) == 0 {
if len(method.Scopes) == 0 {
return nil
}
@@ -335,12 +435,12 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
for _, s := range strings.Fields(result.Scopes) {
grantedSet[s] = true
}
for _, s := range scopes {
if str, ok := s.(string); ok && grantedSet[str] {
for _, s := range method.Scopes {
if grantedSet[s] {
return nil
}
}
recommended := registry.SelectRecommendedScope(scopes, "user")
recommended := registry.SelectRecommendedScopeFromStrings(method.Scopes, "user")
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), []string{recommended})
}
@@ -361,14 +461,44 @@ func newPreflightMissingScopeError(brand, appID, identity string, missing []stri
WithIdentity(identity)
}
// unusableParamValue reports whether a provided path/query parameter value
// cannot form a usable request value: nil or an empty string. A key's presence
// in params is the intent signal — a typed flag is overlaid only when
// explicitly Changed, and a --params JSON key is deliberately written — so
// false and 0 are real values and must not be conflated with "unset"
// (reflect.IsZero would drop an explicit --with-deleted=false or --foo 0).
// Only nil/"" stay treated as missing: that keeps the friendly pre-flight
// error when a required param is fed an empty placeholder, and never emits a
// declared param as an empty path segment or query value. Undeclared keys are
// not judged by this rule — they pass through verbatim as the raw escape hatch.
func unusableParamValue(v interface{}) bool {
if v == nil {
return true
}
s, ok := v.(string)
return ok && s == ""
}
// missingParamHint is the recovery hint for a missing required parameter. It
// names both input paths — the typed flag when the binder registered one, and
// the --params fallback — plus the schema pointer. A params-only field gets
// only the --params form: a flag with its kebab name exists but belongs to
// something else (e.g. the output --format), and the hint must not steer
// there. Asking the binder, not cmd.Flags(), is what tells those apart.
func missingParamHint(opts *ServiceMethodOptions, f meta.Field) string {
paramsForm := fmt.Sprintf("--params '{%q: \"<value>\"}'", f.Name)
if opts.binder.hasTypedFlag(f.Name) {
return fmt.Sprintf("set --%s <value> (or %s); see: lark-cli schema %s", f.FlagName(), paramsForm, opts.SchemaPath)
}
return fmt.Sprintf("set %s; see: lark-cli schema %s", paramsForm, opts.SchemaPath)
}
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
// When dryRun is true and a file is provided, file reading is skipped and
// FileUploadMeta is returned instead so the caller can render dry-run output.
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
spec := opts.Spec
method := opts.Method
schemaPath := opts.SchemaPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
httpMethod := method.HTTPMethod
// stdin is an io.Reader consumed at most once. Only one of --params/--data
// may use "-" (stdin); the conflict check below prevents silent data loss.
@@ -386,53 +516,55 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
if err != nil {
return client.RawApiRequest{}, nil, err
}
opts.binder.overlay(opts.Cmd, params)
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
url := opts.ServicePath + "/" + method.Path
parameters, _ := method["parameters"].(map[string]interface{})
for name, param := range parameters {
p, _ := param.(map[string]interface{})
if registry.GetStrFromMap(p, "location") != "path" {
specs := method.Params()
for _, s := range specs {
if s.Location != "path" {
continue
}
val, ok := params[name]
if !ok || util.IsEmptyValue(val) {
val, ok := params[s.Name]
if !ok || unusableParamValue(val) {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing required path parameter: %s", name).
WithHint("lark-cli schema %s", schemaPath).
WithParam(name)
"missing required path parameter: %s", s.Name).
WithHint("%s", missingParamHint(opts, s)).
WithParam(s.Name)
}
valStr := fmt.Sprintf("%v", val)
if err := validate.ResourceName(valStr, name); err != nil {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(name).WithCause(err)
if err := validate.ResourceName(valStr, s.Name); err != nil {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(s.Name).WithCause(err)
}
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
delete(params, name)
url = strings.Replace(url, "{"+s.Name+"}", validate.EncodePathSegment(valStr), 1)
delete(params, s.Name)
}
queryParams := map[string]interface{}{}
for name, param := range parameters {
p, _ := param.(map[string]interface{})
if registry.GetStrFromMap(p, "location") != "query" {
for _, s := range specs {
if s.Location != "query" {
continue
}
value, exists := params[name]
required, _ := p["required"].(bool)
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
value, exists := params[s.Name]
isPaginationParam := opts.PageAll && (s.Name == "page_token" || s.Name == "page_size")
if s.Required && !isPaginationParam && (!exists || unusableParamValue(value)) {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing required query parameter: %s", name).
WithHint("lark-cli schema %s", schemaPath).
WithParam(name)
"missing required query parameter: %s", s.Name).
WithHint("%s", missingParamHint(opts, s)).
WithParam(s.Name)
}
if exists && !util.IsEmptyValue(value) {
queryParams[name] = value
if exists && !unusableParamValue(value) {
queryParams[s.Name] = value
}
// This loop owns declared query params: consume the key so the
// passthrough below can't resurrect a value the gate dropped (an
// unusable "" would otherwise be sent as an empty query value).
delete(params, s.Name)
}
// Whatever remains is undeclared — the raw escape hatch for params the
// metadata doesn't (yet) describe; passed through verbatim, no filtering.
for name, value := range params {
if _, ok := queryParams[name]; !ok {
queryParams[name] = value
}
queryParams[name] = value
}
request := client.RawApiRequest{
@@ -495,20 +627,45 @@ func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *cor
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return err
@@ -518,7 +675,12 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
output.FormatValue(out, result, output.FormatJSON)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
return nil
default:
@@ -527,9 +689,14 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
output.FormatValue(out, result, format)
return nil
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -8,13 +8,14 @@ import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
)
// highRiskDeleteMethod mirrors a simple DELETE API with a required path
// parameter and risk metadata. The returned map is what service registration
// reads; the test exercises --yes registration and the gate behavior.
func highRiskDeleteMethod() map[string]interface{} {
return map[string]interface{}{
// parameter and risk metadata. The test exercises --yes registration and the
// gate behavior.
func highRiskDeleteMethod() meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "files/{file_token}",
"httpMethod": "DELETE",
"risk": "high-risk-write",
@@ -23,11 +24,11 @@ func highRiskDeleteMethod() map[string]interface{} {
"type": "string", "location": "path", "required": true,
},
},
}
})
}
func writeMethodNoRisk() map[string]interface{} {
return map[string]interface{}{
func writeMethodNoRisk() meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "files/{file_token}",
"httpMethod": "DELETE",
"parameters": map[string]interface{}{
@@ -35,7 +36,7 @@ func writeMethodNoRisk() map[string]interface{} {
"type": "string", "location": "path", "required": true,
},
},
}
})
}
func TestServiceMethod_YesFlagRegisteredForHighRisk(t *testing.T) {

View File

@@ -4,13 +4,19 @@
package service
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
@@ -20,14 +26,14 @@ var testConfig = &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
func driveSpec() map[string]interface{} {
return map[string]interface{}{
func driveSpec() meta.Service {
return meta.ServiceFromMap(map[string]interface{}{
"name": "drive",
"servicePath": "/open-apis/drive/v1",
}
})
}
func driveMethod(httpMethod string, params map[string]interface{}) map[string]interface{} {
func driveMethod(httpMethod string, params map[string]interface{}) meta.Method {
m := map[string]interface{}{
"path": "files/{file_token}/copy",
"httpMethod": httpMethod,
@@ -41,7 +47,7 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in
},
}
}
return m
return meta.FromMap(m)
}
// ── registerService ──
@@ -49,23 +55,23 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in
func TestRegisterService(t *testing.T) {
parent := &cobra.Command{Use: "root"}
f := &cmdutil.Factory{}
spec := map[string]interface{}{
base := meta.ServiceFromMap(map[string]interface{}{
"name": "base",
"description": "Base API",
"servicePath": "/open-apis/base/v3",
}
resources := map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{
"description": "List tables",
"httpMethod": "GET",
"resources": map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{
"description": "List tables",
"httpMethod": "GET",
},
},
},
},
}
})
registerService(parent, spec, resources, f)
registerService(parent, base, f)
// service command exists
svc, _, err := parent.Find([]string{"base"})
@@ -90,18 +96,18 @@ func TestRegisterService_MergesExistingCommand(t *testing.T) {
parent.AddCommand(existing)
f := &cmdutil.Factory{}
spec := map[string]interface{}{
svc := meta.ServiceFromMap(map[string]interface{}{
"name": "base", "description": "Base API", "servicePath": "/open-apis/base/v3",
}
resources := map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{"description": "List", "httpMethod": "GET"},
"resources": map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{"description": "List", "httpMethod": "GET"},
},
},
},
}
})
registerService(parent, spec, resources, f)
registerService(parent, svc, f)
// Should reuse existing, not duplicate
count := 0
@@ -143,7 +149,7 @@ func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) {
func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", nil)
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files", nil)
if cmd.Flags().Lookup("data") != nil {
t.Error("GET method should not have --data flag")
@@ -159,7 +165,7 @@ func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
func TestNewCmdServiceMethod_POSTHasDataFlag(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "POST"}, "create", "files", nil)
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "POST"}), "create", "files", nil)
if cmd.Flags().Lookup("data") == nil {
t.Error("POST method should have --data flag")
@@ -171,7 +177,7 @@ func TestNewCmdServiceMethod_RunFCallback(t *testing.T) {
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
@@ -268,15 +274,15 @@ func TestServiceMethod_MissingPathParam(t *testing.T) {
}
func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) {
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{
})
method := meta.FromMap(map[string]interface{}{
"path": "items", "httpMethod": "GET",
"parameters": map[string]interface{}{
"q": map[string]interface{}{"location": "query", "required": true},
},
}
})
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{}`, "--dry-run"})
@@ -291,15 +297,15 @@ func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) {
}
func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{
})
method := meta.FromMap(map[string]interface{}{
"path": "items", "httpMethod": "GET",
"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"location": "query", "required": true},
},
}
})
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{}`, "--page-all", "--dry-run"})
@@ -315,10 +321,10 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--params", "{bad", "--dry-run"})
@@ -333,10 +339,10 @@ func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
func TestServiceMethod_InvalidDataJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
cmd.SetArgs([]string{"--data", "{bad", "--dry-run"})
@@ -351,10 +357,10 @@ func TestServiceMethod_InvalidDataJSON(t *testing.T) {
func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
cmd.SetArgs([]string{"--params", "-", "--data", "-", "--dry-run"})
@@ -369,10 +375,10 @@ func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--page-all", "--output", "file.bin", "--as", "bot"})
@@ -398,16 +404,27 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "success") {
t.Errorf("expected 'success' in output, got:\n%s", stdout.String())
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
if got["ok"] != true || got["identity"] != "bot" {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if !ok || data["result"] != "success" {
t.Fatalf("data = %#v, want result=success", got["data"])
}
}
@@ -427,16 +444,320 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"id"`) {
t.Errorf("expected items in output, got:\n%s", stdout.String())
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("data.items = %#v, want one item", data["items"])
}
}
type serviceContentSafetyProvider struct {
called bool
path string
data interface{}
match string
}
func (p *serviceContentSafetyProvider) Name() string { return "service-test" }
func (p *serviceContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
p.called = true
p.path = req.Path
p.data = req.Data
if p.match != "" {
b, _ := json.Marshal(req.Data)
if !strings.Contains(string(b), p.match) {
return nil, nil
}
}
return &extcs.Alert{Provider: "service-test", MatchedRules: []string{"pagination"}}, nil
}
func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &serviceContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-safety", AppSecret: "test-secret-service-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan paginated output")
}
if provider.path != "list" {
t.Fatalf("scan path = %q, want list", provider.path)
}
data, ok := provider.data.(map[string]interface{})
if !ok {
t.Fatalf("scanned data type = %T, want map", provider.data)
}
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
alert, ok := got["_content_safety_alert"].(map[string]interface{})
if !ok || alert["provider"] != "service-test" {
t.Fatalf("missing content safety alert in envelope: %#v", got)
}
}
func TestServiceMethod_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &serviceContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-stream-safety", AppSecret: "test-secret-service-stream-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan streamed paginated output")
}
if provider.path != "list" {
t.Fatalf("scan path = %q, want list", provider.path)
}
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from service-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
}
if !strings.Contains(stdout.String(), `"id":"1"`) {
t.Fatalf("expected streamed ndjson output, got: %s", stdout.String())
}
}
func TestServiceMethod_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
provider := &serviceContentSafetyProvider{match: "blocked"}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-stream-block", AppSecret: "test-secret-service-stream-block", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
t.Fatal("expected content safety block error")
}
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
}
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
}
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "pagination" {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
}
}
func TestServiceMethod_BusinessErrorReturnsTypedErrorWithoutSuccessEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-err", AppSecret: "test-secret-service-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
}
func TestServiceMethod_PageAll_DefaultBusinessErrorOutputsRawResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-err", AppSecret: "test-secret-service-pageall-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
if !strings.Contains(stdout.String(), "230027") || !strings.Contains(stdout.String(), "user not authorized") {
t.Fatalf("expected raw error response on stdout, got: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
}
func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-stream-err", AppSecret: "test-secret-service-pageall-stream-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027,
"msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier successful page to remain streamed, got: %s", out)
}
if strings.Contains(out, "230027") || strings.Contains(out, "user not authorized") {
t.Fatalf("streaming stdout should not contain raw error JSON, got: %s", out)
}
if strings.Contains(out, "\n \"code\"") {
t.Fatalf("streaming stdout should not contain indented JSON error dump, got: %s", out)
}
}
@@ -450,8 +771,8 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"})
@@ -470,7 +791,7 @@ func TestNewCmdServiceMethod_JqFlag(t *testing.T) {
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
@@ -492,7 +813,7 @@ func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
@@ -508,10 +829,10 @@ func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
func TestServiceMethod_JqAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--jq", ".data", "--output", "file.bin", "--as", "bot"})
@@ -542,8 +863,8 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--jq", ".data.items[].name"})
@@ -561,10 +882,10 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--jq", ".data", "--format", "ndjson", "--as", "bot"})
@@ -579,10 +900,10 @@ func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
func TestServiceMethod_JqInvalidExpression(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--jq", "invalid[", "--as", "bot"})
@@ -611,8 +932,8 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
@@ -628,10 +949,55 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
}
}
func TestServiceMethod_PageAll_WithJqBusinessErrorOutputsRawResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-spjq-err", AppSecret: "test-secret-spjq-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
}
if !strings.Contains(stdout.String(), "230027") || !strings.Contains(stdout.String(), "user not authorized") {
t.Fatalf("expected raw error response on stdout, got: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
}
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, code int) {
t.Helper()
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype || p.Code != code {
t.Fatalf("problem = %s/%s/%d, want %s/%s/%d", p.Category, p.Subtype, p.Code, category, subtype, code)
}
}
// ── file upload ──
func imImageMethod() map[string]interface{} {
return map[string]interface{}{
func imImageMethod() meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "images",
"httpMethod": "POST",
"requestBody": map[string]interface{}{
@@ -645,14 +1011,14 @@ func imImageMethod() map[string]interface{} {
},
},
"accessTokens": []interface{}{"user", "tenant"},
}
})
}
func imSpec() map[string]interface{} {
return map[string]interface{}{
func imSpec() meta.Service {
return meta.ServiceFromMap(map[string]interface{}{
"name": "im",
"servicePath": "/open-apis/im/v1",
}
})
}
func TestServiceMethod_FileFlagRegistered(t *testing.T) {
@@ -684,7 +1050,7 @@ func TestServiceMethod_FileFlagNotRegisteredForGET(t *testing.T) {
},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), getMethod, "get", "images", nil)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(getMethod), "get", "images", nil)
flag := cmd.Flags().Lookup("file")
if flag != nil {
t.Fatal("expected --file flag NOT to be registered for GET method")
@@ -752,7 +1118,7 @@ func TestDetectFileFields(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectFileFields(tt.method)
got := detectFileFields(meta.FromMap(tt.method))
if len(got) != len(tt.want) {
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
return
@@ -765,3 +1131,22 @@ func TestDetectFileFields(t *testing.T) {
})
}
}
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
})
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--json should be accepted without error, got: %v", err)
}
if captured == nil {
t.Fatal("expected runF to be called")
}
}

183
cmd/skill/skill.go Normal file
View File

@@ -0,0 +1,183 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package skill implements the `lark-cli skills` command group, which serves
// binary-embedded skill content to AI agents. The package is "skill"; the
// user-facing verb is "skills".
package skill
import (
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillcontent"
"github.com/spf13/cobra"
)
func newReader(f *cmdutil.Factory) (*skillcontent.Reader, error) {
if f.SkillContent == nil {
return nil, errs.NewInternalError(errs.SubtypeFileIO,
"skill content not embedded in this build")
}
return skillcontent.New(f.SkillContent), nil
}
type readEnvelope struct {
Skill string `json:"skill"`
Path string `json:"path"`
Content string `json:"content"`
Guidance string `json:"guidance,omitempty"`
}
type listEnvelope struct {
OK bool `json:"ok"`
Skills []skillcontent.SkillInfo `json:"skills"`
Count int `json:"count"`
}
type listPathEnvelope struct {
OK bool `json:"ok"`
Path string `json:"path"`
Entries []skillcontent.DirEntry `json:"entries"`
Count int `json:"count"`
}
func NewCmdSkill(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "skills",
Short: "Read embedded skill content (list / read)",
Long: "Read agent-readable skill content (SKILL.md and reference files) embedded in " +
"the CLI binary at build time, so it stays in sync with the CLI version. " +
"Machine resources such as assets/ and scripts/ are not embedded.",
}
// Risk is set on each leaf (GetRisk does not walk parents); the group has none.
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(newListCmd(f), newReadCmd(f))
return cmd
}
func newListCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "list [name[/path]]",
Short: "List skills, or list one layer under a skill path (like ls)",
Example: ` lark-cli skills list # all skills: name, description, version
lark-cli skills list lark-doc # one layer under a skill (like ls)
lark-cli skills list lark-doc/references # one layer under a subdirectory`,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 1 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"list takes at most 1 argument: [name[/path]]").
WithHint("run 'lark-cli skills list --help'")
}
r, err := newReader(f)
if err != nil {
return err
}
if len(args) == 0 {
skills, err := r.List()
if err != nil {
return err
}
output.PrintJson(f.IOStreams.Out, listEnvelope{OK: true, Skills: skills, Count: len(skills)})
return nil
}
entries, listed, err := r.ListPath(args[0])
if err != nil {
return err
}
output.PrintJson(f.IOStreams.Out, listPathEnvelope{OK: true, Path: listed, Entries: entries, Count: len(entries)})
return nil
},
}
// --json is a no-op (list is always JSON), accepted only to stay symmetric with read.
cmd.Flags().Bool("json", false, "no-op (list output is always JSON)")
cmdutil.SetRisk(cmd, "read")
cmdutil.DisableAuthCheck(cmd)
return cmd
}
func newReadCmd(f *cmdutil.Factory) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "read <name>[/<path>] [path]",
Short: "Print a skill's SKILL.md, or a file under the skill (raw markdown by default)",
Example: ` lark-cli skills read lark-doc # the skill's SKILL.md
lark-cli skills read lark-doc references/lark-doc-fetch.md # a file under the skill
lark-cli skills read lark-doc/references/lark-doc-fetch.md # same, slash form
lark-cli skills read lark-doc --json # JSON envelope`,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
name, relpath, err := parseReadTarget(args)
if err != nil {
return err
}
r, err := newReader(f)
if err != nil {
return err
}
var content []byte
var pathOut string
if relpath == "" {
content, err = r.ReadSkill(name)
pathOut = "SKILL.md"
} else {
content, pathOut, err = r.ReadReference(name, relpath)
}
if err != nil {
return err
}
isMain := pathOut == "SKILL.md"
if asJSON {
env := readEnvelope{Skill: name, Path: pathOut, Content: string(content)}
if isMain {
env.Guidance = readGuidance(name)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// Raw stdout stays byte-identical to the file; guidance goes to stderr.
if _, err := f.IOStreams.Out.Write(content); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "failed to write output: %v", err)
}
if isMain {
fmt.Fprintln(f.IOStreams.ErrOut, readGuidance(name))
}
return nil
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "output as a JSON envelope instead of raw markdown")
cmdutil.SetRisk(cmd, "read")
cmdutil.DisableAuthCheck(cmd)
return cmd
}
// parseReadTarget maps 1-or-2 positional args to (name, relpath); a lone
// "<a>/<b>" splits on the first '/', and relpath "" reads the main SKILL.md.
func parseReadTarget(args []string) (name, relpath string, err error) {
switch len(args) {
case 1:
name, relpath = skillcontent.SplitArg(args[0])
return name, relpath, nil
case 2:
return args[0], args[1], nil
default:
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"read requires 1 or 2 arguments: <name>[/<path>] [path]").
WithHint("run 'lark-cli skills read --help'")
}
}
// readGuidance routes cross-skill "../lark-foo/..." references back through
// `skills read lark-foo/...`: the path guard rejects a literal "../", so the
// relative form must be rewritten.
func readGuidance(name string) string {
return fmt.Sprintf("> Tip: read this skill's own files (e.g. `references/...`) with "+
"`lark-cli skills read %s <relative-path>` to keep them in sync with this CLI version. "+
"A reference to another skill (`../lark-foo/...`) uses the same command with the "+
"leading `../` removed: `lark-cli skills read lark-foo/...`.", name)
}

306
cmd/skill/skill_test.go Normal file
View File

@@ -0,0 +1,306 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skill
import (
"encoding/json"
"io"
"io/fs"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/cmdutil"
)
// calFS is the default single-skill content tree for these tests. The embedded
// FS is now injected through the Factory (no package global), so tests pass it
// explicitly to run() — nothing is shared, so they are safe under -parallel.
func calFS() fstest.MapFS {
return fstest.MapFS{
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Cal\"\nmetadata:\n cliHelp: \"lark-cli calendar --help\"\n---\nbody")},
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
}
}
// run executes the skills command tree against the given content FS (may be nil
// to exercise the not-embedded path) and returns stdout/stderr/err.
func run(t *testing.T, fsys fs.FS, args ...string) (stdout, stderr string, err error) {
t.Helper()
// Isolate CLI config state so tests never read/write the real config dir
// (repo convention).
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, out, errOut, _ := cmdutil.TestFactory(t, nil)
f.SkillContent = fsys
cmd := NewCmdSkill(f)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
err = cmd.Execute()
return out.String(), errOut.String(), err
}
func TestSkillList(t *testing.T) {
stdout, _, err := run(t, calFS(), "list")
if err != nil {
t.Fatalf("list error: %v", err)
}
var got struct {
OK bool `json:"ok"`
Skills []map[string]any `json:"skills"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
// "ok" is an explicit success marker (the list envelope is a typed struct;
// no automatic _notice attaches).
if !got.OK {
t.Error("expected ok=true in list envelope")
}
if got.Count != 1 || len(got.Skills) != 1 {
t.Fatalf("count: got %d", got.Count)
}
if got.Skills[0]["name"] != "lark-calendar" {
t.Errorf("name: got %v", got.Skills[0]["name"])
}
// Top-level list carries version + metadata, not a references list.
if _, ok := got.Skills[0]["references"]; ok {
t.Error("top-level list must not include references")
}
if got.Skills[0]["version"] != "1.0.0" {
t.Errorf("version: got %v, want 1.0.0", got.Skills[0]["version"])
}
if _, ok := got.Skills[0]["metadata"]; !ok {
t.Error("expected metadata in list entry")
}
}
func TestSkillListJSONFlagAccepted(t *testing.T) {
// `list --json` must be accepted (no-op), not rejected as an unknown flag,
// so it stays symmetric with read --json.
stdout, _, err := run(t, calFS(), "list", "--json")
if err != nil {
t.Fatalf("list --json error: %v", err)
}
var got struct {
OK bool `json:"ok"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if !got.OK || got.Count != 1 {
t.Errorf("envelope: %+v", got)
}
}
func TestSkillListPath(t *testing.T) {
stdout, _, err := run(t, calFS(), "list", "lark-calendar")
if err != nil {
t.Fatalf("list <name> error: %v", err)
}
var got struct {
OK bool `json:"ok"`
Path string `json:"path"`
Entries []struct {
Path string `json:"path"`
IsDir bool `json:"is_dir"`
} `json:"entries"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if !got.OK || got.Path != "lark-calendar" {
t.Errorf("envelope: %+v", got)
}
// One layer under the skill root: SKILL.md (file) + references (dir).
if got.Count != 2 || len(got.Entries) != 2 {
t.Fatalf("entries: got %+v", got.Entries)
}
if got.Entries[0].Path != "lark-calendar/SKILL.md" || got.Entries[0].IsDir {
t.Errorf("entry[0]: got %+v", got.Entries[0])
}
if got.Entries[1].Path != "lark-calendar/references" || !got.Entries[1].IsDir {
t.Errorf("entry[1]: got %+v", got.Entries[1])
}
}
func TestSkillListPathUnknown(t *testing.T) {
_, _, err := run(t, calFS(), "list", "no-such-skill")
if err == nil || !strings.Contains(err.Error(), "unknown skill") {
t.Fatalf("expected 'unknown skill' error, got %v", err)
}
}
func TestSkillListPathTraversal(t *testing.T) {
stdout, _, err := run(t, calFS(), "list", "lark-calendar/../../etc")
if err == nil || !strings.Contains(err.Error(), "invalid path") {
t.Fatalf("expected 'invalid path' error, got %v", err)
}
if stdout != "" {
t.Errorf("stdout must be empty on rejection, got %q", stdout)
}
}
func TestSkillListTooManyArgs(t *testing.T) {
_, _, err := run(t, calFS(), "list", "a", "b")
if err == nil || !strings.Contains(err.Error(), "at most 1 argument") {
t.Fatalf("expected 'at most 1 argument' error, got %v", err)
}
}
// TestSkillListSkipsDirWithoutSKILLmd proves a top-level dir lacking SKILL.md is
// omitted from the catalog (no blank entry).
func TestSkillListSkipsDirWithoutSKILLmd(t *testing.T) {
fsys := fstest.MapFS{
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\ndescription: \"Cal\"\n---\nb")},
"not-a-skill/readme.txt": {Data: []byte("junk")}, // dir without SKILL.md
}
stdout, _, err := run(t, fsys, "list")
if err != nil {
t.Fatalf("list error: %v", err)
}
var got struct {
Skills []map[string]any `json:"skills"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if got.Count != 1 || got.Skills[0]["name"] != "lark-calendar" {
t.Fatalf("expected only lark-calendar, got %+v", got.Skills)
}
}
func TestSkillReadRaw(t *testing.T) {
stdout, stderr, err := run(t, calFS(), "read", "lark-calendar")
if err != nil {
t.Fatalf("read error: %v", err)
}
if !strings.HasPrefix(stdout, "---\nname: lark-calendar") {
t.Errorf("raw output: got %q", stdout)
}
// Raw stdout is byte-pure SKILL.md — the guidance tip must NOT be appended.
if strings.Contains(stdout, "Tip:") {
t.Errorf("raw stdout must not carry the guidance tip: got %q", stdout)
}
// Guidance goes to stderr: own files via `skills read <name> ...`, and
// cross-skill refs routed to `skills read <other-skill> ...` (version-
// consistent), not "read directly".
if !strings.Contains(stderr, "lark-cli skills read lark-calendar <relative-path>") {
t.Errorf("expected own-files guidance on stderr: got %q", stderr)
}
if !strings.Contains(stderr, "lark-cli skills read lark-foo/...") {
t.Errorf("expected cross-skill refs routed to skills read: got %q", stderr)
}
if strings.Contains(stderr, "instead of opening them directly") ||
strings.Contains(stderr, "read those directly") {
t.Errorf("guidance must not steer cross-skill refs to direct reads: got %q", stderr)
}
}
func TestSkillReadJSON(t *testing.T) {
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "--json")
if err != nil {
t.Fatalf("read --json error: %v", err)
}
var got struct {
Skill, Path, Content, Guidance string
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v", e)
}
if got.Skill != "lark-calendar" || got.Path != "SKILL.md" || got.Content == "" {
t.Errorf("envelope: %+v", got)
}
// Guidance is a separate field, not merged into content.
if got.Guidance == "" {
t.Error("expected guidance field for main SKILL.md")
}
if strings.Contains(got.Content, "Tip:") {
t.Error("guidance must not be merged into content")
}
}
func TestSkillReadFile(t *testing.T) {
// Both the 2-arg and slash forms read the same file, with no guidance tip.
for _, args := range [][]string{
{"read", "lark-calendar", "references/agenda.md"},
{"read", "lark-calendar/references/agenda.md"},
} {
stdout, stderr, err := run(t, calFS(), args...)
if err != nil {
t.Fatalf("read %v error: %v", args, err)
}
if stdout != "# Agenda" {
t.Errorf("read %v output: got %q", args, stdout)
}
// Reference reads carry no guidance on either stream.
if strings.Contains(stderr, "Tip:") {
t.Errorf("read %v must not emit guidance on stderr: got %q", args, stderr)
}
}
}
func TestSkillReadFileJSON(t *testing.T) {
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "references/agenda.md", "--json")
if err != nil {
t.Fatalf("read file --json error: %v", err)
}
var got struct {
Skill, Path, Content, Guidance string
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if got.Skill != "lark-calendar" || got.Path != "references/agenda.md" || got.Content != "# Agenda" {
t.Errorf("envelope: %+v", got)
}
// Reference reads do not carry the guidance tip.
if got.Guidance != "" {
t.Errorf("reference read must not include guidance, got %q", got.Guidance)
}
}
func TestSkillReadUnknown(t *testing.T) {
_, _, err := run(t, calFS(), "read", "no-such")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "unknown skill") {
t.Errorf("err: %v", err)
}
}
func TestSkillReadMissingArg(t *testing.T) {
_, _, err := run(t, calFS(), "read")
if err == nil || !strings.Contains(err.Error(), "requires 1 or 2 arguments") {
t.Fatalf("expected arg error, got %v", err)
}
}
func TestSkillReadTraversal(t *testing.T) {
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "../../etc/passwd")
if err == nil {
t.Fatal("expected rejection")
}
if !strings.Contains(err.Error(), "invalid path") {
t.Errorf("err: %v", err)
}
if stdout != "" {
t.Errorf("stdout must be empty on rejection, got %q", stdout)
}
}
func TestSkillNilContentFS(t *testing.T) {
_, _, err := run(t, nil, "list")
if err == nil {
t.Fatal("expected error when SkillContent is nil")
}
if !strings.Contains(err.Error(), "not embedded") {
t.Errorf("err: %v", err)
}
}

View File

@@ -11,6 +11,8 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -72,6 +74,136 @@ func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
}
}
func TestUnknownFlagTokens(t *testing.T) {
_, drive, _ := newGroupTree()
// Give a subcommand a flag so a misplaced-but-known flag (the user omitted
// the subcommand) is distinguished from a genuinely unknown one.
for _, c := range drive.Commands() {
if c.Name() == "+search" {
c.Flags().String("query", "", "")
}
}
cases := []struct {
name string
rawArgs []string
want []string
}{
{"genuinely unknown long flag", []string{"drive", "--badflag"}, []string{"--badflag"}},
{"flag known on a subcommand (misplaced)", []string{"drive", "--query", "x"}, nil},
{"no flags at all", []string{"drive"}, nil},
{"tokens after -- are positional", []string{"drive", "--", "--badflag"}, nil},
{"unknown shorthand", []string{"drive", "-Z"}, []string{"-Z"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := unknownFlagTokens(drive, tc.rawArgs)
if len(got) != len(tc.want) {
t.Fatalf("unknownFlagTokens(%v) = %v, want %v", tc.rawArgs, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("token[%d] = %q, want %q", i, got[i], tc.want[i])
}
}
})
}
}
func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
// Simulate `lark-cli drive --badflag`: the UnknownFlags whitelist swallows
// --badflag, so RunE sees no args; the guard must recover it from
// rawInvocationArgs and fail structured rather than print help + exit 0.
rawInvocationArgs = []string{"drive", "--badflag"}
t.Cleanup(func() { rawInvocationArgs = nil })
err := drive.RunE(drive, nil)
if err == nil {
t.Fatal("expected a structured unknown_flag error, got nil (help fallthrough)")
}
if !strings.Contains(err.Error(), "unknown flag") {
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
}
// Typed surface: a validation error (exit 2) whose Params carries the
// offending flag so an agent can recover the token without parsing prose.
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--badflag" {
t.Errorf("params = %v, want one entry named --badflag", verr.Params)
}
}
func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing.T) {
_, drive, _ := newGroupTree()
// --query is defined on the +search subcommand, so it is a *valid* flag that
// was placed before the (omitted) subcommand. Unlike an unknown flag, this
// must still fail structured (missing_subcommand) rather than fall through to
// help + exit 0 — `drive --query x` is a malformed call, not a help request.
for _, c := range drive.Commands() {
if c.Name() == "+search" {
c.Flags().String("query", "", "")
}
}
installUnknownSubcommandGuard(drive.Root())
rawInvocationArgs = []string{"drive", "--query", "x"}
t.Cleanup(func() { rawInvocationArgs = nil })
err := drive.RunE(drive, nil)
if err == nil {
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
}
if !strings.Contains(verr.Message, "missing subcommand") {
t.Errorf("message = %q, want it to mention a missing subcommand", verr.Message)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--query" {
t.Errorf("params = %v, want one entry named --query", verr.Params)
}
if !strings.Contains(verr.Message, "lark-cli drive") {
t.Errorf("message = %q, want it to name the group path", verr.Message)
}
}
// A bare group carrying only a group-valid global flag (e.g. the inherited
// --profile) is not missing a subcommand — those flags do not belong to a
// subcommand — so it must print help, not fail with missing_subcommand.
func TestUnknownSubcommandRunE_GroupValidGlobalFlagShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree()
drive.Root().PersistentFlags().String("profile", "", "") // global, inherited by drive
installUnknownSubcommandGuard(drive.Root())
rawInvocationArgs = []string{"--profile", "p", "drive"}
t.Cleanup(func() { rawInvocationArgs = nil })
var buf bytes.Buffer
drive.SetOut(&buf)
drive.SetErr(&buf)
if err := drive.RunE(drive, nil); err != nil {
t.Fatalf("bare group with only a global flag should print help, got error: %v", err)
}
if !strings.Contains(buf.String(), "drive ops") {
t.Errorf("expected help output, got:\n%s", buf.String())
}
}
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
@@ -97,45 +229,23 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
t.Fatal("expected error for unknown subcommand")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("expected exit code %d, got %d", output.ExitValidation, exitErr.Code)
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("expected exit code %d, got %d", output.ExitValidation, output.ExitCodeOf(err))
}
if exitErr.Detail == nil {
t.Fatal("expected ExitError to carry Detail")
if !strings.Contains(verr.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", verr.Message)
}
if exitErr.Detail.Type != "unknown_subcommand" {
t.Errorf("expected Detail.Type=unknown_subcommand, got %q", exitErr.Detail.Type)
if !strings.Contains(verr.Message, "lark-cli drive") {
t.Errorf("message should name the group path, got %q", verr.Message)
}
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
}
if strings.Contains(exitErr.Detail.Hint, "+secret") {
t.Error("hidden commands must not appear in the hint")
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("expected Detail.Detail to be map[string]any, got %T", exitErr.Detail.Detail)
}
if detail["unknown"] != "+bogus" {
t.Errorf("detail.unknown should be +bogus, got %v", detail["unknown"])
}
if detail["command_path"] != "lark-cli drive" {
t.Errorf("detail.command_path should be %q, got %v", "lark-cli drive", detail["command_path"])
}
available, ok := detail["available"].([]string)
if !ok {
t.Fatalf("detail.available should be []string, got %T", detail["available"])
}
if len(available) != 3 {
t.Errorf("expected 3 available entries (hidden excluded), got %d: %v", len(available), available)
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
// back to pointing at --help (suggestions, when present, are folded into hint).
if !strings.Contains(verr.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", verr.Hint)
}
}
@@ -144,13 +254,12 @@ func TestUnknownSubcommandRunE_NestedResourceGroup(t *testing.T) {
installUnknownSubcommandGuard(root)
err := files.RunE(files, []string{"bogus"})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError on nested group, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError on nested group, got %T", err)
}
if exitErr.Detail.Detail.(map[string]any)["command_path"] != "lark-cli drive files" {
t.Errorf("command_path should reflect the nested resource, got %v",
exitErr.Detail.Detail.(map[string]any)["command_path"])
if !strings.Contains(verr.Message, "lark-cli drive files") {
t.Errorf("message should reflect the nested resource path, got %q", verr.Message)
}
}
@@ -164,7 +273,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
)
got := availableSubcommandNames(root)
got, _ := availableSubcommandNames(root)
want := []string{"alpha", "gamma"}
if len(got) != len(want) {
t.Fatalf("expected %v, got %v", want, got)
@@ -175,3 +284,56 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
}
}
}
func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
root.AddCommand(
&cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
)
available, deprecated := availableSubcommandNames(root)
if len(available) != 1 || available[0] != "+new-cmd" {
t.Errorf("available = %v, want [+new-cmd]", available)
}
if len(deprecated) != 1 || deprecated[0] != "+old-cmd" {
t.Errorf("deprecated = %v, want [+old-cmd]", deprecated)
}
}
// unknownSubcommandRunE ranks suggestions across both current and deprecated
// subcommands so a mistyped legacy alias resolves; the closest match is folded
// into the hint.
func TestUnknownSubcommandRunE_SuggestsAcrossDeprecatedBucket(t *testing.T) {
svc := &cobra.Command{Use: "sheets"}
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
svc.AddCommand(
&cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
)
err := unknownSubcommandRunE(svc, []string{"+reat"})
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
// "+reat" is closest to the deprecated +read: the candidate must surface
// both as a machine-readable param suggestion (for agent retry) and in the
// hint, proving ranking spans the deprecated bucket.
if len(verr.Params) != 1 || verr.Params[0].Name != "+reat" {
t.Fatalf("params = %v, want one entry named +reat (the offending subcommand)", verr.Params)
}
foundSuggestion := false
for _, s := range verr.Params[0].Suggestions {
if s == "+read" {
foundSuggestion = true
}
}
if !foundSuggestion {
t.Errorf("Params[0].Suggestions should include +read, got %v", verr.Params[0].Suggestions)
}
if !strings.Contains(verr.Hint, "+read") {
t.Errorf("hint %q should suggest +read (typo target across deprecated bucket)", verr.Hint)
}
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
@@ -132,12 +133,14 @@ func updateRun(opts *UpdateOptions) error {
// 1. Fetch latest version
latest, err := fetchLatest()
if err != nil {
return reportError(opts, io, output.ExitNetwork, "network", "failed to check latest version: %s", err)
return reportError(opts, io, "network",
errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to check latest version: %s", err).WithCause(err))
}
// 2. Validate version format
if update.ParseVersion(latest) == nil {
return reportError(opts, io, output.ExitInternal, "update_error", "invalid version from registry: %s", latest)
return reportError(opts, io, "update_error",
errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid version from registry: %s", latest))
}
// 3. Compare versions
@@ -166,15 +169,18 @@ func updateRun(opts *UpdateOptions) error {
// --- Output helpers ---
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errType, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
// reportError emits the failure on the requested surface: JSON mode prints the
// {ok:false, error:{type, message}} envelope to stdout and signals the typed
// error's exit code bare; human mode returns the typed error for the
// dispatcher to render.
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, errType string, typedErr errs.TypedError) error {
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{"type": errType, "message": msg},
"ok": false, "error": map[string]interface{}{"type": errType, "message": typedErr.ProblemDetail().Message},
})
return output.ErrBare(exitCode)
return output.ErrBare(output.ExitCodeOf(typedErr))
}
return output.Errorf(exitCode, errType, "%s", msg)
return typedErr
}
func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
@@ -228,7 +234,8 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
restore, err := updater.PrepareSelfReplace()
if err != nil {
return reportError(opts, io, output.ExitAPI, "update_error", "failed to prepare update: %s", err)
return reportError(opts, io, "update_error",
errs.NewAPIError(errs.SubtypeUnknown, "failed to prepare update: %s", err).WithCause(err))
}
if !opts.JSON {

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