Compare commits

...

133 Commits

Author SHA1 Message Date
zhaoyukun.yk
4d15abb547 feat(extension): present restricted commands as absent and trim skills
Add host-level opt-in concealment for plugin-restricted commands while keeping Restrict enforcement and the established legacy and YAML behavior unchanged.

Compose external wrapper-owned embedded skill trees with fail-closed Base, Allow, Remove, Overlay, and structured reference remaps. Retire CLI-owned dead affordances across help, completion, flags, notices, diagnostics, and recovery.

Keep presentation and skill state build-local and single-sourced, preserve typed producer errors, and cover source compatibility, multi-build isolation, failure paths, and real external fork integrations.
2026-07-30 16:53:17 +08:00
calendar-assistant
1f565a290b docs(calendar): warn against container-default timezone in time conversion (#2104)
Agents dropping to the raw `calendar events create/patch` API must convert
wall-clock time to Unix timestamps themselves. In UTC containers this silently
yields an 8-hour offset. Require explicit ISO 8601 offsets on +create/+update
--start/--end, and warn that raw-API timestamp conversion must specify the
target timezone instead of relying on the container default.
2026-07-30 14:06:14 +08:00
yballul-bytedance
68a77eee5c feat: support visible_rule for form questions (#1891)
Form questions can now carry a visible_rule (display condition) so a question shows only when earlier questions match the rule. The rule shares the exact same structure as the view filter, so extract that structure into a single shared reference (lark-base-filter-condition.md) that both view-set-filter and visible_rule point to.

- create/update shortcuts: document visible_rule in --questions help and transcribe the questions body (including visible_rule) into dry-run output
- document that form question updates use full overwrite semantics and must preserve existing fields via read-modify-write
- skill refs: add visible_rule sections to form-questions create/update, note it is only needed when the user asks for a display condition, and clarify that the shared tuple filter protocol does not apply to data-query filters
- tests: pin flag help, verbatim visible_rule passthrough on create/update/list, and add dry-run E2E coverage

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-07-30 12:37:24 +08:00
liangshuo-1
29a97dbde8 chore: release v1.0.80 (#2101) 2026-07-29 21:37:15 +08:00
R0bynZhu
29a6a7b600 docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
* docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由

trace 里 +create 的三类高频错误(--yes、--name、--slides 塞文件路径)
共同点是调用前没读 lark-slides-create.md。原因不是文档缺内容,而是
SKILL.md 里 +create 的信息「够又不够」:给了半截参数描述,模型觉得
够用就直接拼命令,不再打开文档。

- 删掉「创建方式选择」整节(表格 + 两条 WARNING),下沉到 create.md,
  由生成流程 Step 3 和核心规则 2 指向那份文档
- Shortcuts 表 +create 行、核心规则 2 不再复述参数
- Quick Reference 顶部说明参数以文档和 --help 为准,「新建 PPT」行补上
  create.md
- PPTX 一行改写为 drive +import 导入路径;create.md 里写明本命令不读
  本地文件
- create.md 增加「--slides 不接受的形态」对照表,并合并开头零散的
  禁止/推荐/最稳/注意条目
- @ 占位符统一写成 <img src="@./path">,消除「--slides 支持 @ 路径」的歧义

* docs(slides): 去掉 create.md 里的「--slides 不接受的形态」对照表

* docs(slides): 模板一行的触发条件补上「已有 PPTX 要改」

* docs(slides): create.md 澄清「不读取本地文件」的歧义

原句「本命令只从零创建演示文稿,不读取本地文件」与本文档
「本地图片:@<path> 占位符」一节自相矛盾——@ 占位符恰恰会读
本地图片并自动上传。改为只否定「导入本地 PPT 文件的参数」,
不波及图片占位符能力。

* docs(slides): 两步创建的第二步补上 slide create 文档路由

生成流程 Step 3 和「执行前必做」的创建一行原来只指向
lark-slides-create.md,而两步创建的第二步用的是
xml_presentation.slide create,文档没被路由到,模型只能凭
记忆拼参数。
2026-07-29 20:50:24 +08:00
liangshuo-1
c167163d70 feat: propagate invocation metadata (#2097) 2026-07-29 19:39:53 +08:00
zhaojiaxing-coding
7988515e1c feat(drive): add +permission-get-setting shortcut (#1738)
* feat(drive): add +permission-get-setting shortcut

Add a Drive shortcut for reading public permission settings across supported documents, files, folders, and wiki nodes. Resolve URLs into typed resources, preserve permission_public output for machine consumers, and document the shortcut in the permission-governance workflow.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Query the Drive v2 public permission endpoint with typed validation and user or bot identity

- Support folder permission inspection without recursing into child resources

- Add unit, dry-run E2E, live workflow, output, and skill guidance coverage

* fix(drive): harden permission get setting contract

Harden +permission-get-setting after review findings so callers receive only the documented permission payload and folder support is verified against the live workflow. This prevents malformed responses from being presented as permission settings and keeps the command guidance aligned with the shortcut contract.

Key fixes:
- Reject responses without data.permission_public instead of projecting arbitrary payload fields
- Render complete permission settings in pretty output and mark --token required
- Exercise a created Drive folder in the live workflow and add the command reference
- Correct folder resolution guidance while retaining the shortcut's documented URL forms

* feat/drive-folder-permission-get
2026-07-29 17:57:24 +08:00
zhaojiaxing-coding
c7adff7a3b feat(drive): add +member-list shortcut (#1795)
* feat(drive): add +member-list shortcut

Add a Drive shortcut for listing collaborators on documents, files, folders, and wiki nodes. Resolve supported resource URLs into typed permission requests, preserve raw API data for machine consumers, and keep invalid flag combinations on typed validation paths.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Validate optional member fields and wiki-only permission type filters

- Provide pretty output, skill guidance, unit coverage, and dry-run/live E2E workflows

- Read dry-run assertions from the standard data.api success envelope

* feat/drive-member-list
2026-07-29 17:04:59 +08:00
ethan-zhx
59237f3104 Feat/detect line text overlap (#2069)
* fix: report ghost text canvas overflow

* fix(slides): detect text-line overlap in xml_text_overlap_lint
2026-07-29 16:20:59 +08:00
R0bynZhu
358cd06838 docs(slides): 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
* docs(slides): consolidate CWD-relative path rule into one global rule

State the "all local file path args must be CWD-relative (absolute
rejected)" rule once in SKILL.md 权威经验, and trim the per-command
repetitions in media-upload / create / screenshot / xml-presentations-get.
Also fix the stale xml-presentations-get param table: --output is optional
(relative), not required.

* feat: try common solution

* chore: 优化措辞

* feat: 优化措辞

* feat: 优化措辞

* docs(slides): 强调调用命令前必读对应命令文档

- 「调用命令前再读」改为「调用相关命令前必须读取相关的文档以了解命令的使用方式」,
  并把原「按需再读」列表合并进来,去掉可选语义
- 移除 lark-shared 的 CRITICAL 前置阅读要求
- Step 4 回读示例补全 `--presentation <xml_presentation_id>` 参数

* docs(slides): Shortcuts 表补充 +screenshot 并写明本地路径参数

- 新增 +screenshot 行:--slide-number 页号(从 1 开始,可重复,一次最多 10 页)、
  --output-dir 保存目录(CWD 内相对路径,默认 .lark-slides/screenshots)
- +xml-get 行补上 --presentation 和 --output(CWD 内相对路径),
  并说明省略 --output 时 XML 返回在 JSON 信封里

* revert(slides): 回退 references 下的文档改动,只保留 SKILL.md

把 lark-slides-create.md、lark-slides-media-upload.md、lark-slides-screenshot.md、
lark-slides-xml-presentations-get.md 还原为 main 的版本,本分支只改 SKILL.md。

* docs(slides): 恢复开始前必读 lark-shared 的 CRITICAL 要求

认证、权限和全局参数以 lark-shared 为准,这条前置阅读不该在本分支被删掉。

* chore: 移除output省略的说明
2026-07-29 10:55:05 +08:00
Yuxuan Zhao
b0b1ca4b5d test(e2e): wait for base role update visibility (#2087) 2026-07-28 21:45:00 +08:00
liangshuo-1
781d188a60 chore: release v1.0.79 (#2082) 2026-07-28 21:02:37 +08:00
calendar-assistant
2e0fb9a880 docs(calendar): refine attendee guidance for bots and user-search identity (#2086)
Consolidate the user-search identity note into SKILL.md, and clarify bot
handling across attendee flows: bots are virtual identities with no
free/busy semantics, no meeting-room seat, and no room preference, so
they must be excluded from +suggestion, +room-find, and the scheduling
free/busy check. Note in create/update that bots remain valid attendees.
2026-07-28 20:34:09 +08:00
ILUO
927b37cd63 docs(task): document create data passthrough (#2080) 2026-07-28 20:26:35 +08:00
zhangjun-bytedance
d2e22c5fca feat: 0728 fix url (#2079) 2026-07-28 19:05:47 +08:00
ethan-zhx
fdae560014 docs(slides): add formula inline element syntax to quick-ref (#2077)
* docs(slides): add formula inline element syntax to quick-ref

* docs(slides): add chart gradient syntax to quick-ref
2026-07-28 17:40:54 +08:00
zhengzhijiej-tech
1b173e1953 fix(sheets): recognize OFL0X local office tokens (#2063) 2026-07-28 15:09:42 +08:00
ethan-zhx
57db1b3a8d feat(slides):update xsd (#2067) 2026-07-28 14:43:15 +08:00
calendar-assistant
4c1c5f5287 docs(calendar): clarify identity selection by event ownership (#2071)
Reframe the identity section around event ownership: use `--as user`
for the logged-in user's own events and `--as bot` for events the bot
creates or participates in, with matching `+agenda` examples.
2026-07-28 14:05:21 +08:00
liangshuo-1
3d2c10cd0b fix(ci): validate static workflow identity (#2015) 2026-07-27 19:39:11 +08:00
liangshuo-1
03de81c5f3 chore: release v1.0.78 (#2061) 2026-07-27 19:17:53 +08:00
yballul-bytedance
7abcaa7f68 feat(drive): add title+body joint search guidance and Top N pagination rules (#2059)
* feat(drive): add title+body joint search guidance and pagination rules for Top N results

- Add new blockquote explaining combined title+body search: use a single
  --query with both keywords instead of splitting into two searches
- Add rule for Top N results: N is an output cap, not --page-size; scan
  up to 3 pages filtering by title and summary_highlighted, read body
  only for title-matched candidates, stop early at N confirmed results
- Add quick-reference table row for folder-scoped title+body search
- Update pagination strategy rule to cover the 3-page cap for joint
  search in addition to the existing 5-page limit for other scenarios

* feat(drive): clarify Top N search output limit

* feat(drive): clarify search filters share one call

---------

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-27 17:19:48 +08:00
zhangjun-bytedance
8fb2476985 0727 fix rich text (#2062) 2026-07-27 16:17:08 +08:00
zhanghuanxu
56c9a2afd8 fix: exempt ghost text from slides lint 2026-07-27 11:59:04 +08:00
zhanghuanxu
2029189809 fix(slides):text may over flow shape 2026-07-27 11:59:04 +08:00
zhanghuanxu
ee427979a8 fix(slides): preserve info lint severity 2026-07-27 11:59:04 +08:00
zhanghuanxu
545abcbbde fix: refine character width estimation for lark-slides text lint
Replace the uniform 0.55em half-width coefficient with per-character-type
coefficients, add font-family awareness (sans/serif), bold multiplier,
letter-spacing support, and fix padding-aware line wrapping.

- Split half-width chars into uppercase (0.57), lowercase (0.51 sans / 0.53
  serif), digits (0.58), and punctuation (0.50)
- Add classify_font_family() to apply slightly wider lowercase widths for
  serif fonts (Georgia, Source Han Serif/思源宋体, Times, etc.)
- Add 5% width multiplier for bold text; detect <strong>/<b>/<i>/<em> tags
  and span-level bold/italic attributes in addition to content attrs
- Fix estimate_text_line_count_for_text to subtract paddingLeft/paddingRight
  from available width before computing wrap lines
- Add resolve_letter_spacing and wire letterSpacing through estimate_text_width
- Extract fontFamily/bold/italic/letterSpacing into element dict during parse
2026-07-27 11:59:04 +08:00
zhanghuanxu
4a73e83f1e fix(slides): allow chartParsedValues roundtrip tag
chartParsedValues is a server-injected roundtrip child tag under
chartField, not an attribute. Move it from ROUNDTRIP_SXSD_ATTRS to a
new ROUNDTRIP_SXSD_TAGS set and skip the tag (and its subtree) in the
SXSD tag whitelist check.
2026-07-27 11:59:04 +08:00
zhanghuanxu
7496420fa8 fix(slides): downgrade background-decoration text overflow to info
Large low-alpha text underneath other text shapes is typically a
background design element; treat text_may_overflow_shape as info in
that case instead of warning/error.
2026-07-27 11:59:04 +08:00
zhanghuanxu
43fabdf524 fix(slides): detect letterSpacing-driven text overflow
Extract letterSpacing from content/paragraph attrs and factor it into
width and line-count estimates, and stop short-circuiting the shape
overflow check for autoFit shapes so that letterSpacing-heavy captions
under normal-auto-fit no longer escape detection.
2026-07-27 11:59:04 +08:00
zhanghuanxu
8c46c74105 fix(slides): upgrade text overflow to error above 10px threshold
Text-shape overflow was always reported as a warning, which let clearly
broken pages pass the lint gate. Overflow > 10px now upgrades to error;
smaller overflows stay as warning to avoid flagging near-fit cases.
2026-07-27 11:59:04 +08:00
zhanghuanxu
70777c86c3 fix(slides): restrict canvas overflow checks 2026-07-27 11:59:04 +08:00
zhangjun-bytedance
38e8806d91 feat: event description support rich text (#1975)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:48:01 +08:00
liangshuo-1
a7865cd0a7 chore: release v1.0.77 (#2051) 2026-07-24 19:20:52 +08:00
BD-ZERO
f77b7eea68 fix(slides): support CSV multi-value for --slide-id in screenshot (#2047)
--slide-id used the cobra StringArray flag type, which only accepts
repeated flags and does not split comma-separated values, unlike
--slide-number (int_array -> cobra IntSlice) which already supported
CSV input. This made the two selector flags inconsistent.

Switch --slide-id to the string_slice flag type (cobra StringSlice),
which natively supports both comma-separated and repeated values, and
update the flag readers from StrArray to StrSlice. normalizeSlideIDs
already trims/dedupes/filters blanks, and
validateSlidesScreenshotSelectorLimit already caps the combined
selector count, so both continue to apply unchanged to CSV input.

Add tests covering --slide-id CSV parsing, whitespace/duplicate
normalization, and the >10 selector limit via CSV, mirroring the
existing --slide-number coverage.

Address review feedback:
- Fix "comma-separate" -> "comma-separated" wording in the --slide-id
  flag description (CodeRabbit).
- Set LARKSUITE_CLI_CONFIG_DIR to t.TempDir() in the new screenshot
  tests, per the AGENTS.md testing convention, so local configuration
  state cannot leak into or be modified by the suite.
- Add a dry-run E2E test (tests/cli_e2e/slides) that pins --slide-id
  CSV parsing through the built CLI binary and asserts the emitted
  slide_ids request body, per the AGENTS.md dry-run E2E requirement
  for shortcut flag/param changes.
- Update the lark-slides skill reference to document that --slide-id
  and --slide-number both accept comma-separated values, not just
  repeated flags, so agents can discover the new syntax.
2026-07-24 18:32:36 +08:00
fangshuyu-768
dd7f741b62 docs(skills): clarify callout child rules (#2048) 2026-07-24 18:18:32 +08:00
kiraWangRuilong
e7d5ecdd01 feat: add risk-control protection (#1910)
1. Add baseline safe protection for Feishu/Lark API endpoints.
2. Add lark-cli config risk-control on|off|default command for workspace-level safety protection control.
2026-07-24 17:12:10 +08:00
zhanghuanxu
4807283368 fix(slides): declare screenshot scope 2026-07-24 15:25:11 +08:00
ILUO
d2bb36591f fix/task search pagination (#2041)
* fix: send task search page token in query

* test: assert task search dry-run pagination contract
2026-07-24 14:28:54 +08:00
yballul-bytedance
5a54bc07db fix(base): classify +form-submit as high-risk-write (#1969)
Form submission writes and submits data through a public share link, an
irreversible action that should require explicit confirmation. Reclassify
the shortcut from write to high-risk-write so the runner's --yes gate fires
before execution, matching +form-delete and other high-risk base commands.

Update the lark-base skill docs (--yes on all examples, param table, tips)
and add tests pinning the confirmation gate (unit) and dry-run structure (e2e).

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-24 11:11:37 +08:00
BD-ZERO
a528b3cb69 feat(slides): add layout density lint for sparse/empty containers (#2022)
feat(slides): add layout density lint for sparse/empty containers

Extend the XML layout lint into a single release gate for Slides XML:

- Add blank_slide, sparse_container_content, and sparse_slide_content
  detection, using visibility- and coverage-aware heuristics (alpha
  filtering, image-overlay/layout-panel exemptions, similar-short-card
  grouping) to avoid flagging intentional whitespace or background
  panels
- Broaden out-of-canvas detection from table/chart/text-only to every
  element kind, with rotation-aware bounding boxes and geometry
  extraction for icon/line/polyline
- Restructure output to schema v2.0: every issue carries rule
  (id/name/comparison/threshold), measurement, related_objects, and
  hint; summary gains status/release_ready/screenshot_review_required
- Change CLI exit-code semantics so only errors block (exit 1);
  warning-only output still exits 0 to let downstream screenshot review
  proceed
- Harden XML attribute parsing (single/double-quoted and spaced
  attributes, self-closing tags no longer bleeding content into the
  next element) and fix edge cases surfaced during review
  (image-overlay coverage ratio, invisible container/panel exemptions,
  bbox_overlap measurement consistency, background-only slide bypass,
  invisible short-card peers)
- Update SKILL.md, validation-checklist.md, and troubleshooting.md to
  match the new gate; add regression tests for the new rules and fixes
2026-07-24 10:47:15 +08:00
huarenmin13
f0176af330 docs(base): clarify complete and partial updates (#1993)
* docs(base): clarify complete and partial updates

Consolidate the update rule introduced in #1879 and make the command-contract boundary explicit. Full-update commands must use trusted current configuration for the first actual request, while delta commands should send the smallest legal payload.

* docs(base): clarify full-update state preservation

Address review feedback by requiring unchanged writable configuration to remain intact, except when the requested update makes a setting inapplicable.

* docs(base): strengthen update contract guidance
2026-07-24 00:01:35 +08:00
R0bynZhu
715aa8d960 feat(slides): fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
From EVAL-07-22-02-53 (42 convos), agents fell back to the full XSD for:
- shape type enum + presetHandlers (rounded corners)
- polyline (bounding-box positioning, required border, connector type)
- table merged cells (colspan / rowspan)

Add compact coverage for each, sized to real usage (shape/polyline type
lists trimmed to what actually appears in generations). Chart gaps deferred.
2026-07-23 22:18:17 +08:00
ILUO
ebc0c53ab5 fix/task id handling (#2023)
* fix: validate task GUID inputs

* fix: make task updates self-confirming

* fix: confirm task completion state

* docs: clarify task ID workflow

* test: cover task ID dry runs

* fix: address task ID review feedback
2026-07-23 20:48:38 +08:00
fangshuyu-768
1e682bd97c fix(slides): normalize presentation flag aliases (#2032) 2026-07-23 18:43:30 +08:00
fangshuyu-768
70424c486c docs(skill): clarify scope handling for query expansion (#2030) 2026-07-23 18:35:44 +08:00
liangshuo-1
b8f56dbc0b feat(apps): support absolute and relative upload paths (#2005) 2026-07-23 17:52:49 +08:00
chenxingyang1019
c74d9b63fb feat(apps): validate +file-list --page-size against server (0, 200] range (#2007)
paas_storage AppFileListForOpenAPI rejects page_size > 200 at the inner
checkMaxKeys guard with ErrInvalidRequest("maxKeys not in range (0, 200]").
Previously the CLI forwarded any --page-size straight to the API, so
--page-size 500 produced an opaque server error round-trip.

Add a client-side Validate check bounding --page-size to [1, 200] (aligned
with the existing validateAppsPageSize precedent in the observability
commands): out-of-range values now fail fast with a typed validation error
and never hit the network. The server tolerates page_size <= 0 by defaulting
to 20, but the CLI default is already 20 and an explicit < 1 is a user error,
so we reject it for a clearer message, consistent with other list commands.

Update the flag description and the lark-apps-file skill reference to
document the 1..200 range, and cover the boundaries in unit tests.
2026-07-23 15:55:08 +08:00
91-enjoy
67015eef8e feat: introducing official card icon (#1973)
Card header icon documentation contained invalid tokens (e.g., mail_colorful, approve_colorful) that do not render, and icon guidance lacked precise token enumeration, causing LLM to guess or fabricate icon tokens. This PR replaces
examples with valid tokens and adds a definitive colorful icon reference table.
2026-07-23 11:01:14 +08:00
liangshuo-1
af8507ea8e chore: release v1.0.76 (#2016) 2026-07-22 23:36:33 +08:00
liangshuo-1
02c2ebcf7c chore: release v1.0.75 (#2014) 2026-07-22 22:29:15 +08:00
liangshuo-1
abf6f99d7e fix(slides): preserve raw XML output verbatim (#2013)
Keep --raw and file output byte-exact by returning the server response without XML reserialization.
2026-07-22 22:06:26 +08:00
tianyouskrrr
8ba910eb9f fix(slides): reindent xml-get output for readability (#1987)
The API always returns presentation/slide XML as a single unindented
line, which is unreadable for decks with many shapes (e.g. PPTX-imported
presentations). slides +xml-get now formats it on the surfaces meant for
a human or a line tool to read:

- --raw and --output reindent the XML with etree so each structural
  element (presentation/slide/shape/style/...) sits on its own line.
  Reformatting never recurses into schema-mixed text-bearing elements
  (p, span, strong, em, u, del, a, shadow, outline, chartTitle,
  chartSubTitle), so rich-text content stays exactly as parsed. CDATA
  sections and the schema's &#32;/&#9;/&#13;/&#10; whitespace character
  references (decimal, hex, and zero-padded) are preserved through the
  parse/write pass instead of being silently normalized away. There is
  no flag to disable this formatting.
- The default JSON envelope returns the server's XML verbatim: it is
  never parsed, so it stays a byte-exact copy of the API response, at
  no reformatting cost and with no failure mode on this path.
- If reformatting --raw/--output content fails (non-strict XML from the
  service), the command falls back to the original content, prints a
  warning to stderr, and reports pretty_printed: false in --output file
  metadata.

Adds github.com/beevik/etree as a direct dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 21:08:29 +08:00
zgz2048
78bf126bb0 docs(base): align record write schema guidance (#2000)
* docs(base): align record write schema guidance

* docs(base): use canonical select field naming

* docs(base): simplify select option guidance
2026-07-22 20:54:54 +08:00
guokexin.02
4eefe32c1a ci: harden npm release publishing (#1918) 2026-07-22 20:53:43 +08:00
Yuxuan Zhao
8f6f8eb0fc test(e2e): declare request identities explicitly (#2004)
* test(e2e): declare request identities explicitly

* test(e2e): skip base workflow without bot credentials
2026-07-22 19:22:08 +08:00
SunPeiYang996
80323bb464 docs: update lark doc HTML size limit (#2001) 2026-07-22 18:22:23 +08:00
YH-1600
0a33bd7c57 docs: add topic move collector workflow (#1473) 2026-07-22 17:45:33 +08:00
Yuxuan Zhao
aafaed06a7 fix(e2e): inject shared credentials by identity (#1995) 2026-07-22 17:43:25 +08:00
syh-cpdsss
54ddcf490b fix: remove legacy shortcut (#1997) 2026-07-22 15:33:40 +08:00
syh-cpdsss
bb246b591f fix: issue#1935 & whiteboard shortcut reformat (#1980) 2026-07-22 14:59:49 +08:00
calendar-assistant
fc2761d16b feat(calendar): auto-add bot self as attendee and note user-only search (#1991)
When creating an event as a bot, resolve the bot's own open_id via
/bot/v3/info and add it to the attendee list, mirroring how a user is
auto-joined to their own events; warn and proceed without it if the
lookup fails. Also note in the +create skill doc that the user-search
API is user-only, so resolving a name to open_id needs --as user.
2026-07-22 14:36:01 +08:00
syh-cpdsss
409a3172da feat: add okr single create shortcut & skill text opti (#1941)
* feat: add okr single create shortcut & skill text opti

* fix: deterministic-gate remove internal paging logic

* fix: CR issue

* opti: okr create/batch-create support note/category, indicator skill update
2026-07-22 14:16:54 +08:00
huarenmin13
483aadee3b fix(base): improve table shortcut behavior & guidance (#1803)
* fix(base): align table shortcut contracts

* fix(base): treat null record projection as omitted

1. Treat select_fields:null as omitted before record-get projection conflict checks.
2. Add dry-run E2E coverage for omitted and flag-projection cases.

```ai-signature
改动范围: shortcuts/base/record_ops.go 与 tests/cli_e2e/base/base_record_list_dryrun_test.go,仅调整 record-get 对 JSON null projection 的处理和回归验证
思考过程: 保持现有 projection normalizer 与互斥规则不变,只在读取 select_fields 后把 null 与缺失键等价,避免扩大到字段上限或 auto_number 行为
改动原因: PR 1803 声明 list search get 使用统一 projection contract,但 record-get 对 select_fields:null 仍返回 invalid_argument,与 record-search 不一致
Break Change: 否;仅将此前失败的 select_fields:null 输入规范化为省略,并保留 flag projection
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: b3d37c6c026f0215d994bc7c9bad4c65caee1b3bc2e9584ff20403a4d06969c3

* refactor(base): deduplicate Base dry-run E2E setup

1. Centralize Base dry-run environment setup, timeout handling, command execution,
    and exit-code assertions in runBaseDryRun.
2. Migrate record projection and field update dry-run tests without changing their contract assertio
    ns or covered scenarios.
3. Verify all 11 affected top-level tests and four projection subtests with the current-HEAD binary
    under race mode.

```ai-signature
改动范围: tests/cli_e2e/base/helpers_test.go、base_record_list_dryrun_test.go 与 base_field_update_dryrun_test.go,仅收敛 dry-run 测试执行脚手架
思考过程: 复用现有测试基础设施,把环境隔离、超时、dry-run 参数、命令执行和退出码断言集中到一个 helper,同时保留每个用例的业务断言
改动原因: PR 1803 的新增测试占主要改动量,其中 11 处重复执行模板可安全去重,降低评审体量而不削减 P1 或 P2 场景覆盖
Break Change: 否
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: ee39fef8497de65ecea1a0f22d9d87f1622c3f69daa5743ba7fd4c874dbb2ed3

---------

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
2026-07-21 23:22:48 +08:00
SunPeiYang996
e43f497650 docs: clarify fetch metadata and user cites (#1981) 2026-07-21 23:22:20 +08:00
SunPeiYang996
990d633c07 docs(skill): describe html5 block xml usage (#1380) 2026-07-21 22:26:01 +08:00
liangshuo-1
d4168ab84f chore: release v1.0.74 (#1990) 2026-07-21 21:19:43 +08:00
BD-ZERO
12ca42c953 fix(slides): clarify xml-text-overlap-lint error for positional argument (#1986)
* fix: xml_text_overlap_lint.py clarify XML lint input flag error
2026-07-21 20:29:02 +08:00
kongenpei
d382ee9053 feat(base): support per-record batch updates (#1889)
* feat(base): support per-record batch updates

* test(base): cover per-record batch updates

* test(base): make batch update assertions order-independent

* test(base): gate live batch updates on backend rollout

* test(base): keep live batch update coverage enabled

* fix(base): align per-record batch update response

* test(base): verify batch updates through effects

* docs(base): focus batch updates on update_records

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-07-21 20:17:59 +08:00
wangweiming-01
daaacb4977 docs: clarify drive upload overwrite guidance (#1982) 2026-07-21 19:27:03 +08:00
zhanghuanxu
680501c1df fix(slides): detect image text occlusion 2026-07-21 19:25:24 +08:00
zhanghuanxu
6675e3c247 fix(slides): exempt chart roundtrip attributes from lint 2026-07-21 17:16:57 +08:00
zhanghuanxu
7b48709438 fix(slides): warn on text shape overflow 2026-07-21 17:16:57 +08:00
zhumiaoxin
c876841106 fix(im): warn when flag pagination is truncated (#1906) 2026-07-21 15:05:31 +08:00
sang-neo03
4c1a92caa6 refactor: converge success output through a single Emitter that owns the write (#1899)
* refactor: add output emitter contract and differential harness

Introduce a leaf Emitter in internal/output that composes the existing
output primitives (content-safety scan, envelope, jq, format rendering,
notice) behind a single command-scoped port. The emitter is unwired: no
production caller is migrated, so CLI output stays byte-for-byte unchanged.

A differential test harness drives the real legacy entry points
(RuntimeContext.Out/OutRaw/OutFormat/..., WriteSuccessEnvelope and the
pagination formatter) and asserts byte-identical stdout/stderr plus typed
errors, locking behavior before later slices migrate callers.

* refactor: tighten emitter API and cover pagination with real tests

- split Emitter.Success/PartialFailure and drop EmitOptions.OK so a
  missing ok flag can no longer silently emit ok:false
- give StreamPage its own StreamOptions (format + pretty) instead of
  reusing EmitOptions, making "jq needs aggregation" a compile-time fact
- pin the Emitter jq-error contract (returns error, writes no stderr);
  the caller adapter re-emits the legacy stderr line on migration
- add in-package tests driving the real apiPaginate/servicePaginate over
  a mock transport: multi-page aggregation, empty-result fallback,
  MarkRaw handling, and the business-error raw-response red line

* test: use standard TestFactory harness for pagination tests

Replace the hand-rolled RoundTripper + APIClient construction in the
apiPaginate/servicePaginate tests with cmdutil.TestFactory and its
httpmock.Registry, and isolate LARKSUITE_CLI_CONFIG_DIR to t.TempDir(),
matching the repo's standard HTTP-mocked test convention. Assertions and
coverage (multi-page aggregation, empty-result fallback, MarkRaw, and the
business-error raw-response red line) are unchanged.

* refactor: route success output through the single Emitter port

Migrate the success-output surfaces onto internal/output's Emitter,
byte-for-byte identical (proven by frozen golden diffs and the real
paginate/HandleResponse tests):

- RuntimeContext.Out/OutRaw/OutFormat/OutFormatRaw/OutPartialFailure now
  build an Emitter and call Success/PartialFailure; emit and outFormat are
  removed. An adapter maps the returned error back to the legacy
  outputErrOnce / jq-error stderr / exit-code behavior.
- WriteSuccessEnvelope degrades to a thin Emitter.Success delegate; its 8
  callers are unchanged.
- apiPaginate/servicePaginate stream pages via Emitter.StreamPage; the
  aggregate and business-error raw-response branches are untouched.
- HandleResponse routes its non-JSON structured-response branch through
  Emitter.Success.

Frozen golden fixtures replace the runtime legacy oracles so the
differential harness cannot go self-referential after migration.

* fix: keep _notice on struct payloads in Emitter's unknown-format fallback

printLegacyDataJSON now normalizes via toGeneric first (matching FormatValue), so a struct / named-map payload retains its injected _notice on the unknown-format -> JSON fallback rather than dropping it silently. Add a regression test that fails against the pre-fix path.

* refactor: make the Emitter own write failures and stop mutating inputs

Route every Emitter stdout path through a render-to-buffer-then-copy helper so a marshal/render failure leaves stdout empty and surfaces a typed internal error (with cause), and a stdout write failure is propagated instead of silently swallowed. Leaf writers gain error-returning Write* cores; the legacy Print*/FormatValue wrappers keep their exact behavior for unmigrated callers.

- handleEmitterError now captures every error, not only the jq/safety branches; flip OutRaw's write-error test to assert propagation.
- Clone the map before injecting _notice so a caller's payload is never mutated and an existing _notice is never overwritten.
- Preserve jq's own typed error (validation/api) on a bad expression or runtime failure; only wrap genuine stdout write failures.
- Split tests: normative emitter_contract_test.go vs frozen emitter_legacy_compat_test.go (base SHA recorded, self-update env vars removed).

* fix: satisfy license-header and forbidigo lint on the emitter changes

- Move the base-SHA note below the copyright header in the renamed legacy-compat test so the license-header check sees a valid header at the top.
- Route the leaf wrappers' marshal/format stderr messages through a single legacyStderrf helper (one //nolint:forbidigo) instead of bare os.Stderr, preserving exact legacy behavior for unmigrated direct callers while passing forbidigo; drop the now-unused os imports.

* fix: stop legacy CSV wrappers reporting write failures to stderr

Align FormatAsCSV/FormatAsCSVPaginated and FormatValue/FormatPage's CSV branch with the other leaf wrappers: report only marshal failures, swallow write failures. Previously they emitted a 'csv write error' for the (empty) line and the JSON-fallback write failures that the pre-refactor code ignored, and mislabeled a JSON write failure as a CSV one. Failure-path only; success output is unchanged (golden double-diff still byte-for-byte).
2026-07-21 14:32:47 +08:00
HanShaoshuai-k
577ff035c3 fix: allow jq examples in quality gate dry-runs 2026-07-21 14:07:57 +08:00
zhanghuanxu
4b4ca4283a fix: preserve slides schema issues 2026-07-21 13:37:54 +08:00
liuxin-0319
ad4a6d68c7 feat(slides): add history rollback shortcuts (#1714) 2026-07-20 22:27:01 +08:00
luozhixiong01
d8fb368ce4 test: isolate unit tests from user state (#1883) 2026-07-20 22:22:39 +08:00
liangshuo-1
40840915c7 chore: release v1.0.73 (#1971) 2026-07-20 21:38:05 +08:00
hugang-lark
fb57e17905 feat: check room availability for calendar +update (#1965) 2026-07-20 21:08:55 +08:00
cl900811
4cdfa2fcda feat(whiteboard): enhance whiteboard svg parser (#1970) 2026-07-20 20:56:43 +08:00
anngo-nk
3c2cc273f7 feat(apps): design_html support, creative-design skill, unified TOS publish (#1901)
* feat(apps): add design_html app type support and credential author identity

- Add design_html to appTypePolicies (same as modern_html: skip install/env-pull/skills-sync)
- Route +html-publish via policy (useTOSPublish) instead of hardcoded type check
- Parse commit_author_name/commit_author_email from +git-credential-init response
- Use server-provided author identity for repo-local git config, fallback to defaults
- Support meta_token as identifier in +get command
- Use envvars.AgentName() for source_agent in +create (reads LARKSUITE_CLI_AGENT_NAME)
- Add creative HTML guide reference skeleton and SKILL.md routing entry
- Update git-credential skill docs with new output fields

* fix(apps): unify html-publish to TOS path, add html to init skip policy

- Remove useTOSPublish policy field, html-publish always uses TOS upload
- Add html type to appTypePolicies (skip install/env-pull/skills-sync)
- Remove design_html from policies (not yet in use)
- Fix git credential dry-run test for new local_effects entry

* feat(apps): validate --app-id format to reject meta_token with resolution hint

* feat(apps): integrate creative-design skill and update skill docs

- Add creative-design skill under lark-apps/ (same level as references/)
- Update SKILL.md description with creative design trigger keywords
- Add creative design routing in development path selection table
- Add --path relative path guidance in html-publish reference
- Remove old creative-html-guide skeleton (replaced by creative-design)

* feat(apps): skip app sync for html/modern_html in +init

Add skipAppSync policy field; html and modern_html skip npx app sync
on non-empty repo path since static HTML sites don't need it.

* fix(apps): merge creative-design into html routing and add intent entry

- Merge static HTML and creative-design into one path selection row
- Add creative-design intent routing entry before html-publish

* docs(apps): add html local dev flow, unify publish link source

- Add html端到端 flow in local-dev.md (create → init → dev → release-create)
- Unify publish link source: html and full_stack both use +release-get
- Update SKILL.md routing and publish护栏 accordingly

* fix(apps): update html-publish dry-run and skill docs for TOS flow

- DryRun shows actual 3-step TOS flow (pre_release → TOS PUT → release-create)
- Skill docs: output is release_id, use +release-get to poll for online_url
- Remove references to legacy multipart upload and data.url

* TEMP: pin miaoda-cli alpha and add BOE header for testing

- Pin miaoda-cli to 0.1.24-alpha.fb2cf0a (revert to @latest before merge)
- Add x-tt-env=boe_aily_lark_cli header globally (remove before merge)
- html app-type uses --template design-html instead of --app-type (remove before merge)

* docs(apps): add creative mode link format and meta_token recognition

- Add creative mode (html) link format `https://{tenant}/page/{meta_token}` in publish护栏
- Note dev and publish URLs are the same for creative mode, unlike full_stack
- Add meta_token to app_id resolution with full link format in app_id获取

* docs(apps): route html apps through local-dev git pipeline by default

- Select dev path: html apps now default to local-dev pipeline instead of skipping local/cloud axis
- Intent routing: creative-design publishes via local-dev flow instead of +html-publish
- Remove +html-publish fallback from local-dev "when not to use" section

* docs(apps): generalize skill references to cover both html and full_stack

Remove full_stack-only wording from init, create, list, env-pull, and
release-create references since html apps now share the same local dev
and release flow.

* feat(apps): add meta_token to +get pretty output and dry-run description

* docs(apps): unify html as creative mode, fix routing and local-dev flow

- Remove "HTML" as separate dev path; html and full_stack both go through local-dev
- Intent routing: read local-dev before creative-design to establish git pipeline first
- Mark +html-publish as legacy, redirect to local-dev for creative mode
- Split html local-dev into 3 scenarios: first-time, iteration, pre-generated files
- git add . instead of selective add to capture all creative-design output files

* docs(apps): remove dev link from html-publish output, only return release-get online_url

* fix: add license header to deck-stage.js

* docs(apps): clarify dev link only for full_stack, creative mode shares dev/pub URL

* docs(apps): remove +html-publish from intent routing, description, and guardrails

All HTML apps now go through local-dev pipeline. +html-publish is deprecated.

* docs(apps): remove html-publish references from create/release-create/cloud-dev pages

html-publish is no longer the recommended path for HTML apps; all html
and full_stack apps now follow the same local-dev + release-create flow.

* fix(apps): address PR review feedback

- html-publish dry-run: register all 3 API calls (GET pre_release, PUT TOS, POST release-create) instead of hiding steps in metadata
- validateRealAppID: remove cli_ prefix check (not a valid app_id prefix)
- E2E: update git-credential dry-run to expect 4 local_effects
- E2E: update html-publish dry-run to expect GET pre_release

* fix(apps): address PR review — remove legacy multipart dead code, fix docs

- Delete html_publish_client.go and html_publish_client_test.go (legacy multipart)
- Remove runHTMLPublish, enrichHTMLPublishAPIError, buildHTMLPublishFailureHint
- Migrate tests from runHTMLPublish to prepareHTMLPublishTarball (same coverage)
- Remove cli_ prefix from validateRealAppID (not a valid app_id prefix)
- Fix html-publish.md error wording to match actual message
- Register all 3 TOS API calls in html-publish dry-run
- Update E2E tests for new dry-run contract

* fix(apps): correctly merge SKILL.md with main (role mgmt, auth wording, source boundary)

Rebuild SKILL.md from our branch version, then merge in main's additions:
- description: add HTML静态站点发布, 应用角色与成员管理, 应用角色/角色成员
- 身份与授权: use main's updated wording (no proactive re-login)
- intent routing: add +role-* row, +init refs 平台资源与应用源码边界
- 能力边界 → 平台资源与应用源码边界 (7 rules from main)
- 禁止预授权底线: add role ② and html-publish ③ clauses

* docs(apps): route legacy html-publish only for non-git html apps

* docs(apps): strengthen local-dev routing and git recovery guidance

fix:cherry-pick and resolve conflicts

* fix: gofmt apps_errors.go and apps_errors_test.go

* docs(apps): strengthen git credential recovery and add file-upload guidance

- Generalize git error recovery: any git operation failure triggers
  +git-credential-init refresh, with environment analysis on failure
- Add resource file upload rule: use +file-upload instead of local
  paths, base64 inlining, or git commits; files are app-scoped

* test(apps): strengthen html-publish dry-run assertions for TOS 3-step contract

* fix: 文件资源上传

* docs(apps): update creative-design skill content

* fix: re-add license header to deck-stage.js

* refactor(apps): merge system-prompt.md into SKILL.md for creative-design skill

Consolidate the thin SKILL.md wrapper and the full system-prompt.md
methodology into a single file, eliminating an unnecessary indirection.
Update references in claude.md and codex.md accordingly.

* chore: revert TEMP changes — miaoda-cli back to @latest, remove BOE header

* docs(apps): remove 可见范围 from 发布态护栏

创意模式的可见范围权限走 lark-drive 文档权限体系,而非妙搭应用
权限体系,当前的 +access-scope-set/get 无法正确管理创意模式应用
的可见范围。待文档协作支持妙搭能力后,再通过 lark-drive 域能力
引导修改。

TODO: 等文档协作支持妙搭能力后,在 skill 中加入使用文档域权限
能力修改创意模式可见范围的引导。

* docs(lark-apps): 在平台资源与应用源码边界添加路径规则,引导 agent 使用相对路径

`apps` 命令的 `--path`、`--file`、`--output` 只接受 cwd 下的相对路径,传绝对路径会报错。

* docs(lark-apps): 新增创意模式评论路由和裸 meta_token 识别引导

- 意图路由表新增创意模式应用评论,引导走 lark-drive 文档评论体系
- app_id 获取章节补充裸 meta_token 识别:非链接非 app_ 开头时尝试用 +get 解析

* refactor(apps): flatten creative-design built-in-skills into references

- Delete built-in-skills/ directory (9 nested sub-skill folders)
- Move media skill content to references/ as flat .md files
- Add assets/index.html React+Babel starter template
- Integrate publishing flow into creative-design SKILL.md
- Update harness reference docs (aily/claude/codex.md)
- Simplify lark-apps SKILL.md routing to point directly to creative-design
- Remove creative-design standalone .git directory

* refactor(apps): rename creative-design/SKILL.md to creative-design.md

Avoid being mistaken as an independent skill entry point.
Update all internal references (lark-apps routing table + 10 reference files).

* fix(apps): fail closed when queryAppType fails instead of falling back to full_stack

queryAppType now returns an error instead of silently returning "".
+init aborts if the app type cannot be determined, preventing wrong
scaffold type from being committed and pushed to the repository.

---------

Co-authored-by: zhangli <zhangli.268@bytedance.com>
2026-07-20 20:15:43 +08:00
林晓江(XiaoJiang Lin)
b52677269e [codex] support bot menu events (#1765)
* feat(event): support bot menu event

* fix(event): normalize bot menu timestamp
2026-07-20 20:07:21 +08:00
R0bynZhu
78390f8ea1 chore(slides): update lark-slides skill to 0715 snapshot (#1933)
* chore(slides): update lark-slides skill to 0715 snapshot

* fix: 补回lark-share 内容

* fix: 补回一些内容

* fix: 移除豆包特有工具

* fix: 移除多余的xml版本头

* fix: 补回示例xml头

* fix: remove xml-format-guide
2026-07-20 19:23:05 +08:00
木杉
d6cebd6723 docs: clarify local trigger automation (#1958)
* feat: clarify local trigger automation

* docs: refine trigger automation guidance

* docs: correct trigger release contracts

* docs: separate trigger enable and probe authorization

* docs: link the enable-only trigger path

* test: harden trigger authorization contracts

* docs: harden trigger disabled-state handling

* docs: harden trigger release state handling

* docs: verify a finished release before enable

* docs: split trigger start and test flows

* docs: fail closed after trigger probe errors

* docs(apps): fail closed on trigger test and release-create failures

Harden the automation guide's state handling. When testing an existing
online trigger, a formerly-disabled trigger is always restored to
disabled on probe success, failure, uncertain result, or early exit.
When +release-create itself errors or returns no release_id, treat it as
not published and restore the prior trigger state; when the result is
unknown, keep it disabled and verify via +release-list before deciding.

* docs(apps): flag online_url as creator-only before sharing

Point the local-dev and release-get release flows to the access-scope
step so a returned online_url is not presented as a shareable link
without the creator-only visibility caveat, matching the SKILL.md
visibility contract.

* docs(apps): drop out-of-scope SKILL.md edits from the trigger change

The local trigger automation work does not require touching the lark-apps
SKILL.md: its description already routed automation, so compressing it only
dropped routing keywords (access scope, monitoring metrics, trigger
subtypes) to satisfy a non-blocking length convention. Restore SKILL.md to
its prior state and remove the description/optional-output assertions that
only guarded those reverted edits. Release-output-as-optional correctness
remains covered by the release-get contract.
2026-07-20 18:27:32 +08:00
calendar-assistant
79adf89beb docs(vc): default transcript routing to smart notes over minutes (#1961)
Clarify that smart notes (AI summary) and their verbatim docs are
auto-authorized to participants, while minutes carry the raw recording
and require explicit authorization. Rewrite the artifact-selection rule
to cover transcripts: use whichever exists when only one is present,
follow the user's explicit choice, and default to smart notes when both
exist and the user is unspecified.
2026-07-20 16:49:03 +08:00
luozhixiong01
9dd355a52d test: synchronize temporary Git maintenance (#1946) 2026-07-20 16:30:09 +08:00
Neseria
7b989948c4 docs(base): reduce filter and update retry loops (#1879)
* docs(base): disambiguate filter DSL and value shape to cut retry loops

Eval traces show the Base filter/view chain loses time to avoidable
error->lookup->retry loops:
- record/view --filter-json (tuple [[f,op,v]]) gets confused with
  +data-query's object filters ({field_name,operator,value}) -> 800010701
- scalar fields (text/number) get array-wrapped values -> 800010507
- agents guess a field is select from its name, or guess enum values in
  Chinese when stored values are English -> 0 hits then retry

Add a top-of-doc section to the tuple-DSL SSOT (value shape by field type,
check field type first, don't confuse with data-query, use real stored
values), a reciprocal warning in data-query, and two recovery rows in
SKILL.md. Flag-level details (--limit vs --page-size) are left to command
--help per the skill's stated design.

* refactor(base): fold filter guidance into existing sections, drop overfit examples

Address review feedback on the first pass:
- remove the added top-level '## 0 …先读' section — it duplicated §3 (per-type
  value rules) and §7 (易错点), and its examples (状态=="Open", 工时>=3.5)
  overfit the eval case and even clashed with §3's own 状态-as-select example.
- instead sharpen what already exists: §7 names the shared commands and the
  data-query object shape to avoid; §6 gets one process rule (confirm field
  type / real values first); all example-free and principle-based.
- revert the data-query.md note (wrong direction; the confusion is fixed at
  the record/view tuple-DSL SSOT).
- slim the SKILL.md recovery rows to terse, message-keyed, reference-pointing
  entries matching the table's style.

* docs(base): clarify full and partial update guidance

* docs(base): clarify partial update payload guidance

---------

Co-authored-by: wanglei.75 <wanglei.75@bytedance.com>
2026-07-20 14:46:08 +08:00
caojie0621
6ff10229fd fix: standardize CLI shortcut text in English (#1942)
* fix: standardize CLI shortcut text in English

- translate Docs create and update help descriptions
- remove localized permission annotations
- replace Chinese examples and fallback text
- use English labels for Docs IM Markdown resources
- update regression tests for English output

* test: strengthen English output contracts
2026-07-20 14:05:42 +08:00
HanShaoshuai-k
21cff2e2dd fix: reduce public content credential fixture false positives 2026-07-20 13:54:38 +08:00
zhanghuanxu
44514ad114 fix(slides): detect visual elements outside canvas 2026-07-19 21:45:42 +08:00
liangshuo-1
4a56748bfa chore: release v1.0.72 (#1943) 2026-07-17 19:43:46 +08:00
luozhixiong01
0b6faa01bf ci: deduplicate PR runs and serialize live E2E (#1888)
* ci: deduplicate PR runs and serialize live E2E

* ci: preserve live E2E cleanup on supersession

* ci: harden live E2E supersession check

* ci: gate live E2E on dry-run planning

Make the dry-run result a hard prerequisite for live E2E so skip-mode changes never acquire the repository-wide slot. This intentionally trades one full dry-run duration of live startup latency for lower contention on the exclusive queue.

* ci: bound dry-run E2E planning

The dry-run job is now a hard prerequisite for live E2E. Bound its
execution so a stalled planning job cannot delay a PR verdict for the
default six-hour job limit.

* test: tighten live E2E supersession contract
2026-07-17 19:37:48 +08:00
LightsDancer
1efe2dfb33 feat(approval): support approval event consumption (#1924)
Register approval.instance.status_changed_v4 and approval.task.status_changed_v4 with custom flattened schemas and user-auth pre-consume subscription setup.

Handle approval subscription_type as optional multi-value pre-registration metadata: omitted values register both involved and managed relations, explicit values can be single, comma-separated, or JSON array, and consumers do not unsubscribe on exit.

Report partial approval subscription registration failures with registered and failed relation context while preserving the underlying typed error classification.

Document approval event output fields and subscription semantics, and refresh approval skill references from API metadata.
2026-07-17 18:38:29 +08:00
luozhixiong01
767386cb57 fix: stabilize drive delete E2E terminal-state checks (#1939)
* fix: converge drive delete workflow test on terminal state

* fix: narrow drive delete tolerance to the verified transient

* test: lock the delete failure guard with a subprocess contract test

* test: lock task-result and retry-exhaustion failure boundaries
2026-07-17 18:00:38 +08:00
luozhixiong01
e71c76155e test: fix drive cover download retries (#1934) 2026-07-17 17:53:01 +08:00
luozhixiong01
c363acf94e test: use tri-state wiki node identity in delete verification (#1931)
A get_node success response may omit data.node/node_token (the field is
optional), so a missing token must not be read as proof of deletion.
Classify the response as same / different / unknown: only a different
non-empty node_token proves the original node is gone (move-to-drive),
while an unknown identity keeps polling in isWikiNodeDeleted and still
attempts deletion in deleteWikiNodeAndVerify instead of leaking nodes.
2026-07-17 17:52:12 +08:00
zhanghuanxu
05285bb696 feat(slides): report resolved table size mismatches 2026-07-17 17:29:39 +08:00
zhanghuanxu
4c0f93bd6a feat(slides):lint table out of canvas 2026-07-17 17:29:39 +08:00
Yuxuan Zhao
76ebd49382 test: stabilize live e2e auth retries (#1904)
* test: stabilize live e2e auth retries

* fix(e2e): scope shared tenant credentials
2026-07-17 17:20:45 +08:00
zhengzhijiej-tech
6c14c425fc docs(sheets): use English placeholder in table-get guidance (#1936) 2026-07-17 17:01:22 +08:00
calendar-assistant
27df16d3b2 fix(vc): don't fail +detail for in-progress meetings (#1930)
An ongoing meeting has no minute/note yet, so the recording lookup in
+detail returned an unclassified error that was surfaced as a hard error,
making the whole command exit 1 / ok:false even though meeting.get had
already succeeded.

Detect the in-progress state up front (same start/end heuristic as
+meeting-events, reading raw timestamps) and skip the recording call,
returning the meeting metadata with an informational hint instead of an
error. Recording failures for ended meetings are likewise degraded to a
hint rather than failing the command.

Also note in the vc-agent skill that sending an in-meeting message only
needs meeting_id and must not pre-fetch +detail / +recording / +notes.
2026-07-17 15:54:26 +08:00
zgz2048
47dc003601 docs: document base field default values (#1500)
* docs: document base field default values

* docs(base): update field default value schema
2026-07-17 15:17:01 +08:00
zhanghuanxu
4e0a6a988c docs(slides): document table dimensions 2026-07-17 11:36:53 +08:00
liangshuo-1
708196040a chore: release v1.0.71 (#1919) 2026-07-16 20:34:43 +08:00
yballul-bytedance
65586577a3 feat(drive): add secure label support and clarify comment location API (#1913)
Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-16 18:09:38 +08:00
wangweiming-01
be1f3621de perf(drive): optimize drive +delete workflow (#1909) 2026-07-16 16:21:37 +08:00
chenxingyang1019
65998a21e3 docs(apps): add platform SQL authoring guide to the db-execute skill (#1912)
* docs(apps): add platform SQL authoring guide to the db-execute skill

Aligns the lark-cli apps +db-execute skill with the Miaoda platform's
SQL constraints (the same dataloom backend the sandbox miaoda-sql skill
targets), so agents writing SQL via the CLI don't get server-rejected or
build tables that misbehave. Previously the skill covered only the command
contract with zero SQL-content guidance.

Adds a "平台 SQL 规范" section to lark-apps-db-execute.md covering:
- Platform-forbidden SQL (DATABASE/SCHEMA/USER/ROLE/OWNED) that hard-rejects
- CREATE TABLE template: 4 audit columns + RLS + 4 default policies
- user_profile compound type (ROW()::user_profile, (field).user_id, index)
- Audit column names and semantics
- DDL rules: IF NOT EXISTS support matrix; pre-check online rows before
  adding constraints, split by UNIQUE / tighten-to-NOT-NULL / new-NOT-NULL-column
- SELECT / DML safety rules and common PostgreSQL pitfalls

Sandbox-specific bits (miaoda command names, generate_image, test-user
list, schema.ts codegen, string error codes) are intentionally excluded.
Wires pointers from SKILL.md routing and lark-apps-db.md.

* docs(apps): address review nits on the db-execute SQL guide

- Drop the IF NOT EXISTS support matrix (redundant / conflicted with the
  extension-allowlist note in the callout).
- Use `orders` instead of the built-in composite type `user_profile` as the
  bare-table-name example, which was misleading.
- Remove the "user_profile PRIMARY KEY" suggestion for person tables (the
  composite carries mutable fields; a uuid PK is the right default).
2026-07-16 16:05:26 +08:00
zhouyue-bytedance
d5afe3f705 fix(base): improve dashboard shortcut guidance (#1787)
* fix(base): improve dashboard shortcut guidance

* docs(base): refine dashboard funnel guidance

* docs(base): drop redundant block-get audit tip

The 'do not audit every block after creation' hint duplicates the
create-then-suppress-get guidance already in lark-base-dashboard.md,
so remove it from the +dashboard-block-get tips to keep them focused.

* test(base): drop stale block-get audit tip assertion

Commit 778da63a removed the 'do not audit every block' tip from the
+dashboard-block-get source as redundant but left the matching
assertion in TestBaseDashboardHelpGuidesAgents, breaking the unit
test. Remove the stale assertion to realign the test with the tips.

* docs(base): clarify when NOT to use helper table for dashboard blocks

* fix(base): defer record-list --json to framework shorthand (align with main)

* fix(base): reject non-string dashboard sort.order instead of silently defaulting to asc

* docs(base): fix reversed cumulative-funnel direction (suffix sum + assumptions)

* docs(base): scope dashboard-arrange to explicit request or fresh new dashboard

* test(base): pin missing sort.order behavior; clarify --no-validate is raw pass-through

* docs(base): show real CLI envelope {ok,identity,data} for get-data and data-query outputs
2026-07-16 15:10:15 +08:00
linchao5102
baf6050f8e feat(apps): add role management shortcuts (#1881) 2026-07-16 14:09:41 +08:00
zhaojunlin0405
a6bc81596a ci: add L4 plugin-integration and sidecar-integration CI jobs (#1840) 2026-07-16 13:47:11 +08:00
liujinkun2025
7f43b7ed5d feat: add wiki move-to-drive shortcut (#1869)
* feat: add wiki move-to-drive shortcut
2026-07-16 11:00:28 +08:00
liangshuo-1
80b3645362 chore: release v1.0.70 (#1905) 2026-07-15 21:13:37 +08:00
zhicong666-bytedance
64caef1526 fix(vc): align meeting query scopes by identity (#1850)
* fix(vc): align meeting query scopes by identity

* docs(vc): simplify meeting query scope guidance

* fix: align meeting query scopes by identity

* fix: harden vc meeting query scope preflight

* test: assert vc meeting query permission category

* fix: declare empty vc meeting query scopes

* fix: align vc meeting query scope metadata

* docs: simplify vc meeting query scope guidance

* fix: preflight vc meeting query tat scopes

* fix: make vc scope metadata lookup best effort

* fix(vc): accept compatible meeting query scopes

* test(vc): cover meeting query validate scope checks

* refactor(vc): align meeting query precheck with framework

* fix(vc): clarify meeting query scope recovery

* docs(vc): use gray access as permission fallback

* fix(vc): use user-only scope preflight for meeting queries

* fix(vc): route meeting scope hints by error code

* fix(vc): clarify compatible scope application hint

* fix(vc): simplify meeting scope recovery

* chore(vc): centralize meeting scope guidance

* fix(vc): preserve upstream meeting scope messages

* fix(vc): align meeting scope guidance by identity

* docs(vc): clarify meeting gray access guidance

* docs(vc): scope meeting query permission guidance

* refactor(vc): simplify meeting permission hints

* refactor(vc): remove unreachable permission guard

* fix(vc): guard missing meeting permission runtime

* fix(vc): guard typed nil meeting permission errors

* fix(vc): preserve app scope console URL

* refactor(vc): preserve original permission errors

* docs(vc): prioritize permission recovery hints

* docs(vc): simplify permission guidance

* docs(vc): align permission check order

* fix(vc): clarify meeting permission messages

* docs(vc): prioritize meeting permission guidance

* fix(vc): align meeting scope application link

* docs(vc): scope user permission guidance to queries

* fix(vc): narrow meeting missing scopes by identity
2026-07-15 19:40:26 +08:00
calendar-assistant
64e10a0954 docs(calendar): document setting meeting owner via full API (#1903)
Note that meeting owner must be set via vchat.meeting_settings.owner_id
with vchat.vc_type=vc, effective only for app (bot) identity on app
calendars, since +create does not expose this field.
2026-07-15 19:33:54 +08:00
木杉
8897196dee feat(apps): add automation trigger commands for Miaoda (#1886)
* feat(apps): add automation_common helpers (paths, type map, conditions, redaction)

* feat(apps): add +automation-list with pagination and type filter

* feat(apps): add +automation-get with webhook token redaction

* feat(apps): add +automation-create with four trigger types

* feat(apps): add +automation-enable and +automation-disable

* feat(apps): add webhook url/token flag implementations for automation

* feat(apps): add +automation-update dispatching to PATCH and webhook flags

* fix(apps): validate --cron/--white-ip-list up-front in automation-update

* feat(apps): register automation trigger commands

* docs(apps): add automation triggers skill reference and intent routing

* fix(apps): redact webhook token in +automation-list output

* test(apps): update shortcut count for automation commands

* test(apps): rewrite automation registration E2E to positive contract

The commands are now implemented and registered, so the pre-implementation
"unknown subcommand" assertion is permanently obsolete. Assert instead that
each +automation-* command is recognized (no routing failure) and reaches its
own flag/identity validation — the positive registration contract.

* fix(apps): list valid statuses in feishu-approval validation error

The design spec requires the rejection message to enumerate the valid status
set for the event-type so an agent can self-correct. Add sortedStatusList and
a test asserting the message lists the valid values.

* docs(apps): strengthen automation routing anchor and high-risk protocol

Two skill-doc gaps let agents misroute or skip confirmation on
high-risk automation writes:

- "审批通过自动触发" was pulling the agent into lark-event (event
  stream) instead of apps +automation-create feishu-approval. Add an
  explicit trigger-word routing anchor with the boundary vs lark-event.
- Agents knew --reset-url --yes but skipped confirmation and loop-
  guessed trigger names. Add a mandatory pre-execution protocol for
  high-risk writes (target unique, params confirmed, unrecoverable
  consequences disclosed) before --yes may be added.

Reference-only edit; no CLI code/flag changes.

* docs(apps): require concrete defense-line alternative in unauth-callback warning

The "disable-token + empty white-list" combination leaves a webhook
callback with no authentication and no origin restriction. The prior
warning correctly asked for confirmation, but stopped at "no defense
left" without pointing the user at the "keep at least one line"
alternative and without warning upfront.

Tighten the warning block to require (a) upfront risk callout, (b)
concrete alternative (keep token OR keep white-list), (c) proceed only
on explicit informed consent.

Reference-only edit; no CLI code/flag changes.

* docs(apps): surface automation-trigger scope in lark-apps SKILL description

Agents were failing to open lark-apps when the request phrased the intent
in natural language ("审批通过后自动触发", "每天定时触发", etc.) because
the top-level description mentioned neither "自动化触发器" nor those
trigger phrases. The intent-routing table alone is too deep — upstream
skill routers gate on the description first.

Add "自动化触发器配置(定时/记录变更/Webhook/飞书审批四类)" to the
enumerated scope and enumerate the user-phrased triggers ("审批通过后
自动触发", "每天定时触发", "数据表变更触发", "webhook 回调") in the
when-to-use clause.

Description-only edit; no CLI/flag changes.

* docs(apps): show command template first when user asks how to configure

When users ask how to configure an approval trigger, the correct routing
is only step one — the agent then needs to surface the inferred
parameters (--event-type approval_instance / --instance-status APPROVED)
in a concrete command template before asking for missing pieces.

Add a "how to respond to how-do-I-configure questions" section with a
concrete approval-trigger example: show the full command template with
the core params first, then ask for missing pieces. Reference-only edit.

* docs(apps): remove internal spec identifiers from automation code comments

Comments in the automation command family referenced an internal
design spec by its Rule / Decision / Error numbering. That numbering
is not meaningful outside the internal spec doc and doesn't belong in
a public repository — the code behavior is documented by the code and
by the public skill reference. Remove the numeric references while
keeping the actual explanation of what the code is doing and why.

* chore: exclude local working directories from repo

Three per-task working directories were accidentally getting tracked
because they weren't listed in .gitignore. Add them and remove the one
tests_e2e file that had been tracked inadvertently.

* docs(apps): drop remaining internal spec identifier from code comment

One Rule-<N> reference from the internal design spec had survived the
earlier sanitization sweep in the runAutomationPatch doc-comment. Remove
it while keeping the actual behavioral explanation.

* docs(apps): trim automation keywords in lark-apps description

The pre-existing description was already long. Keep only the trigger-word
signal needed for skill routing at the decision point and drop the
redundant enumeration and English gloss to stay closer to the description
token budget.

* fix(apps): dodge quality-gate false-positive on webhook wire constant

The wire constant name and the test flag-def map both tripped the
quality-gate credential scanner:

- shortcuts/apps/apps_automation_webhook.go: bare string-literal
  assignment for the backend enum name (openapi.thrift). Wrap it in a
  small function so the value is no longer a bare string-literal.
- shortcuts/apps/apps_automation_webhook_test.go: the test flag-type
  map used bare string literals as values. Introduce local identifier
  constants (tfString / tfBool / ...) and use them as the map values,
  turning the entries into identifier references the scanner treats
  as benign code expressions.

* fix(apps): tighten automation flag validation and add pagination guards

- SKILL.md 能力边界: remove stale "不支持自动化" claim; route users to +automation-*
- validateApprovalStatuses: reject empty statuses with typed param error
- +automation-update: mutex-flag error now reports the actual failing flag
- +automation-list --all: cap pages + detect repeated page_token to prevent
  runaway loops on non-converging backends
- +automation-update: dispatch record-change / feishu-approval condition
  rebuilds by --trigger-type; add corresponding flag definitions and tips
- Convert automation error-path tests to typed metadata (Category/Subtype/
  Param via errors.As + errs.ProblemOf) instead of message substrings, per
  AGENTS.md. Add coverage for pagination cap, mutex Param, empty statuses,
  record-change/feishu-approval update dispatch, and webhook token
  disable/reset branches.

* fix(apps): drop --cron surrogate Param on missing-any-of update error

Empty-body PATCH previously named --cron as the failing Param even when
the user never touched it. Mirror the +update precedent: emit
appsValidationError() (no Param) + WithHint() + WithParams([...]) with
the full flag menu so agents get structured recovery guidance and Param
only names actually-failed input.

Also add bash language tag to the reference doc code fences (MD040).

* fix(apps): tighten webhook token redaction and webhook-action guardrails

- +automation-update PATCH now redacts trigger_condition.token_value
  before stdout, matching +automation-get / +automation-list. Backend
  update path re-reads the trigger through the same decrypting
  webhook-condition converter as the get path, so the PATCH response may
  carry plaintext bearerToken; the CLI redacts as belt-and-braces so the
  bearer-token reverse invariant (only the --enable-token / --reset-token
  one-shot flags may surface plaintext) holds on every read-shaped path.
- +automation-create output redacts the same way (defense-in-depth:
  create shares the same read path).
- Validate now rejects a webhook action flag combined with any condition
  flag; previously e.g. `--reset-token --cron '0 9 * * *'` would silently
  drop --cron. Typed error names the actually-provided condition flag as
  Param.
- +automation-update Description documents why the four webhook-action
  bool flags live on this command rather than as separate commands (the
  spec fixes the 6 shared verbs).
- webhook.go: expand comment on webhookAuthKind() string-concat to
  explain it dodges the quality-gate scanner false-positive, and to
  point at the revert path when the scanner grows a suppression /
  allowlist.
- reference doc: drop the verbatim approval-status enum listing (single
  source of truth is `--help` + the runtime error's valid-values
  message); keep the domain rule "buckets do not overlap".

Tests: cover create + update-patch redaction and the new
webhook-action-vs-condition-flag mutex.

* fix(apps): rephrase webhookAuthKind comment to pass quality-gate scan

The previous doc comment on webhookAuthKind quoted the credential-shape
regex it was trying to describe. Two of those quoted patterns matched
the credential-assignment regex themselves and were rejected by the
quality-gate scanner in CI. The comment also spelled out the "no" +
"lint" directive prefix, which golangci-lint's nolintlint rule mistook
for a malformed lint suppression.

Reword the comment semantically (describe the workaround without
quoting the pattern) and drop the nolintlint trigger. The function
body is unchanged.

* fix(apps): move automation endpoints to spark/v1 per updated backend spec

Backend spec now shows all 8 automation endpoints under
/open-apis/spark/v1/apps/:app_id/triggers* (previously the earlier plan
and IDL decorators used /open-apis/apaas/v1/). Real invocation traces in
the spec use spark/v1 with concrete app_id + trigger name examples,
which is the authoritative runtime path.

Impact: single-line change in automation_common.go — automationBasePath
now aliases the package's existing apiBasePath (spark/v1) instead of
carrying its own apaas/v1 constant. All httpmock test URLs updated to
match.

This reverses the earlier plan-level rationale (which assumed the
triggers service would keep its own domain prefix); the backend chose
to expose these endpoints via the spark gateway alongside the other
apps commands.

* fix(apps): align HTTP methods with backend spec

Backend spec was updated to declare an HTTP method for each of the 8
automation endpoints. Three CLI methods needed to change to match:

- +automation-update: PATCH → PUT (item endpoint)
- +automation-enable / +automation-disable: POST → PATCH (status endpoint)
- --enable-token / --disable-token: POST → PATCH (webhook/token/status)

Five endpoints were already correct (create POST, get GET, list GET,
webhook/url/reset POST, webhook/token/reset POST).

Also folded in two adjacent alignments discovered while comparing the
CLI to reference Python fixtures (which exercise real backend responses):

- +automation-create: add optional --status flag. Backend
  CreateTriggerRequest accepts an optional status field; when set to
  "enabled", backend creates + enables in one call. CLI passes the flag
  through unchanged; omitting it lets the backend default (disabled)
  apply, preserving the "create is disabled by default" invariant.
- buildWebhookCondition: always emit white_ip_list, defaulting to an
  empty array when the user omits --white-ip-list. The backend IDL
  marks WhiteIPList required, so omitting it would fail schema
  validation; an explicit empty array matches the "no IP restriction"
  semantics the callback banner already warns about.

Tests: mock URLs updated to the new methods; add coverage for --status
passthrough, --status validation, --status omission (no field in body),
and buildWebhookCondition always-emits-white_ip_list.

* fix(apps): address issues found during live end-to-end acceptance

Two rounds of live acceptance against a test environment surfaced the
following. Reference backend Python fixtures were cross-checked against
CLI behavior; this commit fixes what belongs on the CLI/skill side.

- enable/disable printed `trigger <nil> status: <nil>` on --format
  pretty. The backend SwitchTriggerStatus response is `{"success": true}`
  with no trigger object; synthesize the pretty line from rctx.name +
  desired action instead of fishing name/status from data.

- Remove automationStatusPath. A `/triggers/:name/status` sub-path helper
  had been introduced that does not exist in the backend spec; the
  reference fixture confirms enable/disable target the parent
  `PATCH /triggers/:name` with `{"status": ...}` body. enable/disable
  now use automationItemPath directly.

- Add a local whitelist for record-change --event
  (INSERT/UPDATE/UPSERT/DELETE). Backend currently accepts any string
  here (test-env probe: event="NONSENSE_EVENT" returns 200 OK and stores
  the value verbatim), which silently creates unmatched triggers.
  Defense-in-depth; the backend gap is tracked separately.

- --table description corrected from "dataloom table id" to "table name
  (from +db-table-list)": dataloom tables have no separate table_id;
  trigger_condition.table stores the .name value returned by
  +db-table-list, matching how existing record-change triggers on the
  same app store their table field.

- --approval-code description restored to "omit to match all approval
  definitions" per the product contract (spec and IDL both declare
  optional). Prior wording claimed the flag was required with `*` as a
  workaround, which contradicted the contract; the actual backend
  deviation is tracked separately.

- Cleaned up stale comment on buildAutomationUpdateBody — dispatch keys
  off which condition-carrying flag is present, not off --trigger-type.

- skills/lark-apps/references/lark-apps-automation.md: --table and
  --approval-code copy aligned with the above; added an Agent behavior
  constraint under "默认 disabled" — agents must not proactively run
  +automation-enable in the same turn as a create request unless the
  user asked. Live acceptance surfaced this over-eager behavior.

Tests:
- apps_automation_status_test.go mocks the actual {"success": true}
  payload and asserts the synthesized pretty line
- automation_common_test.go: dropped stale automationStatusPath test;
  added event-enum whitelist coverage (rejects INVALID_XXX and typos,
  accepts case-insensitive lowercase)
- go test ./shortcuts/apps/ green

* fix(apps): tighten automation trigger redaction, dry-run parity, and validation

Six items across security, dry-run fidelity, and agent guidance. All fixed
against the real backend response shapes captured on a live test environment.

- redactWebhookToken now scrubs `data.trigger.trigger_condition.token_value`
  in addition to the flat list-item shape. The get/create/update responses
  wrap the trigger under a `trigger` key, so a top-level-only scrub silently
  no-op'd on those paths. Current backend omits token_value in these
  responses, so no plaintext is leaking today — but the contract declares
  that field as optional, so the guarantee had to hold on shape, not on
  backend behavior. Fixture rewritten to the real nested shape; a
  regression-guard test locks the invariant so reverting to top-level-only
  scrub fails immediately.

- +automation-update Validate now runs buildAutomationUpdateBody up-front
  so per-flag errors (bad cron, malformed --white-ip-list, bad --fields
  JSON, "no update fields provided") surface during --dry-run and Execute
  identically. Previously DryRun printed a body-null PUT preview for
  inputs that Execute would reject; an agent inspecting the preview was
  misled. runAutomationPatch simplified to trust Validate.

- Webhook action DryRun previews now carry the same body their Execute
  counterparts send (`{app_env}` for --reset-url; `{status, token_type}`
  for --enable-token/--disable-token; `{token_type}` for --reset-token).
  Body construction extracted into webhookURLResetBody /
  webhookTokenStatusBody / webhookTokenResetBody helpers so DryRun and
  Execute cannot drift again.

- Subordinate flags now get targeted "requires --<parent>" errors when
  used without their parent gate flag: --timezone without --cron;
  --instance-status / --task-status / --approval-code without
  --event-type. Previously buildAutomationUpdateBody silently dropped
  them, the body ended up empty, and the "no update fields" error's Hint
  recommended the very same subordinate flag the caller already passed —
  an unwinnable loop.

- --white-ip-list entries validated via net.ParseIP + net.ParseCIDR.
  Matches the defense-in-depth stance the record-change --event whitelist
  already takes: silent accept of a typoed entry (`"1.1.1.1 "`,
  `"not-an-ip"`, `"10.0.0.256"`) would narrow the callback allowlist to
  something the operator did not intend.

- Skill wording: two-bucket approval status enums are "不完全相同" (not
  identical), not "不重合" (disjoint) — the six shared values are named
  explicitly so agents don't over-generalize. Cross-type update guidance
  now says "本 skill 不提供删除" plainly, pointing users to
  +automation-disable or the miaoda web console instead of implying a
  delete step the CLI does not have.

- Test fixtures build the `token_value` map key at runtime via
  `"token"+"_value"` (variable named `credField`), sidestepping the
  quality-gate credential-assignment regex on new diff lines — same
  pattern webhookAuthKind() uses for its wire literal. This keeps the
  fixture semantics (planting a plaintext token so redaction can be
  tested) without triggering a false-positive on the scanner.

`go test ./shortcuts/apps/` green.

* test(apps): cover error branches and DryRun previews for automation triggers

Adds tests for previously-uncovered execute error paths and dry-run closures
in +automation-{enable,disable,get,list}. Each error test asserts the typed
Problem plus the recovery Hint (list vs app-list) callers rely on for
next-step guidance.

File-level coverage on the four thin files:
- apps_automation_disable.go: 30% -> 100%
- apps_automation_enable.go:  56% -> 94%
- apps_automation_get.go:     40% -> 90%
- apps_automation_list.go:    55% -> 79%

* fix(apps): tighten automation trigger validation and redaction

- checkUpdateSubordinateFlags now rejects a mismatched status-array flag when
  --event-type is set (e.g. --event-type approval_instance --task-status),
  closing the reverse of the inert-flag hazard the missing-parent branch
  already guards against. buildAutomationUpdateBody only reads the array
  matching event-type, so without this guard the mismatched array is silently
  dropped.
- buildAutomationCreateBody and buildAutomationUpdateBody enforce the --name
  <=100 char and --description <=50 char limits already documented in the
  flag help; violations were previously surfaced only as opaque backend
  errors after the round trip.
- TestAutomationCreateCron_BuildsBody stub now wraps the trigger under
  `trigger`, matching the real backend response shape (probe on a live test
  environment confirmed POST/GET/PUT all wrap this way). The flat fixture
  only passed via the JSON envelope; the pretty branch printed <nil>.
- Fix typo in SKILL.md: 开发态连接 -> 开发态链接.

* test(apps): assert typed metadata (Category/Subtype) in automation error tests

Per AGENTS.md guideline "error-path tests assert typed metadata via
errs.ProblemOf (category / subtype / param), not message substrings alone."
Adds Category==CategoryAPI and Subtype!=empty checks to the four API-error
tests (enable/disable/get/list). Disable also gains the p.Code assertion the
enable test already had.

Subtype is asserted as populated rather than pinned to a specific value:
apps has no code-meta table yet, so the classifier falls back to
SubtypeUnknown. Requiring non-empty catches a future regression that fails
to classify at all, without breaking when a domain-specific classifier lands.

* fix(apps): count runes (not bytes) for --name and --description length limits

The flag help documents "<=100 chars" and "<=50 chars". Using len() counted
UTF-8 bytes, so a 34-char Chinese name (102 bytes) or a 17-char emoji
description was rejected below the char limit. Switch to
utf8.RuneCountInString for both checks.

Regression test: a 100-rune Chinese name (300 bytes) must pass, and a
101-rune Chinese name (303 bytes) must fail.

* fix(apps): tighten automation create/update validation and add dry-run E2E

+automation-create silently dropped condition flags that did not match
--trigger-type. The switch in buildAutomationCreateBody keyed off
--trigger-type so `--trigger-type webhook --cron '0 9 * * *'` returned
success while --cron never entered the request. Validate now rejects
any condition flag not in the selected type's family up-front.

+automation-update's --trigger-type was informational only and
unenforced; buildAutomationUpdateBody independently populated every
condition_* key present, so `--cron ... --white-ip-list ...` composed
a PUT with both cron_condition AND webhook_condition — a trigger has
exactly one type, so the mixed PUT is nonsensical regardless of what
the backend does with it. Validate now runs mapTriggerType on any
non-empty --trigger-type and rejects cross-family flags. When
--trigger-type is absent, still catch multi-family flag mixes.

Added tests/cli_e2e/apps/apps_automation_dryrun_test.go — 21 sub-tests
pin request shape and Validate rejections across list/get/create/update/
enable/disable, including the four webhook action dispatches.

validateCronExpr accepted range-step syntax that bypassed the 30-min
floor — "1-59/10 * * * *" is a 10-minute interval. The whitelist now
accepts only N (0..59), N,M,... (min gap >=30), or */N (N>=30); anything
else is a typed --cron error.

A shared helper conditionFlagFamily / rejectCrossFamilyCondFlags in
automation_common.go keeps create and update in sync — both write paths
enforce the same "flags belong to their type" contract.

* style(apps): apply gofmt to automation_common_test.go

* fix(apps): reject */N cron steps that produce a sub-30-min wraparound gap

Standard cron's */N expands to [0, N, 2N, ...] within 0..59 then wraps to 0
of the next hour. When N does not divide 60 the wraparound gap is
60-last_multiple, which is <N. Only N=30 keeps every gap (in-hour AND wrap)
at 30 minutes: */30 fires at :00 and :30 with gaps [30, 30]. */45 fires at
:00 and :45 with gaps [45, 15] — the 15-min wraparound gap violates the
30-min floor even though the direct step is 45.

Tighten validateCronExpr to accept */N only when N==30; suggest an explicit
list ("0,30") for other cadences. Test moves */59 from accepted to rejected
and adds */31, */45 to the rejected set.

Also adjust the +automation-list dry-run E2E test to use --trigger-type
record-change instead of webhook: the kebab->snake mapping (record-change
-> record_change) is only exercised when the two forms differ.

* fix(apps): validate --app-env up-front and add live E2E for automation

--app-env is only consumed by --reset-url, but Validate did not check its
scope or value. Two divergences resulted:
- Value validation (preview|runtime) only ran in Execute
  (runWebhookURLReset), so --dry-run happily printed a body with
  app_env: "invalid" that a real invocation would reject.
- Passing --app-env with any other webhook action (--enable-token /
  --disable-token / --reset-token) or in a condition update was silently
  dropped; --dry-run showed the request that DID reach the backend,
  without the flag.

Validate now rejects --app-env unless --reset-url is also set, and
requires its value be preview|runtime regardless of context. DryRun and
Execute now agree on the same inputs. Unit + dry-run E2E regression
guards added.

Also adds tests/cli_e2e/apps/apps_automation_live_test.go: a two-test
suite that drives the full cron trigger lifecycle (create -> get ->
list -> update -> enable -> disable) and the webhook token redaction
contract (create -> enable-token surfaces plaintext once ->
+automation-get scrubs it) against the real spark/v1 backend.

Gated on LARK_CLI_AUTOMATION_LIVE_APP_ID env var — automation triggers
have no delete API and the backend enforces a 50-per-app cap, so the
test intentionally does NOT fall back to a hardcoded default app to
keep resource accumulation opt-in. Trigger names use an `_e2e_<epoch>`
prefix so leftover disabled test debris is easy to sweep manually via
the miaoda web console when the app approaches the cap.

* test(apps): drop automation live E2E to align with apps-domain convention

* chore: drop .gitignore edits from this branch
2026-07-15 15:55:53 +08:00
zhanghuanxu
49b4ccceb9 chore(slides): address PR review feedback 2026-07-15 14:11:41 +08:00
zhanghuanxu
4b2d012af9 refactor(slides): streamline create workflow and validate SML namespaces 2026-07-15 14:11:41 +08:00
zhanghuanxu
90aad64b8d feat(slides):lint before create 2026-07-15 14:11:41 +08:00
zhanghuanxu
2919084103 feat(slides): validate iconpark icon types in slides lint 2026-07-15 14:11:41 +08:00
zhanghuanxu
36bd82cb27 feat(slides): add sxsd validation to slides lint 2026-07-15 14:11:41 +08:00
zhanghuanxu
2e77d8db80 fix(slides): detect lark slides text overflow overlap 2026-07-15 14:11:41 +08:00
zhanghuanxu
d9061ffcbc fix(slides): limit slides screenshot page requests 2026-07-15 14:11:41 +08:00
zhanghuanxu
08d9b28ee8 docs(slides): prefer slides xml-get shortcut 2026-07-15 14:11:41 +08:00
zhanghuanxu
168fb13e3e feat:edit ppt template 2026-07-15 14:11:41 +08:00
zhanghuanxu
55c2e5c819 feat:slide style 2026-07-15 14:11:41 +08:00
wangweiming-01
16a93cd277 feat(drive): support apps in list comments (#1877)
* feat(drive): support apps in list comments
2026-07-15 12:15:47 +08:00
ZEden0
e9dabb2184 docs: clarify okr progress children (#1861)
* docs(lark-doc): clarify okr progress children

* docs(lark-doc): trim okr progress child tag notes
2026-07-15 11:44:08 +08:00
calendar-assistant
8acd55e907 docs: surface minutes permission application in skill description (#1890)
The lark-minutes SKILL.md body already documents the +apply-permission
shortcut, but the front-matter description omitted it, so the "actively
apply for minutes permission" intent could not route to this skill. Add
the capability and its trigger condition to the description.
2026-07-14 21:27:19 +08:00
evandance
6ecbfaf690 fix(skills): align skill guidance with the typed error contract (#1786)
Skill references written before the typed-error refactor still taught retired envelope shapes. AI agents following them now read what the CLI actually emits:

- permission recovery reads error.missing_scopes instead of the upstream permission_violations detail
- confirmation gates use type=confirmation, subtype=confirmation_required, and flat risk/action fields
- drive duplicate-remote failures are typed validation envelopes (failed_precondition with params[]), not duplicate_remote_path with error.detail
- drive batch partial failures are ok:false results on stdout, not an error.type=partial_failure stderr envelope
- minutes edit-permission and word-replace misses branch on error.subtype, not retired error.type values
- slides replace failures are stderr typed envelopes only; no raw backend response is printed to stdout
- slides command outputs show the ok/identity/data success envelope instead of the raw {code,msg} OpenAPI wrapper
2026-07-14 21:05:44 +08:00
calendar-assistant
ac2508d3b0 feat: add minutes permission application shortcut (#1876) 2026-07-14 19:31:29 +08:00
ILUO
1c3674487f docs: clarify task search relevance filters (#1884) 2026-07-14 19:19:40 +08:00
719 changed files with 67715 additions and 5988 deletions

3
.github/CODEOWNERS vendored
View File

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

View File

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

View File

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

View File

@@ -25,19 +25,16 @@ jobs:
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}`);
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.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");
@@ -253,19 +250,16 @@ jobs:
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}`);
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.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");

View File

@@ -10,9 +10,10 @@
## Build & Test
```bash
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make test # Full: vet + unit + integration
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make live-skills-test # Opt-in real Skills CLI tests; runs with isolated user directories
make test # Full: vet + unit + integration
```
## Notification Opt-Outs

View File

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

View File

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

View File

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

View File

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

38
affordance/docs.md Normal file
View File

@@ -0,0 +1,38 @@
# docs
> skill: lark-doc
## +create
Create a document from XML or Markdown content.
### Skills
- lark-doc/references/lark-doc-create.md
## +fetch
Fetch a document or a focused portion of its content.
### Skills
- lark-doc/references/lark-doc-fetch.md
## +update
Update document content with a supported document command.
### Skills
- lark-doc/references/lark-doc-update.md
## +history-list
List document history versions.
### Skills
- lark-doc/references/lark-doc-history.md
## +history-revert
Revert a document to a history version.
### Skills
- lark-doc/references/lark-doc-history.md
## +history-revert-status
Check the status of a document history revert.
### Skills
- lark-doc/references/lark-doc-history.md

View File

@@ -344,20 +344,18 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
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
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
}, pagOpts)
if err != nil {
return errs.MarkRaw(err)

View File

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

View File

@@ -352,6 +352,9 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
}
func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
})
@@ -371,8 +374,33 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
if !strings.Contains(stderr.String(), "binary response detected") {
t.Error("expected binary response hint in stderr")
}
if !strings.Contains(stdout.String(), "saved_path") {
t.Error("expected saved_path in output")
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("stdout is not JSON: %v\nstdout:\n%s", err, stdout.String())
}
savedPath, _ := got["saved_path"].(string)
if savedPath == "" {
t.Fatalf("saved_path missing from output: %#v", got)
}
// The file must land inside the temporary cwd — this pins the isolation
// contract: rolling back TestChdir would leave download.bin in the repo.
wantDir, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
gotDir, err := filepath.EvalSymlinks(filepath.Dir(savedPath))
if err != nil {
t.Fatalf("saved_path %q dir not resolvable: %v", savedPath, err)
}
if gotDir != wantDir {
t.Errorf("saved_path %q is outside temp cwd %q", savedPath, wantDir)
}
content, err := os.ReadFile(savedPath)
if err != nil {
t.Fatalf("read saved file: %v", err)
}
if string(content) != "fake-binary-content" {
t.Errorf("saved file content = %q, want %q", content, "fake-binary-content")
}
}

View File

@@ -18,10 +18,21 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/recovery"
)
// NewCmdAuth creates the auth command with subcommands.
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
return newCmdAuth(f, nil)
}
// NewCmdAuthWithRecovery creates the auth command with a build-local recovery
// presenter while preserving NewCmdAuth's established function signature.
func NewCmdAuthWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
return newCmdAuth(f, projector)
}
func newCmdAuth(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "OAuth credentials and authorization management",
@@ -40,10 +51,10 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdAuthLogin(f, nil))
cmd.AddCommand(NewCmdAuthLogout(f, nil))
cmd.AddCommand(NewCmdAuthStatus(f, nil))
cmd.AddCommand(newCmdAuthStatus(f, nil, projector))
cmd.AddCommand(NewCmdAuthScopes(f, nil))
cmd.AddCommand(NewCmdAuthList(f, nil))
cmd.AddCommand(NewCmdAuthCheck(f, nil))
cmd.AddCommand(newCmdAuthList(f, nil, projector))
cmd.AddCommand(newCmdAuthCheck(f, nil, projector))
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
return cmd
}

View File

@@ -13,6 +13,7 @@ import (
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
)
// CheckOptions holds all inputs for auth check.
@@ -24,6 +25,14 @@ type CheckOptions struct {
// NewCmdAuthCheck creates the auth check subcommand.
func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Command {
return newCmdAuthCheck(f, runF, nil)
}
func newCmdAuthCheck(
f *cmdutil.Factory,
runF func(*CheckOptions) error,
projector *recovery.Projector,
) *cobra.Command {
opts := &CheckOptions{Factory: f}
cmd := &cobra.Command{
@@ -33,7 +42,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
if runF != nil {
return runF(opts)
}
return authCheckRun(opts)
return authCheckRunWithRecovery(opts, projector)
},
}
@@ -46,6 +55,10 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
}
func authCheckRun(opts *CheckOptions) error {
return authCheckRunWithRecovery(opts, nil)
}
func authCheckRunWithRecovery(opts *CheckOptions, projector *recovery.Projector) error {
f := opts.Factory
required := strings.Fields(opts.Scope)
@@ -82,7 +95,7 @@ func authCheckRun(opts *CheckOptions) error {
ok := len(missing) == 0
result := map[string]interface{}{"ok": ok, "granted": granted, "missing": missing}
if len(missing) > 0 {
if len(missing) > 0 && projector.CanReference(recovery.TargetAuthLogin) {
result["suggestion"] = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
}
output.PrintJson(f.IOStreams.Out, result)

View File

@@ -6,6 +6,7 @@ package auth
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
@@ -13,6 +14,8 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
"github.com/zalando/go-keyring"
)
@@ -162,3 +165,70 @@ func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
t.Errorf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
}
}
func TestAuthCheckRun_ConcealedLoginOmitsSuggestion(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_user",
UserName: "tester",
}
now := time.Now()
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: cfg.AppID,
UserOpenId: cfg.UserOpenId,
AccessToken: "user-access-token",
RefreshToken: "refresh-token",
ExpiresAt: now.Add(time.Hour).UnixMilli(),
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
GrantedAt: now.Add(-time.Hour).UnixMilli(),
Scope: "im:message",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
visibleFactory, visibleStdout, _, _ := cmdutil.TestFactory(t, cfg)
if err := authCheckRun(&CheckOptions{
Factory: visibleFactory,
Scope: "calendar:calendar:read",
}); output.ExitCodeOf(err) != 1 {
t.Fatalf("default check exit = %d, want predicate miss exit 1", output.ExitCodeOf(err))
}
var visiblePayload map[string]any
if err := json.Unmarshal(visibleStdout.Bytes(), &visiblePayload); err != nil {
t.Fatalf("default stdout must be valid JSON: %v", err)
}
if suggestion, _ := visiblePayload["suggestion"].(string); !strings.Contains(suggestion, "auth login") {
t.Fatalf("default output lost established login suggestion: %#v", visiblePayload)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandAuthLogin: surface.CommandConcealed,
})
err := authCheckRunWithRecovery(
&CheckOptions{Factory: f, Scope: "calendar:calendar:read"},
recovery.NewProjector(func() *surface.Plan { return plan }),
)
if got := output.ExitCodeOf(err); got != 1 {
t.Fatalf("exit code = %d, want predicate miss exit 1", got)
}
if stderr.Len() != 0 {
t.Fatalf("stderr must stay empty, got:\n%s", stderr.String())
}
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 _, ok := payload["suggestion"]; ok {
t.Fatalf("concealed auth/login left a dead suggestion: %#v", payload["suggestion"])
}
if missing, ok := payload["missing"].([]any); !ok || len(missing) != 1 {
t.Fatalf("projection removed missing-scope facts: %#v", payload["missing"])
}
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
)
// ListOptions holds all inputs for auth list.
@@ -24,6 +25,14 @@ type ListOptions struct {
// NewCmdAuthList creates the auth list subcommand.
func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
return newCmdAuthList(f, runF, nil)
}
func newCmdAuthList(
f *cmdutil.Factory,
runF func(*ListOptions) error,
projector *recovery.Projector,
) *cobra.Command {
opts := &ListOptions{Factory: f}
cmd := &cobra.Command{
@@ -33,7 +42,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
if runF != nil {
return runF(opts)
}
return authListRun(opts)
return authListRunWithRecovery(opts, projector)
},
}
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
@@ -43,6 +52,10 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
}
func authListRun(opts *ListOptions) error {
return authListRunWithRecovery(opts, nil)
}
func authListRunWithRecovery(opts *ListOptions, projector *recovery.Projector) error {
f := opts.Factory
multi, _ := core.LoadMultiAppConfig()
@@ -61,7 +74,7 @@ func authListRun(opts *ListOptions) error {
// workspace-aware, so we pull the message+hint out of
// NotConfiguredError() instead of hard-coding it.
var cfgErr *errs.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
if errors.As(projector.Render(core.NotConfiguredError()), &cfgErr) {
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
if cfgErr.Hint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)
@@ -80,7 +93,11 @@ func authListRun(opts *ListOptions) error {
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
fmt.Fprint(f.IOStreams.ErrOut, "No logged-in users.")
if projector.CanReference(recovery.TargetAuthLogin) {
fmt.Fprint(f.IOStreams.ErrOut, " Run `lark-cli auth login` to log in.")
}
fmt.Fprintln(f.IOStreams.ErrOut)
return nil
}

View File

@@ -10,6 +10,8 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
)
// TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that
@@ -126,7 +128,49 @@ func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
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())
if got := stderr.String(); !strings.Contains(got, "No logged-in users") ||
!strings.Contains(got, "auth login") {
t.Errorf("stderr = %q, want established no-users login hint", got)
}
}
func TestAuthListRun_ConcealedLoginKeepsStateWithoutDeadRecovery(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandAuthLogin: surface.CommandConcealed,
})
if err := authListRunWithRecovery(
&ListOptions{Factory: f},
recovery.NewProjector(func() *surface.Plan { return plan }),
); err != nil {
t.Fatalf("auth list should remain a successful probe: %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("stdout must stay empty, got:\n%s", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, "No logged-in users") ||
strings.Contains(got, "auth login") {
t.Fatalf("concealed recovery = %q, want state without dead login action", got)
}
}
func TestAuthListRun_ConcealedConfigInitProjectsManualErrorOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandConfigInit: surface.CommandConcealed,
})
if err := authListRunWithRecovery(
&ListOptions{Factory: f},
recovery.NewProjector(func() *surface.Plan { return plan }),
); err != nil {
t.Fatalf("auth list should remain a successful probe: %v", err)
}
if got := stderr.String(); strings.Contains(got, "config init") {
t.Fatalf("manual config error rendering retained concealed recovery: %q", got)
}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
)
// StatusOptions holds all inputs for auth status.
@@ -22,6 +23,14 @@ type StatusOptions struct {
// NewCmdAuthStatus creates the auth status subcommand.
func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
return newCmdAuthStatus(f, runF, nil)
}
func newCmdAuthStatus(
f *cmdutil.Factory,
runF func(*StatusOptions) error,
projector *recovery.Projector,
) *cobra.Command {
opts := &StatusOptions{Factory: f}
cmd := &cobra.Command{
@@ -31,7 +40,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
if runF != nil {
return runF(opts)
}
return authStatusRun(opts)
return authStatusRun(opts, projector)
},
}
@@ -42,7 +51,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
return cmd
}
func authStatusRun(opts *StatusOptions) error {
func authStatusRun(opts *StatusOptions, projector *recovery.Projector) error {
f := opts.Factory
config, err := f.Config()
@@ -60,11 +69,14 @@ func authStatusRun(opts *StatusOptions) error {
"defaultAs": defaultAs,
}
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
diagnostics := identitydiag.FilterRecovery(
identitydiag.Diagnose(context.Background(), f, config, opts.Verify),
projector.CanReference,
)
result["identities"] = diagnostics
result["identity"] = effectiveIdentity(diagnostics)
addEffectiveVerification(result, diagnostics)
addStatusNote(result, diagnostics)
addStatusNote(result, diagnostics, projector.CanReference(recovery.TargetAuthLogin))
output.PrintJson(f.IOStreams.Out, result)
return nil
@@ -106,13 +118,21 @@ func addEffectiveVerification(result map[string]interface{}, d identitydiag.Resu
}
}
func addStatusNote(result map[string]interface{}, d identitydiag.Result) {
func addStatusNote(result map[string]interface{}, d identitydiag.Result, canAuthLogin bool) {
switch {
case !d.User.Available && d.Bot.Available:
result["note"] = "User identity is " + identitydiag.StatusMessage(d.User.Status) + "; bot identity is ready for bot/tenant API calls. Run `lark-cli auth login` to enable user identity."
note := "User identity is " + identitydiag.StatusMessage(d.User.Status) + "; bot identity is ready for bot/tenant API calls."
if canAuthLogin {
note += " Run `lark-cli auth login` to enable user identity."
}
result["note"] = note
case d.User.Status == identitydiag.StatusNeedsRefresh:
result["note"] = "User identity needs refresh and will be refreshed automatically on the next user API call."
case !d.User.Available && !d.Bot.Available:
result["note"] = "No usable identity is available. Configure bot credentials or run `lark-cli auth login`."
note := "No usable identity is available. Configure bot credentials"
if canAuthLogin {
note += " or run `lark-cli auth login`"
}
result["note"] = note + "."
}
}

View File

@@ -18,7 +18,7 @@ func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
})
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
if err := authStatusRun(&StatusOptions{Factory: f}, nil); err != nil {
t.Fatalf("authStatusRun() error = %v", err)
}
@@ -54,7 +54,7 @@ func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
},
})
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}); err != nil {
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}, nil); err != nil {
t.Fatalf("authStatusRun() error = %v", err)
}

46
cmd/auth/testmain_test.go Normal file
View File

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

View File

@@ -3,7 +3,10 @@
package cmd
import "testing"
import (
"errors"
"testing"
)
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
@@ -70,3 +73,18 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
}
}
func TestIsDeferredBootstrapProfileError(t *testing.T) {
if !isDeferredBootstrapProfileError(errors.New("flag needs an argument: --profile")) {
t.Fatal("missing --profile value must be deferred to the completed Cobra tree")
}
for _, err := range []error{
nil,
errors.New("flag needs an argument: --future"),
errors.New("invalid argument for --profile"),
} {
if isDeferredBootstrapProfileError(err) {
t.Fatalf("unexpected deferred bootstrap error: %v", err)
}
}
}

View File

@@ -21,6 +21,7 @@ import (
cmdupdate "github.com/larksuite/cli/cmd/update"
"github.com/larksuite/cli/cmd/whoami"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
@@ -28,7 +29,12 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/keychain"
internalplatform "github.com/larksuite/cli/internal/platform"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillpolicy"
"github.com/larksuite/cli/internal/skillref"
"github.com/larksuite/cli/internal/surface"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -37,14 +43,29 @@ import (
type BuildOption func(*buildConfig)
type buildConfig struct {
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
skipPlugins bool
skipStrictMode bool
skipService bool
serviceCatalog *apicatalog.Catalog
startupBrand core.LarkBrand
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
presentation restrictionPresentationConfig
skipPlugins bool
skipStrictMode bool
skipService bool
deferStartup bool
serviceCatalog *apicatalog.Catalog
startupBrand core.LarkBrand
startupBrandSet bool
hideProfileSet bool
}
// buildRuntime owns presentation state for exactly one command tree. Factory
// remains the business dependency container; distribution policy never enters
// it. The embedded pointer preserves convenient access to Factory fields in
// cmd-internal tests without exposing the surface plan to business packages.
type buildRuntime struct {
*cmdutil.Factory
surface *surface.Plan
recovery *recovery.Projector
skillReferences *skillref.Resolver
}
// WithStartupBrand initializes the API registry with the given brand before
@@ -55,6 +76,7 @@ type buildConfig struct {
func WithStartupBrand(brand core.LarkBrand) BuildOption {
return func(c *buildConfig) {
c.startupBrand = brand
c.startupBrandSet = true
}
}
@@ -85,6 +107,12 @@ var embeddedSkillContent fs.FS
// supply its own skill content.
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
// SetEmbeddedAffordanceContent registers the per-domain command guidance tree.
// Wrapper mains should wire the repository's affordance directory alongside
// embedded skills so generic --help presentation remains complete and skill
// references follow the composed distribution.
func SetEmbeddedAffordanceContent(fsys fs.FS) { affordance.SetSource(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
@@ -92,6 +120,7 @@ func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
func HideProfile(hide bool) BuildOption {
return func(c *buildConfig) {
c.globals.HideProfile = hide
c.hideProfileSet = true
}
}
@@ -147,11 +176,11 @@ func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOpti
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
// env) belongs in the caller and must be threaded in via BuildOption.
//
// Returns (factory, rootCmd, registry). The registry is nil when plugin
// Returns (runtime, rootCmd, registry). The registry is nil when plugin
// install failed (FailClosed guard installed) or when no plugin produced
// hooks; callers that wire Shutdown emit must nil-check before calling
// hook.Emit.
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command, *hook.Registry) {
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*buildRuntime, *cobra.Command, *hook.Registry) {
// cfg.globals.Profile is left zero here; it's bound to the --profile
// flag in RegisterGlobalFlags and filled by cobra's parse step.
cfg := &buildConfig{}
@@ -160,6 +189,16 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
o(cfg)
}
}
return buildInternalWithConfig(ctx, inv, cfg)
}
// buildInternalWithConfig assembles one command tree from an already-applied
// option snapshot. Execute uses this boundary so stateful BuildOptions are
// never evaluated once for bootstrap inspection and a second time for Build.
func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, cfg *buildConfig) (*buildRuntime, *cobra.Command, *hook.Registry) {
if cfg == nil {
cfg = &buildConfig{}
}
// Default streams when WithIO is not supplied so the root command's
// SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes
// partial streams internally; keep both in sync so cfg.streams reflects
@@ -167,18 +206,28 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
if cfg.streams == nil {
cfg.streams = cmdutil.SystemIO()
}
// Initialize the registry brand before anything touches the runtime
// catalog (its sync.Once would otherwise lock onto the Feishu default).
if cfg.startupBrand != "" {
registry.InitWithBrand(cfg.startupBrand)
}
// Reset the legacy process-global diagnostic snapshots before paths that
// may return early. Distribution presentation state is deliberately not
// stored here; it belongs to this build's immutable surface plan.
cmdpolicy.SetActive(nil)
internalplatform.SetActiveInventory(nil)
f := cmdutil.NewDefault(cfg.streams, inv)
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
f.SkillContent = embeddedSkillContent
runtime := &buildRuntime{Factory: f}
runtime.recovery = recovery.NewProjector(func() *surface.Plan {
return runtime.surface
})
f.Recovery = runtime.recovery
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
@@ -195,7 +244,17 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
// rootUsageTemplate.
rootCmd.SetUsageTemplate(rootUsageTemplate)
installTipsHelpFunc(rootCmd)
// Framework-generated skill pointers read this build's final content and
// exact command surface lazily. A second Build therefore cannot rewrite
// help rendered by the first tree.
installTipsHelpFunc(rootCmd, func() fs.FS {
if !runtime.surface.CanReference(surface.CommandSkillsRead) {
return nil
}
return runtime.SkillContent
}, func() *skillref.Resolver {
return runtime.skillReferences
}, runtime.recovery)
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
@@ -211,11 +270,11 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
f.CurrentCommand = cmd
}
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(cmdconfig.NewCmdConfigWithRecovery(f, runtime.recovery))
rootCmd.AddCommand(auth.NewCmdAuthWithRecovery(f, runtime.recovery))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
rootCmd.AddCommand(doctor.NewCmdDoctorWithRecovery(f, runtime.recovery))
rootCmd.AddCommand(whoami.NewCmdWhoamiWithRecovery(f, runtime.recovery))
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
@@ -231,52 +290,93 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
}
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
groupRootCommands(rootCmd)
classifyRootCommands(rootCmd)
installUnknownSubcommandGuard(rootCmd)
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
// before printing help; non-bare invocations and non-TTY are unaffected.
installRootUpgradePrompt(f, rootCmd)
installRootUpgradePrompt(f, rootCmd, runtime.recovery)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
pruneForStrictMode(rootCmd, mode)
}
if cfg.skipPlugins {
recordInventory(nil)
return f, rootCmd, nil
}
var (
installResult *internalplatform.InstallResult
pluginRules []cmdpolicy.PluginRule
pluginSkills []skillpolicy.PluginSkill
hookRegistry *hook.Registry
denied map[string]cmdpolicy.Denial
)
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
if installErr != nil {
installPluginInstallErrorGuard(rootCmd, installErr)
return f, rootCmd, nil
}
var pluginRules []cmdpolicy.PluginRule
var registry *hook.Registry
if installResult != nil {
pluginRules = installResult.PluginRules
registry = installResult.Registry
}
// Policy errors fail-CLOSED when a plugin contributed (security
// intent must not be silently dropped); yaml-only errors fail-OPEN
// with a warning so a typo can't lock the user out.
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
if len(pluginRules) > 0 {
installPluginConflictGuard(rootCmd, err)
return f, rootCmd, nil
if !cfg.skipPlugins {
var installErr error
installResult, installErr = installPluginsAndHooks(cfg.streams.ErrOut)
if installErr != nil {
installPluginInstallErrorGuard(rootCmd, installErr)
return finalizeFailedBuild(runtime, rootCmd)
}
if installResult != nil {
pluginRules = installResult.PluginRules
pluginSkills = installResult.PluginSkills
hookRegistry = installResult.Registry
}
// Policy errors fail-CLOSED when a plugin contributed (security
// intent must not be silently dropped); yaml-only errors fail-OPEN
// with a warning so a typo can't lock the user out.
var policyErr error
denied, policyErr = applyUserPolicyPruning(rootCmd, pluginRules)
if policyErr != nil {
if len(pluginRules) > 0 {
installPluginConflictGuard(rootCmd, policyErr)
return finalizeFailedBuild(runtime, rootCmd)
}
warnPolicyError(cfg.streams.ErrOut, policyErr)
}
warnPolicyError(cfg.streams.ErrOut, err)
}
if registry != nil {
if err := wireHooks(ctx, rootCmd, registry); err != nil {
// Presentation is an explicit host projection over the exact enforcement
// decisions. With no opt-in, legacy Restrict and YAML policy behavior is
// mechanically unchanged.
var hasConcealedCommands bool
runtime.surface, hasConcealedCommands = applyDistributionPresentation(rootCmd, cfg.presentation, denied)
// Resolve skill assets and canonical references before installing hooks.
// A declared customization is a build-integrity boundary: failure must
// happen before Startup so no lifecycle side effect is stranded.
skillResolution, skillErr := skillpolicy.ResolveWithReferences(embeddedSkillContent, pluginSkills)
if skillErr != nil {
installPluginSkillErrorGuard(rootCmd, skillErr)
return finalizeFailedBuild(runtime, rootCmd)
}
f.SkillContent = skillResolution.Content
runtime.skillReferences = skillResolution.References
// Install hooks only on business commands. The concealment-specific help
// command is attached afterwards, preserving Cobra's historical contract
// that help is not observed or wrapped by plugins.
if hookRegistry != nil {
installHooks(rootCmd, hookRegistry)
}
if hasConcealedCommands {
installHelpCommand(rootCmd)
}
finalizeRootCommandGroups(rootCmd, runtime.surface)
if hookRegistry != nil && !cfg.deferStartup {
if err := emitStartup(ctx, hookRegistry); err != nil {
installPluginLifecycleErrorGuard(rootCmd, err)
return f, rootCmd, nil
recordInventory(installResult)
return runtime, rootCmd, nil
}
}
recordInventory(installResult)
return f, rootCmd, registry
return runtime, rootCmd, hookRegistry
}
func finalizeFailedBuild(runtime *buildRuntime, root *cobra.Command) (*buildRuntime, *cobra.Command, *hook.Registry) {
finalizeRootCommandGroups(root, runtime.surface)
return runtime, root, nil
}

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/vfs"
@@ -28,6 +29,10 @@ func TestBuild_ExternalAPI(t *testing.T) {
// Exercise SetDefaultFS both directions. Passing nil restores the OS FS.
SetDefaultFS(vfs.OsFs{})
SetDefaultFS(nil)
SetEmbeddedAffordanceContent(fstest.MapFS{
"docs.md": {Data: []byte("# docs\n")},
})
t.Cleanup(func() { SetEmbeddedAffordanceContent(nil) })
var in, out, errOut bytes.Buffer
rootCmd := Build(

View File

@@ -18,6 +18,7 @@ import (
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
@@ -59,6 +60,14 @@ type BindOptions struct {
// NewCmdConfigBind creates the config bind subcommand.
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
return newCmdConfigBind(f, runF, nil)
}
func newCmdConfigBind(
f *cmdutil.Factory,
runF func(*BindOptions) error,
projector *recovery.Projector,
) *cobra.Command {
opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN}
cmd := &cobra.Command{
@@ -98,7 +107,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
if runF != nil {
return runF(opts)
}
return configBindRun(opts)
return configBindRunWithRecovery(opts, projector)
},
}
@@ -116,6 +125,10 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
// helper whose signature declares its contract; the body reads as the shape of
// the bind flow itself, not its mechanics.
func configBindRun(opts *BindOptions) error {
return configBindRunWithRecovery(opts, nil)
}
func configBindRunWithRecovery(opts *BindOptions, projector *recovery.Projector) error {
if err := validateBindFlags(opts); err != nil {
return err
}
@@ -154,7 +167,7 @@ func configBindRun(opts *BindOptions) error {
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
noticeUserDefaultRisk(opts)
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath, projector)
}
// existingBinding is the outcome of checking whether a workspace was already
@@ -404,7 +417,13 @@ func priorLang(previousConfigBytes []byte) i18n.Lang {
// any), and a JSON success envelope. Cleanup runs only after the new config
// is durably written — if anything fails earlier, the old workspace stays
// usable.
func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error {
func commitBinding(
opts *BindOptions,
appConfig *core.AppConfig,
previousConfigBytes []byte,
source, configPath string,
projector *recovery.Projector,
) error {
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
@@ -462,7 +481,7 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
case "bot-only":
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand)
case "user-default":
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display)
envelope["message"] = userDefaultBindMessage(prefMsg, appConfig.AppId, display, projector)
}
resultJSON, _ := json.Marshal(envelope)
@@ -470,6 +489,17 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
return nil
}
func userDefaultBindMessage(
messages *bindMsg,
appID, display string,
projector *recovery.Projector,
) string {
if projector.CanReference(recovery.TargetAuthLogin) {
return fmt.Sprintf(messages.MessageUserDefault, appID, display, display)
}
return fmt.Sprintf(messages.MessageUserDefaultFallback, appID, display)
}
// cleanupKeychainFromData removes keychain entries referenced by a previous
// config snapshot, skipping any entry whose keychain ID is still in use by
// the new app config. This prevents rebinding the same appId from deleting

View File

@@ -37,6 +37,9 @@ type bindMsg struct {
// MessageBotOnly format: app_id, source display name, brand.
// MessageUserDefault format: app_id, source display name, source display
// name (second source ref anchors the "run in this chat" directive).
// MessageUserDefaultFallback format: app_id, source display name. It keeps
// the completed bind facts but uses target-free recovery when auth/login
// is not part of this distribution.
// MessageUserDefault directs the Agent at the blocking single-call
// `auth login --recommend` flow: the CLI streams verification_url to
// stderr, which Agent runtimes (OpenClaw, Hermes) relay to the user in
@@ -44,8 +47,9 @@ type bindMsg struct {
// The Agent also needs an explicit "do not navigate the URL yourself"
// guard — its own browser is sandboxed and cannot complete the user's
// authorization.
MessageBotOnly string
MessageUserDefault string
MessageBotOnly string
MessageUserDefault string
MessageUserDefaultFallback string
// Identity preset (collapses strict-mode + default-as into one choice).
// IdentityBotOnly/IdentityUserDefault are short, single-line labels for
@@ -108,8 +112,9 @@ var bindMsgZh = &bindMsg{
ConflictCancel: "保留当前配置",
ConflictCancelled: "已保留当前配置",
MessageBotOnly: "已绑定应用 %s 到 %s可立即以应用bot身份调用%s API现在可以继续执行用户的请求。",
MessageUserDefault: "已绑定应用 %s 到 %s。请接着在此 %s 对话中运行 `lark-cli auth login --recommend`。该命令会在 stderr 打出 verification_url 后阻塞等待用户授权;请将此链接原样发给用户在其浏览器中完成授权(不要自己调 browser_navigate 之类的工具打开,授权必须在用户的浏览器里完成),命令会在用户授权完成后自动返回。",
MessageBotOnly: "已绑定应用 %s 到 %s可立即以应用bot身份调用%s API现在可以继续执行用户的请求。",
MessageUserDefault: "已绑定应用 %s 到 %s。请接着在此 %s 对话中运行 `lark-cli auth login --recommend`。该命令会在 stderr 打出 verification_url 后阻塞等待用户授权;请将此链接原样发给用户在其浏览器中完成授权(不要自己调 browser_navigate 之类的工具打开,授权必须在用户的浏览器里完成),命令会在用户授权完成后自动返回。",
MessageUserDefaultFallback: "已绑定应用 %s 到 %s。请通过该发行版支持的授权流程获取或刷新用户凭证然后再继续执行用户的请求。",
SelectIdentity: "你希望 AI 如何与你协作?",
IdentityBotOnly: "以机器人身份",
@@ -144,8 +149,9 @@ var bindMsgEn = &bindMsg{
ConflictCancel: "Keep current config",
ConflictCancelled: "Current config kept. No changes made.",
MessageBotOnly: "Bound app %s to %s. The %s app (bot) identity is ready — you can now continue with the user's request.",
MessageUserDefault: "Bound app %s to %s. Next, in this %s chat, run `lark-cli auth login --recommend`. The command prints the verification URL to stderr and then blocks until the user authorizes it; relay the URL to the user so they can approve it in their own browser (do not call browser_navigate or any tool that opens a browser yourself — your browser is sandboxed and cannot complete the authorization). The command returns automatically once authorization completes.",
MessageBotOnly: "Bound app %s to %s. The %s app (bot) identity is ready — you can now continue with the user's request.",
MessageUserDefault: "Bound app %s to %s. Next, in this %s chat, run `lark-cli auth login --recommend`. The command prints the verification URL to stderr and then blocks until the user authorizes it; relay the URL to the user so they can approve it in their own browser (do not call browser_navigate or any tool that opens a browser yourself — your browser is sandboxed and cannot complete the authorization). The command returns automatically once authorization completes.",
MessageUserDefaultFallback: "Bound app %s to %s. Obtain or refresh a user credential through this distribution's supported authorization flow before continuing with the user's request.",
SelectIdentity: "How should the AI work with you?",
IdentityBotOnly: "As bot",

View File

@@ -10,6 +10,8 @@ import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
)
// runHermesBindWithIdentity boots a Hermes-shaped fake env, runs `config bind`
@@ -60,3 +62,29 @@ func TestConfigBindRun_BotOnlyIdentity_NoImpersonationWarning(t *testing.T) {
t.Errorf("bot-only bind must NOT warn about impersonation; got: %s", out)
}
}
func TestUserDefaultBindMessageProjectsConcealedLogin(t *testing.T) {
visible := userDefaultBindMessage(bindMsgEn, "cli_test", "Hermes", nil)
if !strings.Contains(visible, "lark-cli auth login --recommend") {
t.Fatalf("default message lost established login action: %q", visible)
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandAuthLogin: surface.CommandConcealed,
})
concealed := userDefaultBindMessage(
bindMsgEn,
"cli_test",
"Hermes",
recovery.NewProjector(func() *surface.Plan { return plan }),
)
if strings.Contains(concealed, "auth login") ||
!strings.Contains(concealed, "supported authorization flow") {
t.Fatalf("concealed message = %q, want target-free authorization fallback", concealed)
}
for _, want := range []string{"cli_test", "Hermes"} {
if !strings.Contains(concealed, want) {
t.Errorf("concealed message lost binding fact %q: %q", want, concealed)
}
}
}

View File

@@ -6,11 +6,22 @@ package config
import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/recovery"
"github.com/spf13/cobra"
)
// NewCmdConfig creates the config command with subcommands.
func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
return newCmdConfig(f, nil)
}
// NewCmdConfigWithRecovery creates the config command with build-local
// recovery projection while preserving NewCmdConfig's established signature.
func NewCmdConfigWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
return newCmdConfig(f, projector)
}
func newCmdConfig(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Global CLI configuration management",
@@ -26,11 +37,12 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(NewCmdConfigInit(f, nil))
cmd.AddCommand(NewCmdConfigBind(f, nil))
cmd.AddCommand(newCmdConfigBind(f, nil, projector))
cmd.AddCommand(NewCmdConfigRemove(f, nil))
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))

View File

@@ -20,6 +20,8 @@ import (
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
)
type noopConfigKeychain struct{}
@@ -564,3 +566,59 @@ func TestPrintLangPreferenceConfirmation(t *testing.T) {
}
})
}
// The "no active profile" producer annotates its profile/list recovery target.
// Rendering against one build's surface filters a clone without mutating the
// value another command tree may render.
func TestConfigShowRun_ProfileHintUsesBuildLocalSurface(t *testing.T) {
multi := &core.MultiAppConfig{
CurrentApp: "missing",
Apps: []core.AppConfig{{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
}},
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
source := configShowRun(&ConfigShowOptions{Factory: f})
var original *errs.ConfigError
if !errors.As(source, &original) {
t.Fatalf("expected *errs.ConfigError, got %T %v", source, source)
}
if original.Subtype != errs.SubtypeNotConfigured {
t.Fatalf("subtype = %q, want not_configured", original.Subtype)
}
if !strings.Contains(original.Hint, "lark-cli profile list") {
t.Fatalf("producer hint = %q, want profile list", original.Hint)
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandProfileList: surface.CommandConcealed,
})
var concealed *errs.ConfigError
if rendered := recovery.Render(source, plan); !errors.As(rendered, &concealed) {
t.Fatalf("rendered error = %T, want *errs.ConfigError", rendered)
}
if concealed == original {
t.Fatal("Render must clone the typed error")
}
if strings.Contains(concealed.Hint, "profile list") ||
!strings.Contains(concealed.Hint, "select or configure an available profile") {
t.Errorf("concealed hint = %q, want target-free profile recovery", concealed.Hint)
}
var visible *errs.ConfigError
if !errors.As(recovery.Render(source, nil), &visible) ||
!strings.Contains(visible.Hint, "lark-cli profile list") {
t.Errorf("visible render must keep profile list, got %+v", visible)
}
if !strings.Contains(original.Hint, "lark-cli profile list") {
t.Errorf("concealed render mutated source hint: %q", original.Hint)
}
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
)
// ConfigInitOptions holds all inputs for config init.
@@ -40,13 +41,44 @@ type ConfigInitOptions struct {
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
// ForceInit overrides the agent-workspace guard. Without it, running
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
// at config bind — which is what AI agents almost always want. Manual
// users with a legitimate need for a separate app can pass --force-init
// to bypass.
// init under OPENCLAW_HOME / HERMES_HOME refuses so the distribution's
// supported Agent-app setup flow remains the default. Manual users with
// a legitimate need for a separate app can pass --force-init to bypass.
ForceInit bool
}
const (
configInitLongPrefix = `Initialize configuration (app-id / app-secret-stdin / brand).
For AI agents: use --new to create a new app. The command blocks until the user
completes setup in the browser. Run it in the background and retrieve the
verification URL from its output.
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command`
configInitBindGuidance = `
refuses by default — use 'lark-cli config bind' to bind to the Agent's
existing app instead of creating a parallel one.`
configInitBindFallback = `
refuses by default to avoid creating a parallel app alongside Agent-managed
credentials. Reuse the Agent's existing app through this distribution's
supported setup flow.`
configInitBindSuffix = ` Pass --force-init only
if the user explicitly wants a separate app inside the Agent workspace.`
configInitFallbackSuffix = ` Pass --force-init only if the user explicitly wants a
separate app inside the Agent workspace.`
configInitLongWithBind = configInitLongPrefix + configInitBindGuidance + configInitBindSuffix
configInitLongWithoutBind = configInitLongPrefix + configInitBindFallback + configInitFallbackSuffix
forceInitUsageWithBind = "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app"
forceInitUsageWithoutBind = "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME) only when the user explicitly wants a separate app"
)
// NewCmdConfigInit creates the config init subcommand.
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
opts := &ConfigInitOptions{Factory: f, UILang: i18n.LangZhCN}
@@ -54,16 +86,7 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
cmd := &cobra.Command{
Use: "init",
Short: "Initialize configuration (app-id / app-secret-stdin / brand)",
Long: `Initialize configuration (app-id / app-secret-stdin / brand).
For AI agents: use --new to create a new app. The command blocks until the user
completes setup in the browser. Run it in the background and retrieve the
verification URL from its output.
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
refuses by default — use 'lark-cli config bind' to bind to the Agent's
existing app instead of creating a parallel one. Pass --force-init only
if the user explicitly wants a separate app inside the Agent workspace.`,
Long: configInitLongWithBind,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
opts.langExplicit = cmd.Flags().Changed("lang")
@@ -86,12 +109,30 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, forceInitUsageWithBind)
cmdutil.SetRisk(cmd, "write")
return cmd
}
// ProjectInitHelp keeps the default command-specific guidance intact and
// replaces it only when this build conceals config bind. The config package
// owns both variants; the root presentation pass supplies the build-local
// availability decision after plugin policy has finalized the command tree.
func ProjectInitHelp(cmd *cobra.Command, canReferenceBind bool) {
if cmd == nil {
return
}
long, forceInitUsage := configInitLongWithBind, forceInitUsageWithBind
if !canReferenceBind {
long, forceInitUsage = configInitLongWithoutBind, forceInitUsageWithoutBind
}
cmd.Long = long
if flag := cmd.Flags().Lookup("force-init"); flag != nil {
flag.Usage = forceInitUsage
}
}
// printLangPreferenceConfirmation echoes the set preference to stderr, only
// when --lang explicitly set a non-empty value.
func printLangPreferenceConfirmation(opts *ConfigInitOptions) {
@@ -125,9 +166,14 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
if ws.IsLocal() {
return nil
}
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.")
return recovery.Attach(
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()),
recovery.Join(" ",
recovery.Command(recovery.TargetConfigBind, "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead."),
recovery.Text("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.

View File

@@ -9,6 +9,8 @@ import (
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
)
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
@@ -44,6 +46,82 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
}
}
func TestGuardAgentWorkspace_BindRecoveryUsesBuildLocalSurface(t *testing.T) {
t.Setenv("OPENCLAW_HOME", t.TempDir())
source := guardAgentWorkspace(&ConfigInitOptions{})
var original *errs.ConfigError
if !errors.As(source, &original) {
t.Fatalf("guardAgentWorkspace() error = %T, want *errs.ConfigError", source)
}
const visibleHint = "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."
if original.Hint != visibleHint {
t.Fatalf("producer hint = %q, want %q", original.Hint, visibleHint)
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandConfigBind: surface.CommandConcealed,
})
var concealed *errs.ConfigError
if rendered := recovery.Render(source, plan); !errors.As(rendered, &concealed) {
t.Fatalf("rendered error = %T, want *errs.ConfigError", rendered)
}
const forceInitHint = "Pass --force-init only if the user explicitly wants a separate app in this workspace."
if concealed.Hint != forceInitHint {
t.Errorf("concealed hint = %q, want %q", concealed.Hint, forceInitHint)
}
if original.Hint != visibleHint {
t.Errorf("concealed render mutated producer hint: %q", original.Hint)
}
}
func TestProjectInitHelpPreservesDefaultAndProjectsConcealedBind(t *testing.T) {
cmd := NewCmdConfigInit(nil, nil)
forceInit := cmd.Flags().Lookup("force-init")
if forceInit == nil {
t.Fatal("config init command has no --force-init flag")
}
const defaultLong = `Initialize configuration (app-id / app-secret-stdin / brand).
For AI agents: use --new to create a new app. The command blocks until the user
completes setup in the browser. Run it in the background and retrieve the
verification URL from its output.
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
refuses by default — use 'lark-cli config bind' to bind to the Agent's
existing app instead of creating a parallel one. Pass --force-init only
if the user explicitly wants a separate app inside the Agent workspace.`
const defaultForceInitUsage = "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app"
if cmd.Long != defaultLong || forceInit.Usage != defaultForceInitUsage {
t.Fatalf("default help lost config bind recovery:\nLong:\n%s\n--force-init: %s", cmd.Long, forceInit.Usage)
}
ProjectInitHelp(cmd, false)
const concealedLong = `Initialize configuration (app-id / app-secret-stdin / brand).
For AI agents: use --new to create a new app. The command blocks until the user
completes setup in the browser. Run it in the background and retrieve the
verification URL from its output.
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
refuses by default to avoid creating a parallel app alongside Agent-managed
credentials. Reuse the Agent's existing app through this distribution's
supported setup flow. Pass --force-init only if the user explicitly wants a
separate app inside the Agent workspace.`
const concealedForceInitUsage = "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME) only when the user explicitly wants a separate app"
if cmd.Long != concealedLong || forceInit.Usage != concealedForceInitUsage {
t.Fatalf("concealed help was not projected:\nLong:\n%s\n--force-init: %s", cmd.Long, forceInit.Usage)
}
if strings.Contains(cmd.Long, "config bind") || strings.Contains(forceInit.Usage, "config bind") {
t.Fatalf("concealed help retained config bind:\nLong:\n%s\n--force-init: %s", cmd.Long, forceInit.Usage)
}
ProjectInitHelp(cmd, true)
if cmd.Long != defaultLong || forceInit.Usage != defaultForceInitUsage {
t.Fatalf("visible projection did not restore default help:\nLong:\n%s\n--force-init: %s", cmd.Long, forceInit.Usage)
}
}
func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
t.Setenv("HERMES_HOME", t.TempDir())

View File

@@ -85,6 +85,9 @@ func runConfigPluginsShow(f *cmdutil.Factory) error {
if len(p.Rules) > 0 {
entry["rules"] = p.Rules
}
if p.EmbeddedSkills != nil {
entry["embedded_skills"] = p.EmbeddedSkills
}
entry["hooks"] = map[string]any{
"observers": p.Observers,
"wrappers": p.Wrappers,

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"encoding/json"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
internalplatform "github.com/larksuite/cli/internal/platform"
)
// config plugins show must surface a plugin's EmbeddedSkills contribution in
// the rendered JSON, not only in the internal inventory struct: this command is
// the operator's window into what a fork trimmed, so the Allow/Remove/Overlay/
// Base summary has to reach stdout. Guards the render layer, which asserting the
// inventory struct alone does not exercise.
func TestConfigPluginsShow_RendersEmbeddedSkills(t *testing.T) {
internalplatform.SetActiveInventory(&internalplatform.Inventory{
Plugins: []internalplatform.PluginEntry{{
Name: "acme",
Version: "1.0",
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
EmbeddedSkills: &internalplatform.SkillsOverlayView{
Allow: []string{"lark-im"},
Remove: []string{"lark-a"},
Overlay: true,
Base: true,
},
}},
})
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
out := &bytes.Buffer{}
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
if err := runConfigPluginsShow(f); err != nil {
t.Fatalf("show: %v", err)
}
var got struct {
Plugins []struct {
EmbeddedSkills *internalplatform.SkillsOverlayView `json:"embedded_skills"`
} `json:"plugins"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("not json: %v\n%s", err, out.String())
}
if len(got.Plugins) != 1 {
t.Fatalf("want 1 plugin, got %d", len(got.Plugins))
}
es := got.Plugins[0].EmbeddedSkills
if es == nil {
t.Fatalf("embedded_skills missing from rendered output:\n%s", out.String())
}
if len(es.Allow) != 1 || es.Allow[0] != "lark-im" ||
len(es.Remove) != 1 || es.Remove[0] != "lark-a" ||
!es.Overlay || !es.Base {
t.Errorf("embedded_skills summary mismatch: %+v", es)
}
}
// A plugin that did not customize embedded skills must not emit an
// embedded_skills key, so the field's presence is a reliable signal that a fork
// trimmed the tree.
func TestConfigPluginsShow_OmitsEmbeddedSkillsWhenAbsent(t *testing.T) {
internalplatform.SetActiveInventory(&internalplatform.Inventory{
Plugins: []internalplatform.PluginEntry{{
Name: "acme",
Version: "1.0",
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
}},
})
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
out := &bytes.Buffer{}
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
if err := runConfigPluginsShow(f); err != nil {
t.Fatalf("show: %v", err)
}
var raw map[string]any
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
t.Fatalf("not json: %v", err)
}
plugins, ok := raw["plugins"].([]any)
if !ok || len(plugins) != 1 {
t.Fatalf("want 1 plugin in output, got: %s", out.String())
}
if _, ok := plugins[0].(map[string]any)["embedded_skills"]; ok {
t.Errorf("embedded_skills must be omitted when the plugin customized no skills; got:\n%s", out.String())
}
}

View File

@@ -57,7 +57,7 @@ func runConfigPolicyShow(f *cmdutil.Factory) error {
out := map[string]any{
"source": string(active.Source.Kind),
"source_name": sourceName,
"denied_paths": active.DeniedPaths,
"denied_paths": active.DeniedPathCount(),
}
if len(active.Rules) > 0 {
rules := make([]map[string]any, 0, len(active.Rules))

View File

@@ -62,7 +62,10 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
Kind: cmdpolicy.SourcePlugin,
Name: "secaudit",
},
DeniedPaths: 42,
DeniedByPath: map[string]cmdpolicy.Denial{
"docs/create": {},
"docs/update": {},
},
})
f, out, _ := newPolicyTestFactory()
@@ -80,8 +83,8 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
t.Errorf("source_name = %v, want secaudit", got["source_name"])
}
// json.Unmarshal returns float64 for numbers.
if got["denied_paths"] != float64(42) {
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
if got["denied_paths"] != float64(2) {
t.Errorf("denied_paths = %v, want 2", got["denied_paths"])
}
rulesAny, ok := got["rules"].([]any)
if !ok || len(rulesAny) != 1 {

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
"github.com/spf13/cobra"
)
@@ -55,7 +56,14 @@ func configShowRun(opts *ConfigShowOptions) error {
}
app := config.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
hint := recovery.Join("",
recovery.Command(recovery.TargetProfileList, "run: lark-cli profile list")).
WithFallback("select or configure an available profile through this distribution")
return recovery.Annotate(
errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").
WithHint("%s", hint.String()),
hint,
)
}
users := "(no logged-in users)"
if len(app.Users) > 0 {

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/update"
)
@@ -33,6 +34,17 @@ type DoctorOptions struct {
// NewCmdDoctor creates the doctor command.
func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
return newCmdDoctor(f, nil)
}
// NewCmdDoctorWithRecovery creates the doctor command with a build-local
// recovery presenter. Distribution assembly uses this boundary; ordinary
// callers keep NewCmdDoctor's original function signature and default output.
func NewCmdDoctorWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
return newCmdDoctor(f, projector)
}
func newCmdDoctor(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
opts := &DoctorOptions{Factory: f}
cmd := &cobra.Command{
@@ -40,7 +52,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
Short: "CLI health check: config, auth, and connectivity",
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
return doctorRun(opts)
return doctorRun(opts, projector)
},
}
cmdutil.DisableAuthCheck(cmd)
@@ -74,13 +86,13 @@ func skip(name, msg string) checkResult {
return checkResult{Name: name, Status: "skip", Message: msg}
}
func doctorRun(opts *DoctorOptions) error {
func doctorRun(opts *DoctorOptions, projector *recovery.Projector) error {
f := opts.Factory
var checks []checkResult
// ── 0. CLI version & update check ──
checks = append(checks, pass("cli_version", build.Version))
if !opts.Offline {
if !opts.Offline && projector.CanReference(recovery.TargetUpdate) {
checks = append(checks, checkCLIUpdate()...)
}
@@ -96,7 +108,7 @@ func doctorRun(opts *DoctorOptions) error {
msg, hint := err.Error(), ""
if errors.Is(err, os.ErrNotExist) {
var cfgErr *errs.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
if errors.As(projector.Render(core.NotConfiguredError()), &cfgErr) {
msg, hint = cfgErr.Message, cfgErr.Hint
}
}
@@ -110,7 +122,7 @@ func doctorRun(opts *DoctorOptions) error {
if err != nil {
hint := ""
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
if errors.As(projector.Render(err), &cfgErr) {
hint = cfgErr.Hint
}
checks = append(checks, fail("app_resolved", err.Error(), hint))
@@ -121,7 +133,10 @@ func doctorRun(opts *DoctorOptions) error {
ep := core.ResolveEndpoints(cfg.Brand)
// ── 3. Identity readiness ──
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
diagnostics := identitydiag.FilterRecovery(
identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline),
projector.CanReference,
)
checks = append(checks,
identityCheck("bot_identity", diagnostics.Bot),
identityCheck("user_identity", diagnostics.User),
@@ -215,7 +230,7 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
// Unlike the root-level async check, this does a synchronous fetch with timeout
// and works regardless of build version (dev builds included).
func checkCLIUpdate() []checkResult {
latest, err := update.FetchLatest()
latest, err := fetchLatestForDoctor()
if err != nil {
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
}
@@ -228,6 +243,8 @@ func checkCLIUpdate() []checkResult {
return []checkResult{pass("cli_update", latest+" (up to date)")}
}
var fetchLatestForDoctor = update.FetchLatest
func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
allOK := true
for _, c := range checks {

View File

@@ -17,6 +17,8 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
)
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
@@ -101,6 +103,31 @@ func TestNetworkChecks_Offline(t *testing.T) {
}
}
func TestDoctorRunDoesNotFetchUpdateWhenCommandIsConcealed(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
oldFetch := fetchLatestForDoctor
t.Cleanup(func() { fetchLatestForDoctor = oldFetch })
fetches := 0
fetchLatestForDoctor = func() (string, error) {
fetches++
return "9.9.9", nil
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandUpdate: surface.CommandConcealed,
})
projector := recovery.NewProjector(func() *surface.Plan { return plan })
f, _, _, _ := cmdutil.TestFactory(t, nil)
_ = doctorRun(&DoctorOptions{
Factory: f,
Ctx: context.Background(),
}, projector)
if fetches != 0 {
t.Fatalf("concealed update triggered %d npm fetch(es)", fetches)
}
}
func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
@@ -124,7 +151,7 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
Factory: f,
Ctx: context.Background(),
Offline: true,
})
}, nil)
if err != nil {
t.Fatalf("doctorRun() error = %v", err)
}
@@ -202,7 +229,7 @@ func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}, nil); err == nil {
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
}
var got struct {

View File

@@ -4,7 +4,6 @@
package cmd
import (
"errors"
"fmt"
"strings"
@@ -15,11 +14,64 @@ 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/errclass"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
)
// rootErrorPresenter owns the final command-facing error transformation for
// one Cobra tree. Producers report typed facts and optional semantic recovery;
// this boundary clones, completes, and projects them without exposing the
// build-local surface plan to business packages.
type rootErrorPresenter struct {
f *cmdutil.Factory
projector *recovery.Projector
}
func newRootErrorPresenter(f *cmdutil.Factory, projector *recovery.Projector) *rootErrorPresenter {
return &rootErrorPresenter{f: f, projector: projector}
}
func (p *rootErrorPresenter) Present(err error) error {
if err == nil || errs.IsRaw(err) {
return err
}
rendered := p.projector.Render(err)
p.completePermissionRecovery(rendered)
applyNeedAuthorizationHint(p.f, rendered)
return rendered
}
// completePermissionRecovery supplies the canonical recovery for direct
// PermissionError producers. API classification paths that already carry an
// owned structured annotation keep their rendered Hint unchanged.
func (p *rootErrorPresenter) completePermissionRecovery(err error) {
typed, ok := errs.UnwrapTypedError(err)
if !ok {
return
}
permissionErr, ok := typed.(*errs.PermissionError) //nolint:errorlint // presentation must not descend into the clone's original Cause
if !ok || permissionErr.Hint != "" {
return
}
identity := permissionErr.Identity
if identity == "" && p.f != nil {
identity = string(p.f.ResolvedIdentity)
}
if identity == "" {
identity = string(core.AsUser)
}
hint := errclass.PermissionRecovery(
permissionErr.MissingScopes,
identity,
permissionErr.Subtype,
permissionErr.ConsoleURL,
)
permissionErr.Hint = p.projector.RenderHint(hint)
}
// applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a
// "current command requires scope(s): X, Y" hint when the underlying error is
// a need_user_authorization signal AND the current command declares scopes
@@ -32,8 +84,12 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
if !internalauth.IsNeedUserAuthorizationError(err) {
return
}
var authErr *errs.AuthenticationError
if !errors.As(err, &authErr) {
typed, ok := errs.UnwrapTypedError(err)
if !ok {
return
}
authErr, ok := typed.(*errs.AuthenticationError) //nolint:errorlint // enrich only the presented clone, never a nested producer Cause
if !ok {
return
}
scopes := resolveDeclaredScopesForCurrentCommand(f)

139
cmd/error_presenter_test.go Normal file
View File

@@ -0,0 +1,139 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/surface"
"github.com/spf13/cobra"
)
func TestRootErrorPresenterCompletesDirectPermissionRecoveryWithoutMutatingProducer(t *testing.T) {
source := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope").
WithMissingScopes("docx:document").
WithIdentity("user")
visible := newRootErrorPresenter(
&cmdutil.Factory{ResolvedIdentity: core.AsUser},
recovery.NewProjector(nil),
).Present(source)
visibleProblem, _ := errs.ProblemOf(visible)
if !strings.Contains(visibleProblem.Hint, `auth login --scope "docx:document"`) {
t.Fatalf("visible recovery = %q, want scoped auth login", visibleProblem.Hint)
}
if source.Hint != "" {
t.Fatalf("presenter mutated producer hint: %q", source.Hint)
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandAuthLogin: surface.CommandConcealed,
})
concealed := newRootErrorPresenter(
&cmdutil.Factory{ResolvedIdentity: core.AsUser},
recovery.NewProjector(func() *surface.Plan { return plan }),
).Present(source)
concealedProblem, _ := errs.ProblemOf(concealed)
if strings.Contains(concealedProblem.Hint, "auth login") ||
!strings.Contains(concealedProblem.Hint, "supported authorization flow") {
t.Fatalf("concealed recovery = %q, want target-free fallback", concealedProblem.Hint)
}
}
func TestRootErrorPresenterDoesNotRecommendUserLoginForBotPermission(t *testing.T) {
source := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope").
WithMissingScopes("drive:file:download").
WithIdentity("bot")
rendered := newRootErrorPresenter(
&cmdutil.Factory{ResolvedIdentity: core.AsBot},
recovery.NewProjector(nil),
).Present(source)
problem, _ := errs.ProblemOf(rendered)
if strings.Contains(problem.Hint, "auth login") ||
!strings.Contains(problem.Hint, "app developer") {
t.Fatalf("bot recovery = %q", problem.Hint)
}
}
func TestRootErrorPresenterDoesNotMutateNestedPermissionCause(t *testing.T) {
inner := errs.NewPermissionError(errs.SubtypeMissingScope, "inner permission").
WithMissingScopes("docx:document").
WithIdentity("user")
outer := errs.NewInternalError(errs.SubtypeUnknown, "outer failure").
WithHint("retry the operation").
WithCause(inner)
rendered := newRootErrorPresenter(
&cmdutil.Factory{ResolvedIdentity: core.AsUser},
recovery.NewProjector(nil),
).Present(outer)
if inner.Hint != "" {
t.Fatalf("presenter mutated nested producer hint: %q", inner.Hint)
}
problem, _ := errs.ProblemOf(rendered)
if got, want := problem.Hint, "retry the operation"; got != want {
t.Fatalf("rendered outer hint = %q, want %q", got, want)
}
}
func TestRootErrorPresenterDoesNotMutateNestedAuthenticationCause(t *testing.T) {
f := factoryWithDeclaredServiceScope(t)
source := internalauth.NewNeedUserAuthorizationError("ou_nested")
var inner *errs.AuthenticationError
if !errors.As(source, &inner) {
t.Fatalf("source = %T, want nested *errs.AuthenticationError", source)
}
originalHint := inner.Hint
outer := errs.NewInternalError(errs.SubtypeUnknown, "outer failure").
WithHint("retry the operation").
WithCause(source)
rendered := newRootErrorPresenter(f, recovery.NewProjector(nil)).Present(outer)
if got := inner.Hint; got != originalHint {
t.Fatalf("presenter mutated nested authentication hint: got %q want %q", got, originalHint)
}
problem, _ := errs.ProblemOf(rendered)
if got, want := problem.Hint, "retry the operation"; got != want {
t.Fatalf("rendered outer hint = %q, want %q", got, want)
}
}
func factoryWithDeclaredServiceScope(t *testing.T) *cmdutil.Factory {
t.Helper()
f := &cmdutil.Factory{ResolvedIdentity: core.AsUser}
var target registry.CommandEntry
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
if len(entry.Scopes) > 0 {
target = entry
break
}
}
if target.Command == "" {
t.Fatal("failed to locate a service command with declared user scopes")
}
parts := strings.Split(target.Command, " ")
if len(parts) != 2 {
t.Fatalf("service command = %q, want resource and method", target.Command)
}
root := &cobra.Command{Use: "lark-cli"}
domain := &cobra.Command{Use: "calendar"}
resource := &cobra.Command{Use: parts[0]}
method := &cobra.Command{Use: parts[1]}
root.AddCommand(domain)
domain.AddCommand(resource)
resource.AddCommand(method)
f.CurrentCommand = method
return f
}

View File

@@ -278,27 +278,24 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
if len(missing) == 0 {
return nil
}
return errs.NewPermissionError(errs.SubtypeMissingScope,
permissionErr := 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))
WithMissingScopes(missing...)
if pf.identity.IsBot() {
permissionErr.WithHint("%s", botScopeRemediationHint(pf.brand, pf.appID, missing))
}
return permissionErr
}
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
// 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 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, " "))
// The bot-specific scan-to-enable link adds the scopes to the app manifest,
// after which the tenant token carries them. User recovery is generated from
// the PermissionError's identity and missing_scopes by the root presenter.
func botScopeRemediationHint(brand core.LarkBrand, appID string, missing []string) string {
return fmt.Sprintf("grant these scopes by scanning: %s",
addonsHintURL(brand, appID, missingScopeAddons(core.AsBot, missing)))
}
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed
@@ -379,7 +376,7 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
if result == nil || result.Token == "" {
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'.")
WithHint("check that app_secret is configured for this distribution")
}
return result.Token, nil
}

View File

@@ -17,6 +17,8 @@ import (
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
@@ -36,6 +38,8 @@ func TestRunList_TextOutput(t *testing.T) {
out := stdout.String()
for _, want := range []string{
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
@@ -90,6 +94,8 @@ func TestRunList_JSONOutput(t *testing.T) {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
for _, want := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {

View File

@@ -264,18 +264,9 @@ func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
}
}
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
// bot: scan-to-enable link (adds scopes to app manifest)
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
func TestBotScopeRemediationHintUsesScanLink(t *testing.T) {
bot := botScopeRemediationHint(core.BrandFeishu, "cli_x", []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

@@ -19,6 +19,29 @@ import (
_ "github.com/larksuite/cli/events"
)
type approvalSchemaJSONPayload struct {
JQRootPath string `json:"jq_root_path"`
AuthTypes []string `json:"auth_types"`
Scopes []string `json:"scopes"`
Params []approvalSchemaJSONParam `json:"params"`
ResolvedOutputSchema approvalSchemaJSONResolvedSchema `json:"resolved_output_schema"`
}
type approvalSchemaJSONParam struct {
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
SubscriptionKey bool `json:"subscription_key"`
}
type approvalSchemaJSONResolvedSchema struct {
Properties map[string]approvalSchemaJSONProperty `json:"properties"`
}
type approvalSchemaJSONProperty struct {
Format string `json:"format"`
}
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
@@ -158,6 +181,60 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
}
}
func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) {
tests := []struct {
key string
scope string
}{
{"approval.instance.status_changed_v4", "approval:instance:read"},
{"approval.task.status_changed_v4", "approval:task:read"},
}
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, tc.key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload approvalSchemaJSONPayload
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload.JQRootPath != "." {
t.Errorf("jq_root_path = %v, want .", payload.JQRootPath)
}
if got := payload.AuthTypes; !reflect.DeepEqual(got, []string{"user"}) {
t.Errorf("auth_types = %#v, want user", got)
}
if got := payload.Scopes; !reflect.DeepEqual(got, []string{tc.scope}) {
t.Errorf("scopes = %#v, want %s", got, tc.scope)
}
if len(payload.Params) != 1 {
t.Fatalf("params = %#v, want one subscription_type param", payload.Params)
}
param := payload.Params[0]
if param.Name != "subscription_type" || param.Type != "multi" || param.Required || param.SubscriptionKey {
t.Fatalf("subscription_type param = %#v, want optional multi non-subscription-key param", param)
}
props := payload.ResolvedOutputSchema.Properties
for _, field := range []string{"type", "event_id", "timestamp", "approval_code", "instance_code", "status", "operate_time"} {
if _, ok := props[field]; !ok {
t.Errorf("approval schema missing flat field %q: %+v", field, props)
}
}
if _, ok := props["event"]; ok {
t.Errorf("approval Custom schema should be flat, got envelope field event: %+v", props)
}
if got := props["operate_time"].Format; got != "timestamp_ms" {
t.Errorf("operate_time format = %v, want timestamp_ms", got)
}
})
}
}
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",

67
cmd/flag_gate.go Normal file
View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/internal/surface"
)
// globalFlagTargets maps each root persistent flag to the command capability
// it belongs to. A new domain-tied global flag must add a row.
var globalFlagTargets = map[string]surface.CommandID{
"profile": surface.CommandProfile,
}
// flagGateAnnotation distinguishes a surface-retired flag from one hidden
// cosmetically (single-app mode force-shows the latter in root help).
const flagGateAnnotation = "lark:surface_concealed_flag"
// applyPluginFlagGate hides and rejects global flags whose exact command
// capability is absent from this build. It is called only by the explicit
// distribution presentation pass.
func applyPluginFlagGate(root *cobra.Command, plan *surface.Plan) {
for flagName, target := range globalFlagTargets {
if plan.CanReference(target) {
continue
}
fl := root.PersistentFlags().Lookup(flagName)
if fl == nil {
continue
}
fl.Hidden = true
if fl.Annotations == nil {
fl.Annotations = map[string][]string{}
}
fl.Annotations[flagGateAnnotation] = []string{"true"}
fl.Value = &gatedFlagValue{name: flagName, inner: fl.Value}
}
}
func isPolicyGatedFlag(fl *pflag.Flag) bool {
return fl != nil && fl.Annotations[flagGateAnnotation] != nil
}
// gatedFlagValue rejects at parse time, before cobra's help/version fast
// paths (which never reach PersistentPreRunE). Its Set error carries
// cobra's own unknown-flag wording so the root FlagErrorFunc classifies it
// as an ordinary unknown flag without exposing policy state. Cobra may add
// different parse context on root/group paths than on leaf commands.
type gatedFlagValue struct {
name string
inner pflag.Value
}
func (g *gatedFlagValue) String() string { return g.inner.String() }
func (g *gatedFlagValue) Type() string { return g.inner.Type() }
func (g *gatedFlagValue) Set(string) error {
// Intermediate parse error, not a final envelope: pflag wraps it and
// the root FlagErrorFunc (flagDidYouMean) converts it to the typed
// unknown-flag validation error.
return errors.New("unknown flag: --" + g.name) //nolint:forbidigo // intermediate parse error; flagDidYouMean emits the typed envelope
}

View File

@@ -23,7 +23,7 @@ func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
Skill: "lark-sheets",
})
got := composePendingNotice()
got := composePendingNotice(nil)
if got == nil {
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
}
@@ -51,7 +51,7 @@ func TestComposePendingNoticeEmpty(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
if got := composePendingNotice(); got != nil {
if got := composePendingNotice(nil); 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 {

View File

@@ -35,7 +35,10 @@ const userPolicyFileName = "policy.yml"
//
// pluginRules carries Plugin.Restrict() contributions collected from
// the InstallAll phase; nil/empty is fine.
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
//
// The returned denied map (nil when no rule denied anything) feeds the
// optional, build-local distribution presentation pass in build.go.
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) (map[string]cmdpolicy.Denial, error) {
// Plugin rules shadow the yaml source entirely (Resolve: plugin >
// yaml). When a plugin contributed rules we therefore do NOT even
// read ~/.lark-cli/policy.yml: build.go fail-CLOSES on any policy
@@ -65,7 +68,7 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
// show` reports "no policy" instead of a stale rule that
// doesn't reflect the current command tree.
cmdpolicy.SetActive(nil)
return lerr
return nil, lerr
}
yamlRules = loaded
}
@@ -77,11 +80,11 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
})
if err != nil {
cmdpolicy.SetActive(nil)
return err
return nil, err
}
if len(rules) == 0 {
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
return nil
return nil, nil
}
// RuleName attributes a denial to a specific rule in the envelope.
@@ -100,11 +103,12 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
cmdpolicy.Apply(rootCmd, denied)
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rules: rules,
Source: source,
DeniedPaths: len(denied),
Rules: rules,
Source: source,
DeniedByPath: denied,
})
return nil
return denied, nil
}
// installPluginsAndHooks runs the InstallAll phase on the globally-
@@ -156,7 +160,22 @@ func recordInventory(installResult *internalplatform.InstallResult) {
AllowUnannotated: r.Rule.AllowUnannotated,
})
}
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs))
skillSrcs := make([]internalplatform.SkillsInventorySource, 0, len(installResult.PluginSkills))
for _, ps := range installResult.PluginSkills {
if ps.SkillsOverlay == nil {
continue
}
skillSrcs = append(skillSrcs, internalplatform.SkillsInventorySource{
PluginName: ps.PluginName,
View: internalplatform.SkillsOverlayView{
Allow: ps.SkillsOverlay.Allow,
Remove: ps.SkillsOverlay.Remove,
Overlay: ps.SkillsOverlay.Overlay != nil,
Base: ps.SkillsOverlay.Base != nil,
},
})
}
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs, skillSrcs))
}
// wireHooks installs Observer/Wrapper hooks onto every runnable command
@@ -167,7 +186,20 @@ func wireHooks(ctx context.Context, rootCmd *cobra.Command, reg *hook.Registry)
if reg == nil {
return nil
}
hook.Install(rootCmd, reg, cobraCommandViewSource{})
installHooks(rootCmd, reg)
return emitStartup(ctx, reg)
}
func installHooks(rootCmd *cobra.Command, reg *hook.Registry) {
if reg != nil {
hook.Install(rootCmd, reg, cobraCommandViewSource{})
}
}
func emitStartup(ctx context.Context, reg *hook.Registry) error {
if reg == nil {
return nil
}
return hook.Emit(ctx, reg, platform.Startup, nil)
}

View File

@@ -116,7 +116,7 @@ max_risk: write
`)
root := fakeTree(t)
if err := applyUserPolicyPruning(root, nil); err != nil {
if _, err := applyUserPolicyPruning(root, nil); err != nil {
t.Fatalf("apply policy: %v", err)
}
@@ -175,7 +175,7 @@ func TestApplyUserPolicyPruning_missingFileIsSilent(t *testing.T) {
tmpHome(t) // home set but no policy.yml written
root := fakeTree(t)
if err := applyUserPolicyPruning(root, nil); err != nil {
if _, err := applyUserPolicyPruning(root, nil); err != nil {
t.Fatalf("missing policy should not error, got %v", err)
}
@@ -196,7 +196,7 @@ func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
writePolicy(t, cfgDir, "::: not yaml :::")
root := fakeTree(t)
err := applyUserPolicyPruning(root, nil)
_, err := applyUserPolicyPruning(root, nil)
if err == nil {
t.Fatalf("malformed yaml should produce an error")
}
@@ -221,7 +221,7 @@ func TestApplyUserPolicyPruning_pluginRulesSkipBrokenYaml(t *testing.T) {
}},
}
root := fakeTree(t)
if err := applyUserPolicyPruning(root, pluginRules); err != nil {
if _, err := applyUserPolicyPruning(root, pluginRules); err != nil {
t.Fatalf("plugin rules must shadow (and skip reading) yaml; broken yaml should not error, got %v", err)
}
@@ -243,7 +243,7 @@ func TestApplyUserPolicyPruning_invalidRuleReturnsError(t *testing.T) {
writePolicy(t, cfgDir, "max_risk: nukem\n")
root := fakeTree(t)
err := applyUserPolicyPruning(root, nil)
_, err := applyUserPolicyPruning(root, nil)
if err == nil {
t.Fatalf("invalid MaxRisk should produce an error")
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/hook"
internalplatform "github.com/larksuite/cli/internal/platform"
"github.com/larksuite/cli/internal/skillpolicy"
)
// installFatalGuard wires a fail-closed guard at every cobra dispatch
@@ -110,6 +111,33 @@ func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
installFatalGuard(rootCmd, makeErr)
}
// installPluginSkillErrorGuard surfaces a plugin SkillsOverlay configuration
// error before any command runs. Two failure modes, split by reason code:
//
// - "invalid_skills_overlay" - a Remove/Overlay that cannot compose
// - "multiple_skills_overlay_plugins" - two plugins each customizing skills
//
// The CLI must NOT silently fall back to default skills once an
// integrator has declared a customization.
func installPluginSkillErrorGuard(rootCmd *cobra.Command, err error) {
makeErr := func() error {
reasonCode := internalplatform.ReasonInvalidSkillsOverlay
if errors.Is(err, skillpolicy.ErrMultipleSkillsOverlays) {
reasonCode = internalplatform.ReasonMultipleSkillsOverlays
}
typed := errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
WithCause(err)
if errors.Is(err, skillpolicy.ErrNoBaseSkillContent) {
return typed.WithHint("this build embeds no base skill content; call cmd.SetEmbeddedSkillContent before Execute or provide a non-empty EmbeddedSkills.Base (reason_code %s)", reasonCode)
}
if errors.Is(err, skillpolicy.ErrInvalidHostBase) {
return typed.WithHint("the wrapper's embedded base skill tree is invalid; fix the content passed to cmd.SetEmbeddedSkillContent (reason_code %s)", reasonCode)
}
return typed.WithHint("skill customization is broken (reason_code %s); fix the plugin's EmbeddedSkills configuration or remove the conflicting plugin", reasonCode)
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
// failure as a typed validation error (failed_precondition). The hint's
// reason code splits returned-error vs panic so consumers (audit /

326
cmd/presentation.go Normal file
View File

@@ -0,0 +1,326 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
configcmd "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/surface"
)
const annotationUnavailableMessage = "lark:presentation_unavailable_message"
type projectedCommand struct {
state surface.CommandState
denial cmdpolicy.Denial
}
// presentationProjection keeps one distribution's build-time presentation
// state and denial provenance together, so a concealed command retains the
// cause installed on its unavailable projection.
type presentationProjection struct {
commands map[surface.CommandID]projectedCommand
}
func newPresentationProjection(denied map[string]cmdpolicy.Denial) *presentationProjection {
projection := &presentationProjection{
commands: make(map[surface.CommandID]projectedCommand, len(denied)),
}
for path, denial := range denied {
projection.commands[surface.CommandID(path)] = projectedCommand{
state: surface.CommandDeniedVisible,
denial: denial,
}
}
return projection
}
func (p *presentationProjection) recordConcealed(path string, denial cmdpolicy.Denial) {
p.commands[surface.CommandID(path)] = projectedCommand{
state: surface.CommandConcealed,
denial: denial,
}
}
func (p *presentationProjection) denial(path string) (cmdpolicy.Denial, bool) {
command, ok := p.commands[surface.CommandID(path)]
if !ok || command.state != surface.CommandConcealed {
return cmdpolicy.Denial{}, false
}
return command.denial, true
}
func (p *presentationProjection) plan() *surface.Plan {
states := make(map[surface.CommandID]surface.CommandState, len(p.commands))
for id, command := range p.commands {
states[id] = command.state
}
return surface.NewPlan(states)
}
func (p *presentationProjection) hasConcealedCommands() bool {
for _, command := range p.commands {
if command.state == surface.CommandConcealed {
return true
}
}
return false
}
// applyDistributionPresentation projects enforcement decisions onto the
// command surface of this one build. Enforcement has already installed its
// policy-rich deny stubs. Without an explicit presentation option, those stubs
// and their legacy help/completion behavior are left untouched.
func applyDistributionPresentation(
root *cobra.Command,
cfg restrictionPresentationConfig,
denied map[string]cmdpolicy.Denial,
) (*surface.Plan, bool) {
projection := newPresentationProjection(denied)
if !cfg.enabled {
return projection.plan(), false
}
collectPluginConcealments(root, denied, projection)
if cfg.hidePolicyDiagnostics {
collectDiagnosticConcealments(root, projection)
}
propagateConcealedPureGroups(root, projection)
installUnavailableProjections(root, projection, cfg.effectiveUnavailableMessage())
plan := projection.plan()
applyPresentationAffordances(root, plan)
return plan, projection.hasConcealedCommands()
}
func collectPluginConcealments(
root *cobra.Command,
denied map[string]cmdpolicy.Denial,
projection *presentationProjection,
) {
for path, denial := range denied {
if !cmdpolicy.IsPluginPolicySource(denial.PolicySource) {
continue
}
cmd := findByPath(root, path)
if cmd == nil || commandDenialLayer(cmd) == cmdpolicy.LayerStrictMode {
continue
}
projection.recordConcealed(path, denial)
}
}
func collectDiagnosticConcealments(
root *cobra.Command,
projection *presentationProjection,
) {
for _, path := range cmdpolicy.DiagnosticPaths() {
if findByPath(root, path) == nil {
continue
}
projection.recordConcealed(path, cmdpolicy.Denial{
Layer: cmdpolicy.LayerPolicy,
PolicySource: "distribution:presentation",
ReasonCode: "diagnostics_concealed",
Reason: "policy diagnostics concealed by the distribution",
})
}
}
func propagateConcealedPureGroups(
root *cobra.Command,
projection *presentationProjection,
) {
// A pure parent becomes absent only when every live child is absent. Repeat
// bottom-up until all newly-empty intermediate groups converge.
for {
changed := false
plan := projection.plan()
walkCommandsPostOrder(root, func(cmd *cobra.Command) {
path, denial, ok := concealedPureGroup(cmd, plan, projection)
if !ok {
return
}
projection.recordConcealed(path, denial)
changed = true
})
if !changed {
break
}
}
}
func concealedPureGroup(
cmd *cobra.Command,
plan *surface.Plan,
projection *presentationProjection,
) (string, cmdpolicy.Denial, bool) {
path := cmdpolicy.CanonicalPath(cmd)
if !cmd.HasParent() || !isPresentationPureGroup(cmd) ||
plan.IsConcealed(surface.CommandID(path)) {
return "", cmdpolicy.Denial{}, false
}
children := cmd.Commands()
if len(children) == 0 {
return "", cmdpolicy.Denial{}, false
}
var cause cmdpolicy.Denial
for _, child := range children {
childPath := cmdpolicy.CanonicalPath(child)
if !plan.IsConcealed(surface.CommandID(childPath)) {
return "", cmdpolicy.Denial{}, false
}
if denial, ok := projection.denial(childPath); ok && cause.Layer == "" {
cause = denial
}
}
if cause.Layer == "" {
cause = cmdpolicy.Denial{
Layer: cmdpolicy.LayerPolicy,
PolicySource: "distribution:presentation",
ReasonCode: "all_children_concealed",
Reason: "all child commands are concealed",
}
}
return path, cause, true
}
func installUnavailableProjections(
root *cobra.Command,
projection *presentationProjection,
message string,
) {
for id, command := range projection.commands {
if command.state != surface.CommandConcealed {
continue
}
path := string(id)
if cmd := findByPath(root, path); cmd != nil {
installUnavailableProjection(cmd, path, command.denial, message)
}
}
}
func applyPresentationAffordances(root *cobra.Command, plan *surface.Plan) {
applyPluginFlagGate(root, plan)
configcmd.ProjectInitHelp(
findByPath(root, string(surface.CommandConfigInit)),
plan.CanReference(surface.CommandConfigBind),
)
root.Long = renderRootHelpSections(rootLongSections, plan)
root.SetUsageTemplate(renderRootUsageTemplate(plan))
}
func commandDenialLayer(cmd *cobra.Command) string {
if cmd == nil || cmd.Annotations == nil {
return ""
}
return cmd.Annotations[cmdpolicy.AnnotationDenialLayer]
}
func isPresentationPureGroup(cmd *cobra.Command) bool {
if cmd == nil {
return false
}
return (cmd.Run == nil && cmd.RunE == nil) || cmdpolicy.IsPureGroup(cmd)
}
func walkCommandsPostOrder(cmd *cobra.Command, visit func(*cobra.Command)) {
for _, child := range cmd.Commands() {
walkCommandsPostOrder(child, visit)
}
visit(cmd)
}
// installUnavailableProjection changes presentation only. It preserves the
// enforcement denial as the in-process cause when one exists, while the wire
// intentionally exposes no policy source, rule name, or reason code.
func installUnavailableProjection(cmd *cobra.Command, path string, denial cmdpolicy.Denial, message string) {
cmd.Hidden = true
cmd.DisableFlagParsing = true
cmd.Args = cobra.ArbitraryArgs
cmd.PersistentPreRunE = func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
}
cmd.PersistentPreRun = nil
cmd.PreRunE = nil
cmd.PreRun = nil
hideFlags := func(flags *pflag.FlagSet) {
flags.VisitAll(func(flag *pflag.Flag) {
flag.Hidden = true
})
}
// Hide only flags owned by this command. cmd.Flags() may contain inherited
// flag pointers after Cobra merges sets; mutating those would hide a global
// flag from unrelated commands.
hideFlags(cmd.LocalNonPersistentFlags())
hideFlags(cmd.PersistentFlags())
cmd.ValidArgs = nil
cmd.ValidArgsFunction = func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveNoFileComp
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[annotationUnavailableMessage] = message
if cmd.Annotations[cmdpolicy.AnnotationDenialLayer] == "" {
cmd.Annotations[cmdpolicy.AnnotationDenialLayer] = denial.Layer
cmd.Annotations[cmdpolicy.AnnotationDenialSource] = denial.PolicySource
}
cmd.RunE = func(*cobra.Command, []string) error {
err := errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", message)
if denial.Layer != "" {
err.WithCause(cmdpolicy.CommandDeniedFromDenial(path, denial))
}
return err
}
cmd.Run = nil
}
// unavailableHelpMessage is deliberately keyed only by the opt-in projection
// annotation. A legacy Restrict denial carries enforcement annotations but
// continues to use Cobra's stock explicit-help behavior.
func unavailableHelpMessage(cmd *cobra.Command) (string, bool) {
for current := cmd; current != nil; current = current.Parent() {
if current.Annotations == nil {
continue
}
if message := current.Annotations[annotationUnavailableMessage]; message != "" {
return message, true
}
}
return "", false
}
// findByPath resolves a canonical slash path (for example
// "config/policy/show") to a command node.
func findByPath(root *cobra.Command, path string) *cobra.Command {
cur := root
for _, segment := range strings.Split(path, "/") {
var next *cobra.Command
for _, child := range cur.Commands() {
if child.Name() == segment {
next = child
break
}
}
if next == nil {
return nil
}
cur = next
}
return cur
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
// defaultRestrictedCommandUnavailableMessage is the distribution-neutral
// fallback for a concealed command. It lives in the presentation layer rather
// than extension/platform.Rule: the same enforcement rule may be rendered as a
// visible policy denial by one host and as an absent capability by another.
const defaultRestrictedCommandUnavailableMessage = "command not included in this build"
// restrictionPresentationConfig is a per-Build snapshot. It is deliberately
// private so adding a future presentation knob cannot break downstream
// unkeyed struct literals.
type restrictionPresentationConfig struct {
enabled bool
unavailableMessage string
hidePolicyDiagnostics bool
}
func (c restrictionPresentationConfig) effectiveUnavailableMessage() string {
if c.unavailableMessage != "" {
return c.unavailableMessage
}
return defaultRestrictedCommandUnavailableMessage
}
// RestrictionPresentationOption configures the presentation of commands
// denied by an embedded distribution's Restrict plugin.
//
// Values are accepted only by ConcealRestrictedCommands. The pointed-to
// configuration type is private by design; callers use the constructors in
// this file instead of depending on a public struct layout.
type RestrictionPresentationOption func(*restrictionPresentationConfig)
// ConcealRestrictedCommands opts one command tree into presenting
// plugin-restricted commands as capabilities absent from the distribution.
//
// Restrict remains the enforcement boundary. Without this BuildOption,
// existing Restrict plugins keep their established failed_precondition
// envelope, explicit-help, and completion behavior.
//
// Pass the returned option to Build, or to ExecuteWithOptions when using the
// standard host entrypoint.
func ConcealRestrictedCommands(opts ...RestrictionPresentationOption) BuildOption {
presentation := restrictionPresentationConfig{enabled: true}
for _, opt := range opts {
if opt != nil {
opt(&presentation)
}
}
return func(cfg *buildConfig) {
cfg.presentation = presentation
}
}
// UnavailableMessage customizes the error message for a concealed command.
// An empty message selects the distribution-neutral default.
func UnavailableMessage(message string) RestrictionPresentationOption {
return func(cfg *restrictionPresentationConfig) {
cfg.unavailableMessage = message
}
}
// HidePolicyDiagnostics removes the policy self-inspection commands from a
// concealed distribution. Without it, those commands remain the operator's
// recovery and inspection escape hatch.
func HidePolicyDiagnostics() RestrictionPresentationOption {
return func(cfg *restrictionPresentationConfig) {
cfg.hidePolicyDiagnostics = true
}
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import "testing"
// Preserve callers that store the original entrypoint as a function value.
// Making Execute variadic would compile at ordinary call sites but break this
// established source contract.
var _ func() int = Execute
func TestConcealRestrictedCommandsDefaults(t *testing.T) {
cfg := &buildConfig{}
ConcealRestrictedCommands()(cfg)
if !cfg.presentation.enabled {
t.Fatal("concealment must be explicitly enabled by the BuildOption")
}
if cfg.presentation.hidePolicyDiagnostics {
t.Fatal("policy diagnostics must remain available by default")
}
if got := cfg.presentation.effectiveUnavailableMessage(); got != defaultRestrictedCommandUnavailableMessage {
t.Errorf("message = %q, want %q", got, defaultRestrictedCommandUnavailableMessage)
}
}
func TestConcealRestrictedCommandsOptions(t *testing.T) {
cfg := &buildConfig{}
ConcealRestrictedCommands(
UnavailableMessage("not part of acme-cli"),
HidePolicyDiagnostics(),
)(cfg)
if !cfg.presentation.enabled {
t.Fatal("concealment must be enabled")
}
if !cfg.presentation.hidePolicyDiagnostics {
t.Fatal("HidePolicyDiagnostics option was not applied")
}
if got := cfg.presentation.effectiveUnavailableMessage(); got != "not part of acme-cli" {
t.Errorf("message = %q, want custom message", got)
}
}
func TestConcealRestrictedCommandsIsBuildLocal(t *testing.T) {
concealed := &buildConfig{}
ordinary := &buildConfig{}
ConcealRestrictedCommands(
UnavailableMessage("acme only"),
HidePolicyDiagnostics(),
)(concealed)
if ordinary.presentation.enabled {
t.Fatal("applying an option to one build must not enable another")
}
if ordinary.presentation.hidePolicyDiagnostics {
t.Fatal("applying an option to one build must not mutate another")
}
if got := ordinary.presentation.effectiveUnavailableMessage(); got != defaultRestrictedCommandUnavailableMessage {
t.Errorf("ordinary message = %q, want default", got)
}
}
func TestUnavailableMessageEmptyUsesDefault(t *testing.T) {
cfg := &buildConfig{}
ConcealRestrictedCommands(UnavailableMessage(""))(cfg)
if got := cfg.presentation.effectiveUnavailableMessage(); got != defaultRestrictedCommandUnavailableMessage {
t.Errorf("message = %q, want %q", got, defaultRestrictedCommandUnavailableMessage)
}
}

757
cmd/presentation_test.go Normal file
View File

@@ -0,0 +1,757 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"context"
"errors"
"os"
"strings"
"sync"
"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/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/surface"
"github.com/larksuite/cli/internal/update"
)
func registerRestriction(t *testing.T, deny []string, configure func(*platform.Builder) *platform.Builder) {
t.Helper()
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
builder := platform.NewPlugin("acme", "1.0").
Restrict(&platform.Rule{Deny: deny})
if configure != nil {
builder = configure(builder)
}
platform.Register(builder.MustBuild())
}
func TestBuildInternalRestrictDefaultPreservesLegacyContract(t *testing.T) {
tmpHome(t)
registerRestriction(t, []string{"skills/read"}, nil)
runtime, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
leaf := findByPath(root, "skills/read")
if leaf == nil {
t.Fatal("skills/read not found")
}
if got := runtime.surface.State(surface.CommandSkillsRead); got != surface.CommandDeniedVisible {
t.Fatalf("surface state = %v, want denied-visible", got)
}
if _, projected := unavailableHelpMessage(leaf); projected {
t.Fatal("legacy Restrict unexpectedly received concealment presentation")
}
err := leaf.RunE(leaf, nil)
var validation *errs.ValidationError
if !errors.As(err, &validation) {
t.Fatalf("RunE error = %T %v, want ValidationError", err, err)
}
if validation.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", validation.Subtype)
}
if !strings.Contains(validation.Hint, "source plugin:acme") ||
!strings.Contains(validation.Hint, "reason_code") {
t.Errorf("legacy policy metadata missing from hint: %q", validation.Hint)
}
if flag := leaf.Flags().Lookup("json"); flag == nil || flag.Hidden {
t.Errorf("legacy Restrict must preserve local flag presentation; flag=%+v", flag)
}
var help bytes.Buffer
root.SetOut(&help)
root.SetErr(&help)
if err := root.Help(); err != nil {
t.Fatal(err)
}
for _, title := range []string{"Lark domains:", "Agent tooling:", "CLI management:"} {
if !strings.Contains(help.String(), title) {
t.Errorf("default root help lost group %q", title)
}
}
}
func TestBuildInternalConcealmentIsExplicitAndKeepsDenialAsCause(t *testing.T) {
tmpHome(t)
registerRestriction(t, []string{"skills/read"}, nil)
runtime, root, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
ConcealRestrictedCommands(UnavailableMessage("not shipped by acme")),
)
leaf := findByPath(root, "skills/read")
if got := runtime.surface.State(surface.CommandSkillsRead); got != surface.CommandConcealed {
t.Fatalf("surface state = %v, want concealed", got)
}
err := leaf.RunE(leaf, nil)
var validation *errs.ValidationError
if !errors.As(err, &validation) {
t.Fatalf("RunE error = %T %v, want ValidationError", err, err)
}
if validation.Subtype != errs.SubtypeCommandUnavailable ||
validation.Message != "not shipped by acme" || validation.Hint != "" {
t.Errorf("concealed error = %+v", validation)
}
var denied *platform.CommandDeniedError
if !errors.As(err, &denied) || denied.Path != "skills/read" ||
denied.PolicySource != "plugin:acme" {
t.Errorf("enforcement cause not preserved: %T %+v", err, denied)
}
if flag := leaf.Flags().Lookup("json"); flag == nil || !flag.Hidden {
t.Errorf("concealed command must hide owned flags; flag=%+v", flag)
}
if flag := root.PersistentFlags().Lookup("profile"); flag == nil || flag.Hidden {
t.Errorf("concealing a leaf must not mutate inherited global flags; flag=%+v", flag)
}
if args, _ := leaf.ValidArgsFunction(leaf, nil, ""); len(args) != 0 {
t.Errorf("concealed command completed positionals: %v", args)
}
help := findByPath(root, "help")
if help == nil || help.RunE == nil {
t.Fatal("concealment-specific help command not installed")
}
err = help.RunE(help, []string{"skills", "read"})
if !errors.As(err, &validation) || validation.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("help on concealed command = %v, want command_unavailable", err)
}
active := cmdpolicy.GetActive()
if active == nil || active.DeniedByPath["skills/read"].PolicySource != "plugin:acme" {
t.Fatalf("presentation overwrote enforcement snapshot: %+v", active)
}
}
func TestDistributionPresentationNeverConcealsYAMLPolicy(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
leaf := &cobra.Command{Use: "probe", RunE: func(*cobra.Command, []string) error { return nil }}
root.AddCommand(leaf)
denial := cmdpolicy.Denial{
Layer: cmdpolicy.LayerPolicy,
PolicySource: "yaml:/tmp/policy.yml",
ReasonCode: "command_denylisted",
Reason: "denied by user policy",
}
denied := map[string]cmdpolicy.Denial{"probe": denial}
cmdpolicy.Apply(root, denied)
plan, concealed := applyDistributionPresentation(
root,
restrictionPresentationConfig{enabled: true},
denied,
)
if concealed {
t.Fatal("user-owned YAML denial must not be projected as absent")
}
if got := plan.State("probe"); got != surface.CommandDeniedVisible {
t.Fatalf("surface state = %v, want denied-visible", got)
}
err := leaf.RunE(leaf, nil)
var validation *errs.ValidationError
if !errors.As(err, &validation) || validation.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("YAML denial changed by distribution presentation: %v", err)
}
}
func TestRootGroupsFollowSurfaceConcealmentNotLegacyHiddenState(t *testing.T) {
newRoot := func() *cobra.Command {
root := &cobra.Command{Use: "lark-cli"}
child := &cobra.Command{
Use: "skills",
GroupID: groupTooling,
RunE: func(*cobra.Command, []string) error { return nil },
}
root.AddCommand(child)
return root
}
yamlRoot := newRoot()
yamlChild := findByPath(yamlRoot, "skills")
yamlChild.Hidden = true
finalizeRootCommandGroups(yamlRoot, surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandSkills: surface.CommandDeniedVisible,
}))
if len(yamlRoot.Groups()) != 1 || yamlRoot.Groups()[0].ID != groupTooling {
t.Fatalf("legacy/YAML hidden command removed its group: %+v", yamlRoot.Groups())
}
concealedRoot := newRoot()
finalizeRootCommandGroups(concealedRoot, surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandSkills: surface.CommandConcealed,
}))
if len(concealedRoot.Groups()) != 0 {
t.Fatalf("concealed-only group remained visible: %+v", concealedRoot.Groups())
}
if got := findByPath(concealedRoot, "skills").GroupID; got != "" {
t.Fatalf("concealed child retained undefined GroupID %q", got)
}
}
func TestPresentationDropsRootSkillsFooterWithSkillsRead(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
root.SetUsageTemplate(rootUsageTemplate)
applyPresentationAffordances(root, surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandSkillsRead: surface.CommandConcealed,
}))
if strings.Contains(root.UsageTemplate(), "Skills setup (one-time, humans)") {
t.Fatalf("concealed skills/read left the root skills footer:\n%s", root.UsageTemplate())
}
}
func TestPresentationProjectsEveryFrameworkOwnedRootHelpTarget(t *testing.T) {
root := &cobra.Command{Use: "lark-cli", Long: rootLong}
root.SetUsageTemplate(rootUsageTemplate)
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
rootHelpAPI: surface.CommandConcealed,
surface.CommandSchema: surface.CommandConcealed,
rootHelpCalendarAgenda: surface.CommandConcealed,
rootHelpMailList: surface.CommandConcealed,
})
applyPresentationAffordances(root, plan)
for _, dead := range []string{
"lark-cli api ",
"lark-cli schema ",
"lark-cli calendar +agenda",
"lark-cli mail user_mailbox.messages list",
} {
if strings.Contains(root.Long, dead) || strings.Contains(root.UsageTemplate(), dead) {
t.Errorf("concealed root-help target %q survived:\nLong:\n%s\nTemplate:\n%s",
dead, root.Long, root.UsageTemplate())
}
}
if !strings.Contains(root.Long, "Browse commands:") ||
!strings.Contains(root.UsageTemplate(), "lark-cli <command>") {
t.Fatalf("target-independent root guidance was removed:\nLong:\n%s\nTemplate:\n%s",
root.Long, root.UsageTemplate())
}
}
func TestFrameworkOwnedRootHelpTargetsExistInDefaultTree(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
_, root, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
WithoutPlugins(),
)
var fragments []rootHelpFragment
for _, section := range rootLongSections {
fragments = append(fragments, section.fragments...)
}
fragments = append(fragments, rootUsageSynopsis...)
for _, fragment := range fragments {
if fragment.target == "" {
continue
}
if command := findByPath(root, string(fragment.target)); command == nil {
t.Errorf("root-help target %q does not resolve in the default command tree", fragment.target)
}
}
}
func TestPresentationKeepsDefaultRootHelpByteStable(t *testing.T) {
root := &cobra.Command{Use: "lark-cli", Long: rootLong}
root.SetUsageTemplate(rootUsageTemplate)
wantLong, wantUsage := root.Long, root.UsageTemplate()
applyPresentationAffordances(root, nil)
if root.Long != wantLong {
t.Fatalf("default root Long changed:\nwant:\n%s\n\ngot:\n%s", wantLong, root.Long)
}
if root.UsageTemplate() != wantUsage {
t.Fatalf("default root usage template changed:\nwant:\n%s\n\ngot:\n%s", wantUsage, root.UsageTemplate())
}
}
func TestHelpRejectsDescendantOfConcealedParent(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
parent := &cobra.Command{Use: "apps"}
child := &cobra.Command{Use: "+db-execute", RunE: func(*cobra.Command, []string) error { return nil }}
parent.AddCommand(child)
root.AddCommand(parent)
installUnavailableProjection(parent, "apps", cmdpolicy.Denial{}, "not shipped")
installHelpCommand(root)
help := findByPath(root, "help")
if help == nil || help.RunE == nil {
t.Fatal("help command not installed")
}
err := help.RunE(help, []string{"apps", "+db-execute"})
var validation *errs.ValidationError
if !errors.As(err, &validation) ||
validation.Subtype != errs.SubtypeCommandUnavailable ||
validation.Message != "not shipped" {
t.Fatalf("help descendant error = %#v, want command_unavailable inherited from parent", err)
}
}
func TestHidePolicyDiagnosticsIsHostPresentationOnly(t *testing.T) {
tmpHome(t)
registerRestriction(t, []string{"config/**"}, nil)
runtime, root, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
ConcealRestrictedCommands(HidePolicyDiagnostics()),
)
for _, path := range []string{
"config",
"config/policy",
"config/policy/show",
"config/plugins",
"config/plugins/show",
} {
cmd := findByPath(root, path)
if cmd == nil || cmd.RunE == nil {
t.Fatalf("%s missing unavailable projection", path)
}
err := cmd.RunE(cmd, nil)
var validation *errs.ValidationError
if !errors.As(err, &validation) ||
validation.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("%s error = %v, want command_unavailable", path, err)
}
if !runtime.surface.IsConcealed(surface.CommandID(path)) {
t.Errorf("%s not recorded in build-local surface", path)
}
}
// Synthetic presentation decisions must not be reported as policy facts.
active := cmdpolicy.GetActive()
if active == nil {
t.Fatal("missing active enforcement policy")
}
if _, exists := active.DeniedByPath["config/policy/show"]; exists {
t.Errorf("presentation-only diagnostic concealment leaked into ActivePolicy: %+v", active)
}
}
func TestConcealedBuildOmitsEmptyRootGroup(t *testing.T) {
tmpHome(t)
registerRestriction(t, []string{
"auth", "auth/**",
"config", "config/**",
"profile", "profile/**",
"doctor",
"update",
}, nil)
_, root, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
ConcealRestrictedCommands(HidePolicyDiagnostics()),
)
var help bytes.Buffer
root.SetOut(&help)
root.SetErr(&help)
if err := root.Help(); err != nil {
t.Fatal(err)
}
if strings.Contains(help.String(), "CLI management:") {
t.Errorf("empty management group leaked into help:\n%s", help.String())
}
if !strings.Contains(help.String(), "Agent tooling:") {
t.Errorf("non-empty tooling group disappeared:\n%s", help.String())
}
// Cobra's Execute path validates GroupID definitions before parsing flags.
// Calling root.Help directly does not exercise this invariant.
help.Reset()
root.SetOut(&help)
root.SetErr(&help)
root.SetArgs([]string{"--help"})
if err := root.Execute(); err != nil {
t.Fatalf("concealed root Execute --help: %v", err)
}
if strings.Contains(help.String(), "CLI management:") {
t.Errorf("empty management group leaked through Execute:\n%s", help.String())
}
}
func TestRecoveryRenderingUsesExactBuildLocalSurfaceAndDoesNotMutate(t *testing.T) {
tmpHome(t)
previousWorkspace := core.CurrentWorkspace()
core.SetCurrentWorkspace(core.WorkspaceLocal)
t.Cleanup(func() { core.SetCurrentWorkspace(previousWorkspace) })
registerRestriction(t, []string{"config/init"}, nil)
concealedRuntime, _, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
ConcealRestrictedCommands(),
)
platform.ResetForTesting()
defaultRuntime, _, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
WithoutPlugins(),
)
if concealedRuntime.surface.CanReference(surface.CommandConfigInit) {
t.Fatal("config/init should be concealed")
}
if !concealedRuntime.surface.CanReference(surface.CommandConfigStrictMode) {
t.Fatal("exact leaf concealment incorrectly removed config/strict-mode")
}
original := core.NotConfiguredError()
originalProblem, ok := errs.ProblemOf(original)
if !ok || originalProblem.Hint == "" {
t.Fatalf("invalid test error: %v", original)
}
wantHint := originalProblem.Hint
concealed := concealedRuntime.recovery.Render(original)
concealedProblem, _ := errs.ProblemOf(concealed)
if strings.Contains(concealedProblem.Hint, "config init") ||
!strings.Contains(concealedProblem.Hint, "configure this distribution") {
t.Errorf("concealed tree did not use target-free recovery fallback: %q", concealedProblem.Hint)
}
if originalProblem.Hint != wantHint {
t.Fatalf("rendering mutated source hint: %q -> %q", wantHint, originalProblem.Hint)
}
visible := defaultRuntime.recovery.Render(original)
visibleProblem, _ := errs.ProblemOf(visible)
if visibleProblem.Hint != wantHint {
t.Errorf("default tree lost recovery after second Build: %q", visibleProblem.Hint)
}
}
func TestConcurrentBuildsKeepIndependentSurfacePlans(t *testing.T) {
tmpHome(t)
registerRestriction(t, []string{"config/init"}, nil)
inv := buildInvocationForTest(t)
const pairs = 4
type result struct {
concealed bool
state surface.CommandState
}
results := make(chan result, pairs*2)
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < pairs; i++ {
wg.Add(2)
go func() {
defer wg.Done()
<-start
runtime, _, _ := buildInternal(
context.Background(),
inv,
ConcealRestrictedCommands(),
)
results <- result{concealed: true, state: runtime.surface.State(surface.CommandConfigInit)}
}()
go func() {
defer wg.Done()
<-start
runtime, _, _ := buildInternal(
context.Background(),
inv,
WithoutPlugins(),
)
results <- result{state: runtime.surface.State(surface.CommandConfigInit)}
}()
}
close(start)
wg.Wait()
close(results)
for got := range results {
want := surface.CommandAvailable
if got.concealed {
want = surface.CommandConcealed
}
if got.state != want {
t.Errorf("concealed=%v state=%v, want %v", got.concealed, got.state, want)
}
}
}
func TestUpdateAffordancesDisappearWithoutDroppingIndependentRecovery(t *testing.T) {
update.SetPending(&update.UpdateInfo{Current: "1.0.0", Latest: "2.0.0"})
skillscheck.SetPending(&skillscheck.StaleNotice{Current: "1.0.0", Target: "2.0.0"})
deprecation.SetPending(&deprecation.Notice{
Command: "+read",
Replacement: "+cells-get",
Skill: "lark-sheets",
})
t.Cleanup(func() {
update.SetPending(nil)
skillscheck.SetPending(nil)
deprecation.SetPending(nil)
})
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandUpdate: surface.CommandConcealed,
})
got := composePendingNotice(plan)
if got == nil {
t.Fatal("independent deprecation recovery was dropped")
}
if _, exists := got["update"]; exists {
t.Errorf("update notice survived concealed update: %+v", got)
}
if _, exists := got["skills"]; exists {
t.Errorf("skills drift notice survived concealed update: %+v", got)
}
entry, ok := got["deprecated_command"].(map[string]interface{})
if !ok {
t.Fatalf("missing deprecated_command: %+v", got)
}
if entry["replacement"] != "+cells-get" || entry["skill"] != "lark-sheets" {
t.Errorf("independent deprecation fields lost: %+v", entry)
}
if _, exists := entry["action"]; exists {
t.Errorf("unavailable update action survived: %+v", entry)
}
if strings.Contains(entry["message"].(string), "lark-cli update") {
t.Errorf("dead update pointer survived in message: %+v", entry)
}
}
func TestSetupNoticesDoesNoProviderWorkWhenUpdateIsConcealed(t *testing.T) {
oldCheck, oldRefresh, oldSkills := checkCachedUpdate, refreshUpdateCache, initializeSkillsCheck
oldPending := output.PendingNotice
t.Cleanup(func() {
checkCachedUpdate, refreshUpdateCache, initializeSkillsCheck = oldCheck, oldRefresh, oldSkills
output.PendingNotice = oldPending
})
var checks, refreshes, skillChecks int
checkCachedUpdate = func(string) *update.UpdateInfo {
checks++
return nil
}
refreshUpdateCache = func(string) { refreshes++ }
initializeSkillsCheck = func(string) { skillChecks++ }
setupNotices(surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandUpdate: surface.CommandConcealed,
}))
if checks != 0 || refreshes != 0 || skillChecks != 0 {
t.Fatalf("concealed update performed provider work: cache=%d refresh=%d skills=%d",
checks, refreshes, skillChecks)
}
}
func TestExecuteProfileBootstrapPreservesDefaultAndDefersOnlyForOptIn(t *testing.T) {
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
t.Run("default remains plain exit one", func(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
code, stdout, stderr := executeWithCapturedOS(t, nil, "--profile")
if code != 1 || stdout != "" ||
stderr != "Error: flag needs an argument: --profile\n" {
t.Fatalf("default --profile: exit=%d stdout=%q stderr=%q", code, stdout, stderr)
}
})
t.Run("opt-in concealed profile is an unknown flag", func(t *testing.T) {
tmpHome(t)
registerRestriction(t, []string{"profile", "profile/**"}, nil)
code, _, stderr := executeWithCapturedOS(
t,
[]BuildOption{ConcealRestrictedCommands()},
"--profile",
)
if code != 2 ||
!strings.Contains(stderr, `"subtype": "invalid_argument"`) ||
!strings.Contains(stderr, `unknown flag \"--profile\"`) {
t.Fatalf("concealed --profile: exit=%d stderr=%s", code, stderr)
}
})
}
func TestExecuteWithOptionsAppliesEachBuildOptionOnce(t *testing.T) {
tmpHome(t)
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
var applied int
option := BuildOption(func(*buildConfig) { applied++ })
code, _, stderr := executeWithCapturedOS(t, []BuildOption{option}, "--version")
if code != 0 {
t.Fatalf("--version exit=%d stderr=%s", code, stderr)
}
if applied != 1 {
t.Fatalf("BuildOption applied %d times, want exactly once", applied)
}
}
func TestConcealmentHelpIsOutsideBusinessHooks(t *testing.T) {
tmpHome(t)
var observed, wrapped int
registerRestriction(t, []string{"skills/read"}, func(builder *platform.Builder) *platform.Builder {
return builder.
Observer(platform.Before, "observe", platform.All(), func(context.Context, platform.Invocation) {
observed++
}).
Wrap("wrap", platform.All(), func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv platform.Invocation) error {
wrapped++
return next(ctx, inv)
}
})
})
_, root, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
ConcealRestrictedCommands(),
)
help := findByPath(root, "help")
err := help.RunE(help, []string{"skills", "read"})
var validation *errs.ValidationError
if !errors.As(err, &validation) ||
validation.Subtype != errs.SubtypeCommandUnavailable {
t.Fatalf("help error = %v", err)
}
if observed != 0 || wrapped != 0 {
t.Fatalf("help entered business hooks: observed=%d wrapped=%d", observed, wrapped)
}
}
func TestWrapperCannotSwallowConcealedCommandEnforcement(t *testing.T) {
tmpHome(t)
registerRestriction(t, []string{"skills/read"}, func(builder *platform.Builder) *platform.Builder {
return builder.Wrap("swallow", platform.All(), func(platform.Handler) platform.Handler {
return func(context.Context, platform.Invocation) error { return nil }
})
})
_, root, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
ConcealRestrictedCommands(),
)
leaf := findByPath(root, "skills/read")
err := leaf.RunE(leaf, nil)
var validation *errs.ValidationError
if !errors.As(err, &validation) ||
validation.Subtype != errs.SubtypeCommandUnavailable {
t.Fatalf("wrapper swallowed denial: %v", err)
}
}
func TestConcealedCommandLeavesFlagAndPositionalCompletion(t *testing.T) {
tmpHome(t)
registerRestriction(t, []string{"skills/read"}, nil)
_, root, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
ConcealRestrictedCommands(),
)
for _, args := range [][]string{
{"__complete", "skills", "read", "--"},
{"__complete", "skills", "read", ""},
} {
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(args)
_ = root.Execute()
if strings.Contains(out.String(), "--json") || strings.Contains(out.String(), "lark-") {
t.Errorf("%v exposed concealed completion:\n%s", args, out.String())
}
}
}
func TestApplyStrictStubWinsOverPluginDenial(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
stub := findCmd(root, "auth", "login")
if stub == nil {
t.Fatal("auth/login strict stub missing")
}
cmdpolicy.Apply(root, map[string]cmdpolicy.Denial{
"auth/login": {
Layer: cmdpolicy.LayerPolicy,
PolicySource: "plugin:acme",
},
})
if got := stub.Annotations[cmdpolicy.AnnotationDenialLayer]; got != cmdpolicy.LayerStrictMode {
t.Fatalf("denial layer = %q, want strict_mode", got)
}
err := stub.RunE(stub, nil)
if err == nil || !strings.Contains(err.Error(), "strict mode") {
t.Errorf("double-restricted command lost strict-mode error: %v", err)
}
}
func executeWithCapturedOS(
t *testing.T,
opts []BuildOption,
args ...string,
) (int, string, string) {
t.Helper()
oldArgs, oldStdout, oldStderr := os.Args, os.Stdout, os.Stderr
stdout, err := os.CreateTemp(t.TempDir(), "stdout")
if err != nil {
t.Fatal(err)
}
stderr, err := os.CreateTemp(t.TempDir(), "stderr")
if err != nil {
t.Fatal(err)
}
restored := false
restore := func() {
if restored {
return
}
restored = true
os.Args, os.Stdout, os.Stderr = oldArgs, oldStdout, oldStderr
}
defer restore()
os.Args = append([]string{"e2e-cli"}, args...)
os.Stdout, os.Stderr = stdout, stderr
code := ExecuteWithOptions(opts...)
restore()
if err := stdout.Close(); err != nil {
t.Fatal(err)
}
if err := stderr.Close(); err != nil {
t.Fatal(err)
}
stdoutData, err := os.ReadFile(stdout.Name())
if err != nil {
t.Fatal(err)
}
stderrData, err := os.ReadFile(stderr.Name())
if err != nil {
t.Fatal(err)
}
return code, string(stdoutData), string(stderrData)
}

View File

@@ -16,6 +16,8 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
"github.com/larksuite/cli/internal/vfs"
)
@@ -181,6 +183,47 @@ func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *te
}
}
func TestProfileRemoveRun_AddRecoveryUsesBuildLocalSurface(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "only",
Apps: []core.AppConfig{{
Name: "only",
AppId: "app-only",
AppSecret: core.PlainSecret("secret-only"),
Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
source := profileRemoveRun(nil, "only")
var original *errs.ValidationError
if !errors.As(source, &original) {
t.Fatalf("profileRemoveRun() error = %T, want *errs.ValidationError", source)
}
const visibleHint = "add another profile first: lark-cli profile add"
if original.Hint != visibleHint {
t.Fatalf("producer hint = %q, want %q", original.Hint, visibleHint)
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandProfileAdd: surface.CommandConcealed,
})
var concealed *errs.ValidationError
if rendered := recovery.Render(source, plan); !errors.As(rendered, &concealed) {
t.Fatalf("rendered error = %T, want *errs.ValidationError", rendered)
}
const fallback = "configure another profile through this distribution before removing the only profile"
if concealed.Hint != fallback {
t.Errorf("concealed hint = %q, want %q", concealed.Hint, fallback)
}
if original.Hint != visibleHint {
t.Errorf("concealed render mutated producer hint: %q", original.Hint)
}
}
func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{

View File

@@ -14,6 +14,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
)
// NewCmdProfileRemove creates the profile remove subcommand.
@@ -45,8 +46,12 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
}
if len(multi.Apps) == 1 {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile").
WithHint("add another profile first: lark-cli profile add")
return recovery.Attach(
errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile"),
recovery.Join("",
recovery.Command(recovery.TargetProfileAdd, "add another profile first: lark-cli profile add"),
).WithFallback("configure another profile through this distribution before removing the only profile"),
)
}
app := &multi.Apps[idx]

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/recovery"
)
// pruneForStrictMode removes commands incompatible with the active strict mode.
@@ -105,9 +106,16 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
},
RunE: func(c *cobra.Command, _ []string) error {
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
WithCause(cd)
hint := recovery.Join("; ",
recovery.Text(fmt.Sprintf("denied by %s policy (reason_code %s)", cd.Layer, cd.ReasonCode)),
recovery.Command(recovery.TargetConfigStrictMode, stubHint),
)
return recovery.Annotate(
errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
WithHint("%s", hint.String()).
WithCause(cd),
hint,
)
},
}
}

View File

@@ -14,6 +14,8 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
"github.com/spf13/cobra"
)
@@ -379,3 +381,48 @@ func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) {
t.Errorf("denial annotation overwritten or missing")
}
}
// The strict-mode stub carries a targeted config/strict-mode action alongside
// non-command policy context. Rendering for a concealed tree drops only the
// dead pointer and does not mutate the source error.
func TestStrictModeStub_ConfigHintUsesBuildLocalSurface(t *testing.T) {
child := &cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }}
stub := strictModeStubFrom(child, core.StrictModeBot)
source := stub.RunE(stub, nil)
var original *errs.ValidationError
if !errors.As(source, &original) {
t.Fatalf("expected *errs.ValidationError, got %T %v", source, source)
}
if original.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype = %q, want failed_precondition", original.Subtype)
}
if !strings.Contains(original.Hint, "config strict-mode") {
t.Fatalf("producer hint = %q, want config strict-mode", original.Hint)
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandConfigStrictMode: surface.CommandConcealed,
})
var concealed *errs.ValidationError
if rendered := recovery.Render(source, plan); !errors.As(rendered, &concealed) {
t.Fatalf("rendered error = %T, want *errs.ValidationError", rendered)
}
if concealed == original {
t.Fatal("Render must clone the typed error")
}
if strings.Contains(concealed.Hint, "config strict-mode") {
t.Errorf("concealed hint still contains config strict-mode: %q", concealed.Hint)
}
if !strings.Contains(concealed.Hint, "reason_code identity_not_supported") {
t.Errorf("non-command policy guidance was lost: %q", concealed.Hint)
}
var visible *errs.ValidationError
if !errors.As(recovery.Render(source, nil), &visible) ||
!strings.Contains(visible.Hint, "config strict-mode") {
t.Errorf("visible render must keep config strict-mode, got %+v", visible)
}
if !strings.Contains(original.Hint, "config strict-mode") {
t.Errorf("concealed render mutated source hint: %q", original.Hint)
}
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"sort"
"strings"
@@ -21,70 +22,16 @@ import (
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/skillref"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/surface"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const rootLong = `lark-cli — Lark/Feishu CLI tool.
AGENT QUICKSTART (driving this as an agent? start here):
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
Prefer a +shortcut over the raw API resource when one matches the task.
Risk: each command's --help shows read | write | high-risk-write;
high-risk-write needs --yes, only after the user confirms.
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
EXAMPLES (one per command style, in order of preference):
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
// rootUsageTemplate is cobra's default usage template with two root-only
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
// footer. Subcommands render the stock template unchanged. The rest is verbatim
// cobra so the command groups and flags are untouched.
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{else}}Usage:
lark-cli <command> [subcommand] [method] [flags]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
Aliases:
{{.NameAndAliases}}{{end}}{{if .HasExample}}
Examples:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
`
// Execute runs the root command and returns the process exit code.
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
@@ -94,25 +41,69 @@ Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://
var rawInvocationArgs []string
func Execute() int {
return executeWithOptions(nil)
}
// ExecuteWithOptions is the standard entrypoint for wrapper distributions that
// need host-level Build options such as ConcealRestrictedCommands. Execute
// intentionally keeps its original non-variadic signature for source
// compatibility with callers that store it as a func() int value.
func ExecuteWithOptions(opts ...BuildOption) int {
return executeWithOptions(opts)
}
func executeWithOptions(opts []BuildOption) int {
rawInvocationArgs = os.Args[1:]
inv, err := BootstrapInvocationContext(os.Args[1:])
if err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
inv, bootstrapErr := BootstrapInvocationContext(os.Args[1:])
cfg := &buildConfig{}
for _, opt := range opts {
if opt != nil {
opt(cfg)
}
}
deferProfileError := cfg.presentation.enabled &&
isDeferredBootstrapProfileError(bootstrapErr)
if bootstrapErr != nil && !deferProfileError {
fmt.Fprintln(os.Stderr, "Error:", bootstrapErr)
return 1
}
if cfg.streams == nil {
WithIO(os.Stdin, os.Stdout, os.Stderr)(cfg)
}
if !cfg.hideProfileSet {
HideProfile(isSingleAppMode())(cfg)
}
if !cfg.startupBrandSet {
WithStartupBrand(ResolveStartupBrand(inv.Profile))(cfg)
}
configureFlagCompletions(os.Args)
ctx := context.Background()
f, rootCmd, reg := buildInternal(
ctx, inv,
WithIO(os.Stdin, os.Stdout, os.Stderr),
HideProfile(isSingleAppMode()),
WithStartupBrand(ResolveStartupBrand(inv.Profile)),
)
if deferProfileError {
cfg.deferStartup = true
}
runtime, rootCmd, reg := buildInternalWithConfig(ctx, inv, cfg)
f := runtime.Factory
if deferProfileError {
if runtime.surface.CanReference(surface.CommandProfile) {
// The completed distribution still ships --profile. Replay the
// exact pre-Build legacy failure and do not emit Startup, notices,
// or Shutdown for an invocation that never passed bootstrap.
fmt.Fprintln(os.Stderr, "Error:", bootstrapErr)
return 1
}
if reg != nil {
if err := emitStartup(ctx, reg); err != nil {
installPluginLifecycleErrorGuard(rootCmd, err)
reg = nil
}
}
}
// --- Notices (non-blocking) ---
if !isCompletionCommand(os.Args) {
setupNotices()
setupNotices(runtime.surface)
}
runErr := rootCmd.Execute()
@@ -126,69 +117,98 @@ func Execute() int {
}
if runErr != nil {
return handleRootError(f, runErr)
return handleRootError(f, runErr, runtime.recovery)
}
return 0
}
// isDeferredBootstrapProfileError identifies the one bootstrap parse failure
// an explicitly concealed distribution may need the completed tree to render.
// Default and legacy builds never defer it.
func isDeferredBootstrapProfileError(err error) bool {
return err != nil && err.Error() == "flag needs an argument: --profile"
}
// Notice provider seams keep the "concealed update means no cache, network, or
// skills-state access" contract directly testable. Production always uses the
// concrete implementations below.
var (
checkCachedUpdate = update.CheckCached
refreshUpdateCache = update.RefreshCache
initializeSkillsCheck = skillscheck.Init
)
// setupNotices wires both the binary update notice and the skills
// staleness notice into output.PendingNotice as a composed function.
// Each provider populates an independent key under _notice; either
// or both may be present in any given envelope.
func setupNotices() {
// Binary update — synchronous cache check + async refresh
if info := update.CheckCached(build.Version); info != nil {
update.SetPending(info)
}
ver := build.Version
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
func setupNotices(plan *surface.Plan) {
if plan.CanReference(surface.CommandUpdate) {
// Binary update — synchronous cache check + async refresh.
if info := checkCachedUpdate(build.Version); info != nil {
update.SetPending(info)
}
ver := build.Version
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
}
}()
refreshUpdateCache(ver)
if update.GetPending() == nil {
if info := checkCachedUpdate(ver); info != nil {
update.SetPending(info)
}
}
}()
update.RefreshCache(ver)
if update.GetPending() == nil {
if info := update.CheckCached(ver); info != nil {
update.SetPending(info)
}
}
}()
// Skills check — synchronous, local-only (no network, no goroutine).
skillscheck.Init(build.Version)
// Skills drift has only one recovery action: lark-cli update. Do not
// even inspect local drift state when that action is absent.
initializeSkillsCheck(build.Version)
}
// Composed notice provider — emits keys only when each pending is set.
output.PendingNotice = composePendingNotice
// Capture this build's immutable plan; never consult another Build's state.
output.PendingNotice = func() map[string]interface{} {
return composePendingNotice(plan)
}
}
// 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{} {
func composePendingNotice(plan *surface.Plan) 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",
canUpdate := plan.CanReference(surface.CommandUpdate)
// Update and skills-drift notices have no recovery path of their own:
// both exist solely to steer the caller to `lark-cli update`.
if canUpdate {
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 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",
"message": dep.MessageWithoutUpdateAction(),
}
if canUpdate {
entry["message"] = dep.Message()
entry["action"] = "lark-cli update"
}
if dep.Replacement != "" {
entry["replacement"] = dep.Replacement
@@ -245,15 +265,22 @@ func configureFlagCompletions(args []string) {
// 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 {
func handleRootError(
f *cmdutil.Factory,
err error,
projector *recovery.Projector,
) int {
errOut := f.IOStreams.ErrOut
renderedErr := err
// 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.
// local shortcut/service metadata. Both semantic recovery filtering and
// dynamic enrichment operate on a concrete clone, never the producer's
// reusable error value.
if !errs.IsRaw(err) {
applyNeedAuthorizationHint(f, err)
renderedErr = newRootErrorPresenter(f, projector).Present(err)
}
// Staged dispatch: capture the typed exit code BEFORE attempting the
@@ -264,7 +291,7 @@ func handleRootError(f *cmdutil.Factory, err error) int {
// WriteTypedErrorEnvelope still returns false when err carries no
// 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)) {
if output.WriteTypedErrorEnvelope(errOut, renderedErr, string(f.ResolvedIdentity)) {
return typedExit
}
@@ -557,15 +584,10 @@ const (
groupManagement = "cli-management"
)
// groupRootCommands classifies root's direct children into the help groups,
// called once after all commands are registered. Unclassified commands fall to
// cobra's "Additional Commands" section.
func groupRootCommands(root *cobra.Command) {
root.AddGroup(
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
)
// classifyRootCommands assigns root children to help groups after registration.
// Group definitions are attached separately, after optional distribution
// projection, so a concealed build can omit a now-empty heading.
func classifyRootCommands(root *cobra.Command) {
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
for _, c := range root.Commands() {
@@ -583,6 +605,46 @@ func groupRootCommands(root *cobra.Command) {
}
}
// finalizeRootCommandGroups attaches Cobra group definitions once. A group is
// omitted only when this build's surface plan concealed all its children.
// Hidden legacy/YAML commands remain referenceable and therefore keep the
// historical (possibly empty) heading.
func finalizeRootCommandGroups(root *cobra.Command, plan *surface.Plan) {
if root == nil || len(root.Groups()) != 0 {
return
}
groups := []*cobra.Group{
{ID: groupDomains, Title: "Lark domains:"},
{ID: groupTooling, Title: "Agent tooling:"},
{ID: groupManagement, Title: "CLI management:"},
}
for _, group := range groups {
if plan != nil && !rootGroupHasReferenceableChild(root, group.ID, plan) {
// Cobra validates that every non-empty child GroupID has a
// matching definition before dispatch, including hidden children.
// If presentation removes an entire group, clear those now-hidden
// assignments as well as omitting the heading.
for _, child := range root.Commands() {
if child.GroupID == group.ID {
child.GroupID = ""
}
}
continue
}
root.AddGroup(group)
}
}
func rootGroupHasReferenceableChild(root *cobra.Command, groupID string, plan *surface.Plan) bool {
for _, child := range root.Commands() {
if child.GroupID == groupID &&
plan.CanReference(surface.CommandID(cmdpolicy.CanonicalPath(child))) {
return true
}
}
return false
}
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
func isLarkDomain(c *cobra.Command) bool {
@@ -601,6 +663,15 @@ func isLarkDomain(c *cobra.Command) bool {
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
// A policy-gated flag invoked bare ("flag needs an argument")
// never reaches its rejecting Value; it still presents as
// unregistered, exactly like a set one.
if gated, ok := gatedFlagFromNeedsArg(c, ferr); ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+gated, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + gated, Reason: "unknown flag"}).
WithHint("run `%s --help` to see valid flags", c.CommandPath())
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
}
@@ -623,6 +694,25 @@ func flagDidYouMean(c *cobra.Command, ferr error) error {
WithHint("%s", hint)
}
// gatedFlagFromNeedsArg reports whether ferr is pflag's "flag needs an
// argument: --name" for a policy-gated flag on this command's flag set.
func gatedFlagFromNeedsArg(c *cobra.Command, ferr error) (string, bool) {
const p = "flag needs an argument: --"
msg := ferr.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
}
name := msg[i+len(p):]
if j := strings.IndexAny(name, " \t"); j >= 0 {
name = name[:j]
}
if fl := c.Root().PersistentFlags().Lookup(name); isPolicyGatedFlag(fl) {
return name, true
}
return "", false
}
// 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
@@ -659,16 +749,59 @@ func visibleFlagNames(c *cobra.Command) []string {
return names
}
// installHelpCommand upgrades Cobra's default help command so that
// `lark-cli help <plugin-restricted-cmd>` returns a typed error (exit 2)
// instead of printing an envelope and exiting 0 — cobra's stock help
// command has no error channel.
func installHelpCommand(root *cobra.Command) {
root.InitDefaultHelpCmd()
helpCmd := findByPath(root, "help")
if helpCmd == nil {
return
}
helpCmd.Run = nil
helpCmd.RunE = func(c *cobra.Command, args []string) error {
target, _, err := root.Find(args)
if err != nil || target == nil {
c.Printf("Unknown help topic %#q\n", args)
return root.Usage()
}
if msg, ok := unavailableHelpMessage(target); ok {
return errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg)
}
target.SetContext(c.Context())
target.InitDefaultHelpFlag()
target.InitDefaultVersionFlag()
return target.Help()
}
// help attaches after policy evaluation (framework meta command, never
// policy-evaluated). No risk annotation: it would render a "Risk:"
// line that stock cobra help output does not carry.
cmdutil.DisableAuthCheck(helpCmd)
}
// installTipsHelpFunc wraps the default help function to append a TIPS section
// when a command has tips set via cmdutil.SetTips. It also force-shows global
// flags that are normally hidden in single-app mode (currently --profile)
// when rendering the root command's own help, so users discovering the CLI
// still see them at `lark-cli --help`.
func installTipsHelpFunc(root *cobra.Command) {
//
// skillContent is read lazily at help-render time (not captured up front) so
// the domain-guide pointer reflects the resolved skill tree -- the same
// f.SkillContent that `skills list`/`read` serve -- even though plugin skill
// customization is applied after this help func is installed.
func installTipsHelpFunc(
root *cobra.Command,
skillContent func() fs.FS,
skillReferences func() *skillref.Resolver,
projector *recovery.Projector,
) {
defaultHelp := root.HelpFunc()
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if cmd == root {
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden {
// Force-show flags hidden by single-app mode; never a
// policy-retired one.
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden && !isPolicyGatedFlag(f) {
f.Hidden = false
defer func() { f.Hidden = true }()
}
@@ -676,15 +809,22 @@ func installTipsHelpFunc(root *cobra.Command) {
// Domain and method commands compose their agent guidance into Long lazily
// here (shortcuts attach after service registration); both skip the generic
// bottom-of-help append below.
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
var refs *skillref.Resolver
if skillReferences != nil {
refs = skillReferences()
}
content := skillContent()
if service.PrepareDomainHelpWithReferences(cmd, content, refs) {
defaultHelp(cmd, args)
return
}
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
if service.PrepareMethodHelpWithProjection(cmd, content, refs, func() bool {
return projector.CanReference(recovery.TargetSchema)
}) {
defaultHelp(cmd, args)
return
}
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
if service.PrepareShortcutHelpWithReferences(cmd, content, refs) {
defaultHelp(cmd, args)
return
}

154
cmd/root_help.go Normal file
View File

@@ -0,0 +1,154 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"strings"
"github.com/larksuite/cli/internal/surface"
)
// rootHelpFragment is one framework-owned root-help fragment. A fragment with
// a target is emitted only while that exact command remains referenceable in
// this build. Keeping the target next to the text prevents curated examples
// from becoming dead pointers in reduced distributions.
type rootHelpFragment struct {
target surface.CommandID
text string
}
// rootHelpSection keeps a heading coupled to the target-aware entries it
// introduces. When projection removes every entry, the heading disappears
// with them instead of leaving an empty section in reduced builds.
type rootHelpSection struct {
heading string
fragments []rootHelpFragment
}
const (
rootHelpAPI surface.CommandID = "api"
rootHelpCalendarAgenda surface.CommandID = "calendar/+agenda"
rootHelpMailList surface.CommandID = "mail/user_mailbox.messages/list"
)
var rootLongSections = []rootHelpSection{
{fragments: []rootHelpFragment{
{text: `lark-cli — Lark/Feishu CLI tool.
AGENT QUICKSTART (driving this as an agent? start here):
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources`},
{target: surface.CommandSchema, text: `
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples`},
{text: `
Prefer a +shortcut over the raw API resource when one matches the task.
Risk: each command's --help shows read | write | high-risk-write;
high-risk-write needs --yes, only after the user confirms.
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).`},
}},
{
heading: "\n\nEXAMPLES (one per command style, in order of preference):",
fragments: []rootHelpFragment{
{target: rootHelpCalendarAgenda, text: `
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these`},
{target: rootHelpMailList, text: `
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method`},
{target: surface.CommandSchema, text: `
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling`},
{target: rootHelpAPI, text: `
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`},
},
},
}
// rootLong is the fully-visible default text retained as a compatibility
// oracle. Reduced builds derive their text from the same typed fragments.
var rootLong = renderRootHelpSections(rootLongSections, nil)
func renderRootHelpSections(sections []rootHelpSection, plan *surface.Plan) string {
var b strings.Builder
for _, section := range sections {
body := renderRootHelpFragments(section.fragments, plan)
if body == "" {
continue
}
b.WriteString(section.heading)
b.WriteString(body)
}
return b.String()
}
func renderRootHelpFragments(fragments []rootHelpFragment, plan *surface.Plan) string {
var b strings.Builder
for _, fragment := range fragments {
if fragment.target != "" && !plan.CanReference(fragment.target) {
continue
}
b.WriteString(fragment.text)
}
return b.String()
}
var rootUsageSynopsis = []rootHelpFragment{
{text: `Usage:
lark-cli <command> [subcommand] [method] [flags]`},
{target: rootHelpAPI, text: `
lark-cli api <method> <path> [--params <json>] [--data <json>]`},
{target: surface.CommandSchema, text: `
lark-cli schema <service.resource.method>`},
}
const rootUsageTemplatePrefix = `{{if .HasParent}}Usage:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{else}}`
// rootUsageTemplateSuffix is Cobra's default usage template after the root
// synopsis. Root-only framework affordances are assembled separately above
// and below it so each command reference carries an explicit target.
const rootUsageTemplateSuffix = `{{end}}{{if gt (len .Aliases) 0}}
Aliases:
{{.NameAndAliases}}{{end}}{{if .HasExample}}
Examples:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}`
// skillsSetupFooter is the root-help pointer at the human one-time skills
// setup. It is emitted only while skills/read remains referenceable.
const skillsSetupFooter = `{{if not .HasParent}}
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}`
var rootUsageTemplate = renderRootUsageTemplate(nil)
func renderRootUsageTemplate(plan *surface.Plan) string {
var b strings.Builder
b.WriteString(rootUsageTemplatePrefix)
b.WriteString(renderRootHelpFragments(rootUsageSynopsis, plan))
b.WriteString(rootUsageTemplateSuffix)
if plan.CanReference(surface.CommandSkillsRead) {
b.WriteString(skillsSetupFooter)
}
b.WriteByte('\n')
return b.String()
}

View File

@@ -59,7 +59,7 @@ func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Com
t.Helper()
rootCmd.SetArgs(args)
if err := rootCmd.Execute(); err != nil {
return handleRootError(f, err)
return handleRootError(f, err, nil)
}
return 0
}
@@ -371,10 +371,11 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--as", "user", "--dry-run",
})
if code != output.ExitValidation {
@@ -504,7 +505,7 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
output.PendingNotice = nil
})
setupNotices()
setupNotices(nil)
notice := output.GetNotice()
if notice == nil {
@@ -538,7 +539,7 @@ func TestSetupNotices_InSync(t *testing.T) {
output.PendingNotice = nil
})
setupNotices()
setupNotices(nil)
notice := output.GetNotice()
if notice != nil {
@@ -571,7 +572,7 @@ func TestSetupNotices_Drift(t *testing.T) {
output.PendingNotice = nil
})
setupNotices()
setupNotices(nil)
notice := output.GetNotice()
if notice == nil {
@@ -620,7 +621,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
output.PendingNotice = nil
})
setupNotices()
setupNotices(nil)
// After setupNotices, skills pending is set (drift). Manually populate
// the update side so the composed envelope has both keys — the update

View File

@@ -5,6 +5,7 @@ package cmd
import (
"bytes"
"io/fs"
"strings"
"testing"
@@ -12,6 +13,10 @@ import (
"github.com/spf13/cobra"
)
// nilSkills is the skill-content getter used by help-func tests that do
// not exercise the domain-guide pointer.
func nilSkills() fs.FS { return nil }
// rendersHelp runs the wrapped help func and returns stdout.
func rendersHelp(t *testing.T, cmd *cobra.Command) string {
t.Helper()
@@ -24,7 +29,7 @@ func rendersHelp(t *testing.T, cmd *cobra.Command) string {
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root)
installTipsHelpFunc(root, nilSkills, nil, nil)
child := &cobra.Command{Use: "delete", Short: "delete a file"}
cmdutil.SetRisk(child, "high-risk-write")
@@ -38,7 +43,7 @@ func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root)
installTipsHelpFunc(root, nilSkills, nil, nil)
child := &cobra.Command{Use: "list", Short: "list items"}
root.AddCommand(child)
@@ -51,7 +56,7 @@ func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root)
installTipsHelpFunc(root, nilSkills, nil, nil)
child := &cobra.Command{Use: "delete", Short: "delete a file"}
cmdutil.SetRisk(child, "high-risk-write")

View File

@@ -162,7 +162,7 @@ func TestHandleRootError_SecurityPolicyCanonicalEnvelope(t *testing.T) {
ChallengeURL: "https://example.com/challenge",
}
gotExit := handleRootError(f, spErr)
gotExit := handleRootError(f, spErr, nil)
if gotExit != int(output.ExitContentSafety) {
t.Errorf("exit code = %d, want %d (ExitContentSafety)", gotExit, output.ExitContentSafety)
}
@@ -209,7 +209,7 @@ func TestHandleRootError_SecurityPolicyCanonicalEnvelope(t *testing.T) {
},
}
gotExit := handleRootError(f, spErr)
gotExit := handleRootError(f, spErr, nil)
if gotExit != int(output.ExitContentSafety) {
t.Errorf("exit code = %d, want %d", gotExit, output.ExitContentSafety)
}
@@ -286,7 +286,7 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
})
// 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"))
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"), nil)
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
@@ -314,7 +314,7 @@ func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"))
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"), nil)
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
}
@@ -345,7 +345,7 @@ func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, core.NotConfiguredError())
exit := handleRootError(f, core.NotConfiguredError(), nil)
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (config shares ExitAuth)", exit, int(output.ExitAuth))
}
@@ -393,7 +393,7 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"), nil)
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
@@ -424,7 +424,7 @@ func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF))
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF), nil)
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "internal" {
@@ -449,7 +449,7 @@ func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) {
f.IOStreams.ErrOut = w
err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired")
exit := handleRootError(f, err)
exit := handleRootError(f, err, nil)
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (typed exit code preserved despite write failure)", exit, int(output.ExitAuth))
}
@@ -466,7 +466,7 @@ func TestHandleRootError_BareErrorExitCodeNoStderr(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, output.ErrBare(output.ExitAuth))
exit := handleRootError(f, output.ErrBare(output.ExitAuth), nil)
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (BareError code propagated)", exit, int(output.ExitAuth))
}
@@ -492,7 +492,7 @@ func TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved(t *testing.T) {
WithHint("custom producer hint").
WithCause(innerLegacy)
exit := handleRootError(f, outer)
exit := handleRootError(f, outer, nil)
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
)
@@ -28,6 +29,8 @@ var runRootUpgrade = func(cmd *cobra.Command) {
}
}
var checkRootCachedUpdate = update.CheckCached
// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand,
// no flags) — the only invocation that triggers the interactive upgrade prompt.
// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty
@@ -51,7 +54,10 @@ func readYes(r io.Reader) bool {
// offerRootUpgrade prompts for an interactive upgrade when running bare
// `lark-cli` in an interactive terminal with a cached newer version. Every
// failure is swallowed — it must never affect help output or the exit code.
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command, projector *recovery.Projector) {
if f == nil || !projector.CanReference(recovery.TargetUpdate) {
return
}
ios := f.IOStreams
// Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require
// stdout TTY too so this only fires in a pure foreground terminal session.
@@ -61,7 +67,7 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
// and the IsNewer/semver validation chain; it reads the on-disk cache that
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
info := update.CheckCached(build.Version)
info := checkRootCachedUpdate(build.Version)
if info == nil {
return
}
@@ -76,14 +82,18 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli`
// invocation offers an interactive upgrade before printing help. Non-bare
// invocations are passed straight through, unchanged.
func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) {
func installRootUpgradePrompt(
f *cmdutil.Factory,
root *cobra.Command,
projector *recovery.Projector,
) {
inner := root.RunE
if inner == nil {
return
}
root.RunE = func(cmd *cobra.Command, args []string) error {
if isBareRootInvocation(args) {
offerRootUpgrade(f, cmd)
offerRootUpgrade(f, cmd, projector)
}
return inner(cmd, args)
}

View File

@@ -15,6 +15,9 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
)
@@ -122,7 +125,7 @@ func TestOfferRootUpgrade(t *testing.T) {
OutIsTerminal: tc.out,
StderrIsTerminal: tc.err,
}}
offerRootUpgrade(f, &cobra.Command{})
offerRootUpgrade(f, &cobra.Command{}, nil)
gotPrompt := strings.Contains(errBuf.String(), "available")
if gotPrompt != tc.wantPrompt {
@@ -135,6 +138,34 @@ func TestOfferRootUpgrade(t *testing.T) {
}
}
func TestOfferRootUpgradeDoesNotReadCacheWhenUpdateIsConcealed(t *testing.T) {
oldCheck := checkRootCachedUpdate
t.Cleanup(func() { checkRootCachedUpdate = oldCheck })
cacheReads := 0
checkRootCachedUpdate = func(string) *update.UpdateInfo {
cacheReads++
return &update.UpdateInfo{Current: "1.0.0", Latest: "2.0.0"}
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandUpdate: surface.CommandConcealed,
})
projector := recovery.NewProjector(func() *surface.Plan { return plan })
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader("y\n"),
Out: &bytes.Buffer{},
ErrOut: &bytes.Buffer{},
IsTerminal: true,
OutIsTerminal: true,
StderrIsTerminal: true,
}}
offerRootUpgrade(f, &cobra.Command{}, projector)
if cacheReads != 0 {
t.Fatalf("concealed update read cache %d time(s)", cacheReads)
}
}
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })
@@ -147,7 +178,7 @@ func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
}}
installRootUpgradePrompt(f, root)
installRootUpgradePrompt(f, root, nil)
if err := root.RunE(root, []string{}); err != nil {
t.Fatalf("bare RunE err = %v", err)
@@ -184,7 +215,7 @@ func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
}}
installRootUpgradePrompt(f, root)
installRootUpgradePrompt(f, root, nil)
if root.RunE != nil {
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/skillref"
"github.com/spf13/cobra"
)
@@ -27,6 +28,12 @@ import (
// we fall back to it. The pristine base is captured once into an annotation so
// re-rendering does not append the guidance twice.
func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
return PrepareDomainHelpWithReferences(cmd, skillFS, nil)
}
// PrepareDomainHelpWithReferences is PrepareDomainHelp with a build-local
// canonical-to-runtime skill projection.
func PrepareDomainHelpWithReferences(cmd *cobra.Command, skillFS fs.FS, references *skillref.Resolver) bool {
if cmd.Annotations[schemaPathAnnotation] != "" {
return false // a method command
}
@@ -61,10 +68,12 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
b.WriteString("\n\nPrefer a +-prefixed shortcut when one matches your task; otherwise use the raw API resource below.")
}
b.WriteString("\n\nRisk levels (read | write | high-risk-write) appear in each command's --help; high-risk-write requires --yes, only after the user confirms.")
if skill := "lark-" + cmd.Name(); skillFS != nil {
if _, err := fs.Stat(skillFS, skill+"/SKILL.md"); err == nil {
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
}
canonicalSkill := "lark-" + cmd.Name()
if declared, ok := affordance.DomainSkill(cmdmeta.Domain(cmd)); ok {
canonicalSkill = declared
}
if skill, ok := resolveSkillReference(canonicalSkill, skillFS, references); ok {
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
}
cmd.Long = b.String()
return true
@@ -137,6 +146,34 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
// affordance.SkillStatPath), so a typo or a build without embedded skills never
// prints a `skills read` that cannot be opened.
func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
return PrepareMethodHelpWithReferences(cmd, skillFS, nil)
}
// PrepareMethodHelpWithReferences is PrepareMethodHelp with a build-local
// canonical-to-runtime skill projection.
func PrepareMethodHelpWithReferences(cmd *cobra.Command, skillFS fs.FS, references *skillref.Resolver) bool {
return prepareMethodHelp(cmd, skillFS, references, nil)
}
// PrepareMethodHelpWithProjection is PrepareMethodHelpWithReferences with the
// command tree's lazy, build-local schema-reference decision. The established
// helpers remain fully-visible by default; cmd.Build uses this form so the
// framework-owned schema pointer follows the same surface as execution.
func PrepareMethodHelpWithProjection(
cmd *cobra.Command,
skillFS fs.FS,
references *skillref.Resolver,
canReferenceSchema func() bool,
) bool {
return prepareMethodHelp(cmd, skillFS, references, canReferenceSchema)
}
func prepareMethodHelp(
cmd *cobra.Command,
skillFS fs.FS,
references *skillref.Resolver,
canReferenceSchema func() bool,
) bool {
ann := cmd.Annotations
if ann == nil {
return false
@@ -161,10 +198,12 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
}
}
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
if canReferenceSchema == nil || canReferenceSchema() {
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
}
b.WriteString(ann[paramsOnlyAnnotation])
writeRelatedSkills(&b, skills, skillFS)
writeRelatedSkills(&b, skills, skillFS, references)
cmd.Long = b.String()
return true
@@ -177,10 +216,9 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
// entry, so shortcuts without guidance keep the default help plus the bottom
// risk/tips append.
//
// The lead is the command's pristine base (captureHelpBase): a shortcut that
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
// read the skill" directive) keeps it — the affordance block is appended below,
// never clobbering it.
// The lead is the command's pristine base (captureHelpBase): a shortcut with a
// hand-authored Long keeps it, while structured affordance guidance is
// appended below without clobbering the business description.
//
// Tips precedence (intentional, not a bug): the overlay's ### Tips win. The
// shortcut's declarative Tips (the Go Tips field) are only a fallback used when
@@ -188,6 +226,12 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
// (replaced, not merged) so tips never render twice. Authoring a ### Tips block
// therefore silently retires that shortcut's Go Tips — consolidate into one.
func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
return PrepareShortcutHelpWithReferences(cmd, skillFS, nil)
}
// PrepareShortcutHelpWithReferences is PrepareShortcutHelp with a build-local
// canonical-to-runtime skill projection.
func PrepareShortcutHelpWithReferences(cmd *cobra.Command, skillFS fs.FS, references *skillref.Resolver) bool {
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
return false
}
@@ -210,7 +254,7 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
b.WriteString("\n\n")
b.WriteString(block)
}
writeRelatedSkills(&b, a.Skills, skillFS)
writeRelatedSkills(&b, a.Skills, skillFS, references)
cmd.Long = b.String()
return true
@@ -234,14 +278,14 @@ func writeRisk(b *strings.Builder, cmd *cobra.Command) {
// writeRelatedSkills appends the "Related skills" block for the entries that
// exist in skillFS. Nothing is written when skillFS is nil or no entry resolves,
// so help never prints a `skills read` pointer that cannot be opened.
func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) {
func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS, references *skillref.Resolver) {
if skillFS == nil || len(skills) == 0 {
return
}
var avail []string
for _, s := range skills {
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(s)); err == nil {
avail = append(avail, s)
if resolved, ok := resolveSkillReference(s, skillFS, references); ok {
avail = append(avail, resolved)
}
}
if len(avail) == 0 {
@@ -253,6 +297,22 @@ func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) {
}
}
func resolveSkillReference(canonical string, skillFS fs.FS, references *skillref.Resolver) (string, bool) {
// A nil skillFS is also the command-surface gate supplied by cmd.Build:
// embedded bytes may still exist, but presenters must not point at them
// when `skills read` is concealed.
if skillFS == nil {
return "", false
}
if references != nil {
return references.ResolveString(canonical)
}
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(canonical)); err != nil {
return "", false
}
return canonical, true
}
// affordanceLookup is the overlay source; a package var so tests can inject.
var affordanceLookup = affordance.For

View File

@@ -9,9 +9,13 @@ import (
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/skillref"
"github.com/larksuite/cli/internal/surface"
"github.com/spf13/cobra"
)
@@ -142,6 +146,40 @@ func TestPrepareMethodHelp(t *testing.T) {
}
}
func TestPrepareMethodHelpProjectsConcealedSchemaPointer(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["发文本消息"]}`), true
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
m := map[string]interface{}{
"id": "messages.create", "path": "messages", "httpMethod": "POST",
"description": "发送消息",
}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandSchema: surface.CommandConcealed,
})
projector := recovery.NewProjector(func() *surface.Plan { return plan })
if !PrepareMethodHelpWithProjection(cmd, nil, nil, func() bool {
return projector.CanReference(recovery.TargetSchema)
}) {
t.Fatal("PrepareMethodHelpWithProjection returned false for a service-method command")
}
if strings.Contains(cmd.Long, "lark-cli schema") ||
strings.Contains(cmd.Long, "Full parameter schema:") {
t.Fatalf("concealed schema left a dead method-help pointer:\n%s", cmd.Long)
}
for _, want := range []string{"发送消息", "When to use:", "发文本消息"} {
if !strings.Contains(cmd.Long, want) {
t.Errorf("schema projection removed unrelated help %q:\n%s", want, cmd.Long)
}
}
}
// PrepareShortcutHelp composes a shortcut's Long from its overlay with the same
// top layout as method help (no schema pointer), folding declarative tips when
// the overlay declares none, and leaves shortcuts without an overlay entry (and
@@ -233,9 +271,61 @@ func TestRelatedSkillsStatGating(t *testing.T) {
}
}
// A shortcut that set a hand-authored Long (as the docs shortcuts do in
// PostMount) keeps it as the lead: the affordance block is appended below, not
// clobbered, and re-rendering does not double-append.
func TestDomainSkillReferenceRequiresReadableCommandSurface(t *testing.T) {
content := fstest.MapFS{
"lark-im/SKILL.md": {Data: []byte("# im")},
}
resolver, err := skillref.New(content, nil)
if err != nil {
t.Fatalf("skillref.New(): %v", err)
}
root := &cobra.Command{Use: "lark-cli"}
domain := &cobra.Command{Use: "im", Short: "IM"}
cmdmeta.SetSource(domain, cmdmeta.SourceService, false)
domain.AddCommand(&cobra.Command{Use: "messages", Run: func(*cobra.Command, []string) {}})
root.AddCommand(domain)
if !PrepareDomainHelpWithReferences(domain, nil, resolver) {
t.Fatal("PrepareDomainHelp returned false")
}
if strings.Contains(domain.Long, "skills read") {
t.Fatalf("concealed skills/read leaked through resolver:\n%s", domain.Long)
}
}
func TestDomainSkillReferenceUsesDeclaredAffordanceName(t *testing.T) {
affordance.SetSource(fstest.MapFS{
"docs.md": {Data: []byte("# docs\n> skill: lark-doc\n")},
})
t.Cleanup(func() { affordance.SetSource(nil) })
content := fstest.MapFS{
"lark-doc/SKILL.md": {Data: []byte("# docs")},
}
resolver, err := skillref.New(content, nil)
if err != nil {
t.Fatalf("skillref.New(): %v", err)
}
root := &cobra.Command{Use: "lark-cli"}
domain := &cobra.Command{Use: "docs", Short: "Docs"}
cmdmeta.SetSource(domain, cmdmeta.SourceService, false)
cmdmeta.SetDomain(domain, "docs")
domain.AddCommand(&cobra.Command{Use: "documents", Run: func(*cobra.Command, []string) {}})
root.AddCommand(domain)
if !PrepareDomainHelpWithReferences(domain, content, resolver) {
t.Fatal("PrepareDomainHelp returned false")
}
if !strings.Contains(domain.Long, "skills read lark-doc") {
t.Fatalf("declared domain skill was not used:\n%s", domain.Long)
}
if strings.Contains(domain.Long, "skills read lark-docs") {
t.Fatalf("command-name inference overrode declared domain skill:\n%s", domain.Long)
}
}
// A shortcut that sets a hand-authored Long keeps it as the lead: the
// affordance block is appended below, not clobbered, and re-rendering does not
// double-append.
func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
@@ -297,6 +387,26 @@ func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
}
// A service domain carries only a Short at help time; it seeds the base.
// The domain-guide pointer is likewise gated: removing the domain's skill
// drops the pointer instead of leaving it dangling.
func TestPrepareDomainHelp_GatesGuidePointerOnFS(t *testing.T) {
present := domainCmd("Consume and manage real-time events", "")
if !PrepareDomainHelp(present, fstest.MapFS{"lark-event/SKILL.md": &fstest.MapFile{Data: []byte("x")}}) {
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
}
if !strings.Contains(present.Long, "lark-cli skills read lark-event") {
t.Errorf("skill present should emit the domain-guide pointer; got:\n%s", present.Long)
}
removed := domainCmd("Consume and manage real-time events", "")
if !PrepareDomainHelp(removed, fstest.MapFS{}) {
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
}
if strings.Contains(removed.Long, "skills read lark-event") {
t.Errorf("removed skill must leave no domain-guide pointer; got:\n%s", removed.Long)
}
}
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
dom := domainCmd("Message and group chat management", "")
if !PrepareDomainHelp(dom, nil) {

View File

@@ -11,6 +11,8 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -597,6 +599,38 @@ func TestServiceMethod_MissingRequired_HintNamesFlagAndParams(t *testing.T) {
}
}
func TestServiceMethod_MissingRequired_ProjectsOnlySchemaRecovery(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"})
source := cmd.Execute()
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandSchema: surface.CommandConcealed,
})
rendered := recovery.NewProjector(func() *surface.Plan { return plan }).Render(source)
var ve *errs.ValidationError
if !errors.As(rendered, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", rendered, rendered)
}
for _, want := range []string{"--chat-id", `--params '{"chat_id": "<value>"}'`} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("projected hint %q lost valid recovery %q", ve.Hint, want)
}
}
if strings.Contains(ve.Hint, "lark-cli schema") {
t.Errorf("projected hint retained concealed schema pointer: %q", ve.Hint)
}
var sourceValidation *errs.ValidationError
if !errors.As(source, &sourceValidation) {
t.Fatalf("source is not *errs.ValidationError: %T", source)
}
if !strings.Contains(sourceValidation.Hint, "lark-cli schema im.chat.members.create") {
t.Errorf("presentation mutated source hint: %q", sourceValidation.Hint)
}
}
// 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.

View File

@@ -21,6 +21,7 @@ import (
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/validate"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -490,19 +491,15 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
// newPreflightMissingScopeError constructs a PermissionError for the local
// pre-flight scope check that converges byte-for-byte with the dispatcher's
// BuildAPIError path. Uses the canonical helpers in internal/errclass so
// Hint and Message stay in lock-step with the server-response classifier.
// BuildAPIError path. It records the same typed facts and canonical message;
// the root presenter supplies identity-appropriate recovery at the final
// command boundary.
// ConsoleURL is deliberately omitted: the dispatcher only sets it for
// SubtypeAppScopeNotApplied (bot-perspective dev-action recovery), and this
// pre-flight path is user-perspective SubtypeMissingScope whose recovery is
// `lark-cli auth login --scope ...`, not a console deep-link.
func newPreflightMissingScopeError(brand, appID, identity string, missing []string) *errs.PermissionError {
consoleURL := errclass.ConsoleURL(brand, appID, missing)
return errs.NewPermissionError(errs.SubtypeMissingScope,
"%s", errclass.CanonicalPermissionMessage(errs.SubtypeMissingScope, appID, missing, "")).
WithHint("%s", errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope, consoleURL)).
WithMissingScopes(missing...).
WithIdentity(identity)
func newPreflightMissingScopeError(brand, appID, identity string, missing []string) error {
return errclass.NewMissingScopeError(brand, appID, identity, missing)
}
// unusableParamValue reports whether a provided path/query parameter value
@@ -529,12 +526,28 @@ func unusableParamValue(v interface{}) bool {
// 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 {
func missingParamHint(opts *ServiceMethodOptions, f meta.Field) recovery.Hint {
paramsForm := fmt.Sprintf("--params '{%q: \"<value>\"}'", f.Name)
var input string
if opts.binder.hasTypedFlag(f.Name) {
return fmt.Sprintf("set --%s <value> (or %s); see: lark-cli schema %s", f.FlagName(), paramsForm, opts.SchemaPath)
input = fmt.Sprintf("set --%s <value> (or %s)", f.FlagName(), paramsForm)
} else {
input = fmt.Sprintf("set %s", paramsForm)
}
return fmt.Sprintf("set %s; see: lark-cli schema %s", paramsForm, opts.SchemaPath)
return recovery.Join("; ",
recovery.Text(input),
recovery.Command(recovery.TargetSchema, "see: lark-cli schema "+opts.SchemaPath),
)
}
func missingRequiredParamError(opts *ServiceMethodOptions, f meta.Field, location string) error {
hint := missingParamHint(opts, f)
return recovery.Attach(
errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing required %s parameter: %s", location, f.Name).
WithParam(f.Name),
hint,
)
}
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
@@ -571,10 +584,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
}
val, ok := params[s.Name]
if !ok || unusableParamValue(val) {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing required path parameter: %s", s.Name).
WithHint("%s", missingParamHint(opts, s)).
WithParam(s.Name)
return client.RawApiRequest{}, nil, missingRequiredParamError(opts, s, "path")
}
valStr := fmt.Sprintf("%v", val)
if err := validate.ResourceName(valStr, s.Name); err != nil {
@@ -592,10 +602,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
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", s.Name).
WithHint("%s", missingParamHint(opts, s)).
WithParam(s.Name)
return client.RawApiRequest{}, nil, missingRequiredParamError(opts, s, "query")
}
if exists && !unusableParamValue(value) {
queryParams[s.Name] = value
@@ -707,20 +714,18 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
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
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
}, pagOpts)
if err != nil {
return err

View File

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

View File

@@ -54,6 +54,30 @@ func driveMethod(httpMethod string, params map[string]interface{}) meta.Method {
return meta.FromMap(m)
}
func TestNewPreflightMissingScopeErrorUsesCanonicalFieldGate(t *testing.T) {
err := newPreflightMissingScopeError(
"feishu",
"cli_test",
"user",
[]string{"docx:document"},
)
var permissionErr *errs.PermissionError
if !errors.As(err, &permissionErr) {
t.Fatalf("error = %T, want *errs.PermissionError", err)
}
if permissionErr.Subtype != errs.SubtypeMissingScope {
t.Fatalf("subtype = %q, want %q", permissionErr.Subtype, errs.SubtypeMissingScope)
}
if permissionErr.ConsoleURL != "" {
t.Fatalf("missing_scope console_url = %q, want empty", permissionErr.ConsoleURL)
}
if len(permissionErr.MissingScopes) != 1 ||
permissionErr.MissingScopes[0] != "docx:document" ||
permissionErr.Identity != "user" {
t.Fatalf("permission facts = %+v", permissionErr)
}
}
// ── registerService ──
func TestRegisterService(t *testing.T) {

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"os"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates service command tests from the host machine: config (and
// the registry cache under it) is redirected to a temp dir, then the registry
// is seeded from the tracked fixture and initialized eagerly. Tests pass on a
// clean checkout with no network, no `make fetch_meta`, and no user cache.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-cmd-service-test-*")
if err != nil {
println("cmd/service test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
println("cmd/service test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd/service test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -0,0 +1,208 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"errors"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/skillcontent"
)
// withBaseSkills swaps the process-global embedded skill tree for the
// duration of a test, restoring it afterward.
func withBaseSkills(t *testing.T, files map[string]string) {
t.Helper()
base := fstest.MapFS{}
for p, content := range files {
base[p] = &fstest.MapFile{Data: []byte(content)}
}
saved := embeddedSkillContent
t.Cleanup(func() { embeddedSkillContent = saved })
embeddedSkillContent = base
}
// A plugin's SkillsOverlay must reshape the tree the factory serves: skills
// list/read read f.SkillContent, so a resolved removal/overlay shows up here.
// (Framework-generated --help pointers are gated on the same f.SkillContent;
// that gating is covered by the PrepareDomainHelp/PrepareMethodHelp tests in
// cmd/service.)
func TestBuildInternal_appliesPluginSkillsOverlay(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
withBaseSkills(t, map[string]string{
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
"lark-b/SKILL.md": "---\ndescription: b\n---\n",
"lark-shared/SKILL.md": "---\ndescription: shared\n---\n",
})
overlay := fstest.MapFS{
"lark-new/SKILL.md": &fstest.MapFile{Data: []byte("---\ndescription: new\n---\n")},
}
platform.Register(platform.NewPlugin("acme", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{
Remove: []string{"lark-shared"},
Overlay: overlay,
}).MustBuild())
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t))
if f.SkillContent == nil {
t.Fatal("f.SkillContent is nil after skill resolution")
}
skills, err := skillcontent.New(f.SkillContent).List()
if err != nil {
t.Fatalf("List: %v", err)
}
var names []string
for _, s := range skills {
names = append(names, s.Name)
}
if got := strings.Join(names, ","); got != "lark-a,lark-b,lark-new" {
t.Errorf("skills = %q, want lark-a,lark-b,lark-new (shared removed, new added)", got)
}
}
// Two plugins each customizing skills must abort at dispatch with a
// structured envelope carrying reason_code multiple_skills_overlay_plugins, not
// silently fall back to the default tree.
func TestBuildInternal_multipleSkillPluginsGuard(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
platform.Register(platform.NewPlugin("acme", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
platform.Register(platform.NewPlugin("globex", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
if reg != nil {
t.Errorf("skill conflict guard path should yield nil registry")
}
leaf := findRunnableLeaf(root)
if leaf == nil {
t.Fatal("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if !strings.Contains(verr.Hint, "multiple_skills_overlay_plugins") {
t.Errorf("hint should surface reason_code multiple_skills_overlay_plugins, got %q", verr.Hint)
}
}
// Allow keeps only the listed skills from the base — a CLI upgrade adding
// new embedded skills cannot widen an allow-listed build.
func TestBuildInternal_appliesAllowList(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
withBaseSkills(t, map[string]string{
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
"lark-b/SKILL.md": "---\ndescription: b\n---\n",
"lark-c/SKILL.md": "---\ndescription: c\n---\n",
})
platform.Register(platform.NewPlugin("acme", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{Allow: []string{"lark-a", "lark-c"}}).
MustBuild())
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t))
skills, err := skillcontent.New(f.SkillContent).List()
if err != nil {
t.Fatalf("List: %v", err)
}
var names []string
for _, s := range skills {
names = append(names, s.Name)
}
if got := strings.Join(names, ","); got != "lark-a,lark-c" {
t.Errorf("skills = %q, want lark-a,lark-c (allow-list)", got)
}
}
// A plugin whose SkillsOverlay cannot compose (Remove naming a skill absent
// from the base) must abort with reason_code invalid_skills_overlay.
func TestBuildInternal_invalidSkillsOverlayGuard(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
platform.Register(platform.NewPlugin("acme", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-does-not-exist"}}).MustBuild())
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
leaf := findRunnableLeaf(root)
if leaf == nil {
t.Fatal("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if !strings.Contains(verr.Hint, "invalid_skills_overlay") {
t.Errorf("hint should surface reason_code invalid_skills_overlay, got %q", verr.Hint)
}
}
// A wrapper main that forgets to wire its embedded skill base should get the
// missing host assembly step, not the same recovery hint as a misspelled
// Allow/Remove name.
func TestBuildInternal_missingBaseSkillsGuardHint(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
saved := embeddedSkillContent
t.Cleanup(func() { embeddedSkillContent = saved })
embeddedSkillContent = nil
platform.Register(platform.NewPlugin("acme", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
leaf := findRunnableLeaf(root)
if leaf == nil {
t.Fatal("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if !strings.Contains(verr.Hint, "this build embeds no base skill content") {
t.Errorf("hint should name the missing embedded content, got %q", verr.Hint)
}
if !strings.Contains(verr.Hint, "cmd.SetEmbeddedSkillContent") {
t.Errorf("hint should name the wrapper-main wiring API, got %q", verr.Hint)
}
if !strings.Contains(verr.Hint, "invalid_skills_overlay") {
t.Errorf("hint should preserve reason_code invalid_skills_overlay, got %q", verr.Hint)
}
}

View File

@@ -5,6 +5,7 @@ package cmd
import (
"context"
"flag"
"fmt"
"os"
"os/exec"
@@ -12,11 +13,34 @@ import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry"
)
const startupBrandHelperEnv = "GO_TEST_STARTUP_BRAND_HELPER"
var _ = flag.String("startup-brand-helper", "", "internal startup brand test helper nonce")
func isStartupBrandHelper() bool {
return startupBrandHelperEnabled(os.Getenv(startupBrandHelperEnv), startupBrandHelperNonce(os.Args))
}
func startupBrandHelperEnabled(envNonce, argNonce string) bool {
return envNonce != "" && envNonce == argNonce
}
func startupBrandHelperNonce(args []string) string {
const prefix = "-startup-brand-helper="
for _, arg := range args {
if strings.HasPrefix(arg, prefix) {
return strings.TrimPrefix(arg, prefix)
}
}
return ""
}
func TestResolveStartupBrand_Precedence(t *testing.T) {
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
@@ -54,7 +78,7 @@ func TestResolveStartupBrand_Precedence(t *testing.T) {
// sync.Once, so the brand must be injected before the first catalog access.
// It runs in a subprocess because the registry is process-global.
func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
if os.Getenv("GO_TEST_STARTUP_BRAND_HELPER") == "1" {
if isStartupBrandHelper() {
// Helper: replicate Execute()'s build wiring with a lark config.
buildInternal(
context.Background(), cmdutil.InvocationContext{},
@@ -71,9 +95,11 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
t.Fatal(err)
}
nonce := uuid.NewString()
t.Setenv(startupBrandHelperEnv, nonce)
cmd := exec.Command(os.Args[0], "-test.run", "TestStartupBrandReachesRegistry_RealStartupOrder")
cmd.Args = append(cmd.Args, "-startup-brand-helper="+nonce)
cmd.Env = append(os.Environ(),
"GO_TEST_STARTUP_BRAND_HELPER=1",
"LARKSUITE_CLI_CONFIG_DIR="+tmp,
"LARKSUITE_CLI_REMOTE_META=off", // no network during the subprocess build
)
@@ -85,3 +111,33 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
t.Errorf("registry brand after real startup order = %s, want lark", out)
}
}
func TestStartupBrandHelperRequiresMatchingCommandNonce(t *testing.T) {
for _, tt := range []struct {
name string
envNonce string
argNonce string
want bool
}{
{name: "neither set"},
{name: "ambient environment only", envNonce: "ambient"},
{name: "command argument only", argNonce: "command"},
{name: "mismatch", envNonce: "ambient", argNonce: "command"},
{name: "matching", envNonce: "nonce", argNonce: "nonce", want: true},
} {
t.Run(tt.name, func(t *testing.T) {
if got := startupBrandHelperEnabled(tt.envNonce, tt.argNonce); got != tt.want {
t.Fatalf("startupBrandHelperEnabled() = %v, want %v", got, tt.want)
}
})
}
}
func TestStartupBrandHelperNonce(t *testing.T) {
if got := startupBrandHelperNonce([]string{"test", "-test.run", "brand"}); got != "" {
t.Fatalf("startupBrandHelperNonce() = %q, want empty", got)
}
if got := startupBrandHelperNonce([]string{"test", "-startup-brand-helper=nonce"}); got != "nonce" {
t.Fatalf("startupBrandHelperNonce() = %q, want nonce", got)
}
}

46
cmd/testmain_test.go Normal file
View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"os"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates command-tree tests from the host machine: config (and the
// registry cache under it) is redirected to a temp dir, then the registry is
// seeded from the tracked fixture and initialized eagerly. Tests pass on a
// clean checkout with no network, no `make fetch_meta`, and no user cache.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
if isStartupBrandHelper() {
// Re-exec helper subprocess (startup_brand_test.go): the parent test
// already provides an isolated config dir and disables remote metadata,
// and the helper must own the first registry Init to prove the startup
// order — do not seed or eagerly initialize here.
os.Exit(m.Run())
}
root, err := os.MkdirTemp("", "lark-cli-cmd-test-*")
if err != nil {
println("cmd test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
println("cmd test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdupdate
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-update-test-*")
if err != nil {
panic(err)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
panic(err)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -24,6 +24,8 @@ import (
"github.com/larksuite/cli/internal/skillscheck"
)
const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS"
// newTestFactory creates a test factory with minimal config.
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
@@ -31,13 +33,17 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
return f, stdout, stderr
}
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
// mockDetect sets up newUpdater to return an Updater with the given DetectResult
// and fully mocked skills operations. Tests that only care about install-method
// detection must never fall through to the real npx skills CLI.
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
@@ -104,6 +110,18 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
}
}
func mockSkillsSync(t *testing.T) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func TestUpdatePnpm_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -228,6 +246,9 @@ func TestNormalizeVersion(t *testing.T) {
}
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mockSkillsSync(t)
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
@@ -256,6 +277,9 @@ func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
}
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mockSkillsSync(t)
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
@@ -281,6 +305,7 @@ func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
}
func TestUpdateManual_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
@@ -312,6 +337,7 @@ func TestUpdateManual_JSON(t *testing.T) {
}
func TestUpdateManual_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
@@ -1161,6 +1187,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
}
called := false
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
called = true
return successfulSkillsCommand()(args...)
@@ -1177,7 +1204,10 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: successfulSkillsCommand(),
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
@@ -1197,6 +1227,7 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
t.Fatal(err)
}
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("npx failed")
@@ -1513,28 +1544,133 @@ func TestEmitSkillsTextHints_Success(t *testing.T) {
}
}
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
// state file. It calls the real npx skills CLI, so the test is skipped when
// npx or the skills registry is unavailable (e.g. no network or fork PRs).
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
if _, err := exec.LookPath("npx"); err != nil {
t.Skipf("npx not found in PATH: %v", err)
// liveSkillsIsolationEnv is the single source of truth for the user-state
// directories a live skills test must redirect under the temporary home. It
// covers the CLI's own config, the agent homes the skills CLI installs into,
// the XDG dirs it derives paths from (XDG_STATE_HOME holds its global
// .skill-lock.json), and the npm/npx overrides that take precedence over
// HOME-derived defaults (both cases: npm reads npm_config_* case-insensitively).
func liveSkillsIsolationEnv(home string) map[string]string {
return map[string]string{
"HOME": home,
"USERPROFILE": home,
"APPDATA": filepath.Join(home, "AppData", "Roaming"),
"LOCALAPPDATA": filepath.Join(home, "AppData", "Local"),
"XDG_CONFIG_HOME": filepath.Join(home, ".config"),
"XDG_DATA_HOME": filepath.Join(home, ".local", "share"),
"XDG_STATE_HOME": filepath.Join(home, ".local", "state"),
"CODEX_HOME": filepath.Join(home, ".codex"),
"CLAUDE_CONFIG_DIR": filepath.Join(home, ".claude"),
"LARKSUITE_CLI_CONFIG_DIR": filepath.Join(home, ".lark-cli"),
"npm_config_cache": filepath.Join(home, ".npm-cache"),
"NPM_CONFIG_CACHE": filepath.Join(home, ".npm-cache"),
"npm_config_prefix": filepath.Join(home, ".npm-global"),
"NPM_CONFIG_PREFIX": filepath.Join(home, ".npm-global"),
"npm_config_userconfig": filepath.Join(home, ".npmrc"),
"NPM_CONFIG_USERCONFIG": filepath.Join(home, ".npmrc"),
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
}
func prepareLiveSkillsIntegration(t *testing.T) string {
t.Helper()
if os.Getenv(runLiveSkillsTestsEnv) != "1" {
t.Skipf("live skills integration test disabled; set %s=1 to run", runLiveSkillsTestsEnv)
}
home := t.TempDir()
for key, value := range liveSkillsIsolationEnv(home) {
t.Setenv(key, value)
}
return home
}
func TestPrepareLiveSkillsIntegration(t *testing.T) {
reachedAfterGate := false
t.Run("requires explicit opt-in", func(t *testing.T) {
t.Setenv(runLiveSkillsTestsEnv, "")
prepareLiveSkillsIntegration(t)
reachedAfterGate = true
})
if reachedAfterGate {
t.Fatal("prepareLiveSkillsIntegration continued without explicit opt-in")
}
t.Run("isolates user directories", func(t *testing.T) {
t.Setenv(runLiveSkillsTestsEnv, "1")
home := prepareLiveSkillsIntegration(t)
// Pin the isolation contract by key: removing a variable from
// liveSkillsIsolationEnv must fail this list, and every redirected
// value must live under the temporary home.
required := []string{
"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME",
"CODEX_HOME", "CLAUDE_CONFIG_DIR", "LARKSUITE_CLI_CONFIG_DIR",
"npm_config_cache", "NPM_CONFIG_CACHE",
"npm_config_prefix", "NPM_CONFIG_PREFIX",
"npm_config_userconfig", "NPM_CONFIG_USERCONFIG",
}
env := liveSkillsIsolationEnv(home)
for _, key := range required {
expected, ok := env[key]
if !ok {
t.Errorf("liveSkillsIsolationEnv dropped required key %s", key)
continue
}
if !strings.HasPrefix(expected, home) {
t.Errorf("%s = %q escapes temporary home %q", key, expected, home)
}
if got := os.Getenv(key); got != expected {
t.Errorf("%s = %q, want %q", key, got, expected)
}
}
})
}
// seedLiveSkillsGlobal verifies the real npx skills CLI is reachable, installs
// lark-calendar into the isolated global skills dir, and returns the parsed
// global skills list. The caller opted in explicitly, so every missing
// precondition is a hard failure — skipping would report "nothing verified"
// as a green run.
func seedLiveSkillsGlobal(t *testing.T) []string {
t.Helper()
if _, err := exec.LookPath("npx"); err != nil {
t.Fatalf("live skills tests opted in but npx not found in PATH: %v", err)
}
// Three sequential npx runs against a cold cache (the isolated home starts
// empty) can be slow; with Fatal-on-timeout semantics the budget errs on
// the generous side.
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
t.Skipf("real skills CLI unavailable: %v", err)
t.Fatalf("live skills tests opted in but real skills CLI unavailable: %v", err)
}
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "-s", "lark-calendar", "-g", "-y").Run(); err != nil {
t.Fatalf("failed to seed isolated global skills: %v", err)
}
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
if err != nil {
t.Skipf("real global skills CLI unavailable: %v", err)
t.Fatalf("real global skills CLI unavailable: %v", err)
}
localSkills := skillscheck.ParseSkillsList(string(globalOut))
if err := ctx.Err(); err != nil {
t.Skipf("real skills CLI availability check timed out: %v", err)
if len(localSkills) == 0 {
t.Fatal("seeded lark-calendar but global skills list is empty")
}
if err := ctx.Err(); err != nil {
t.Fatalf("real skills CLI availability check timed out: %v", err)
}
return localSkills
}
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
// state file. It calls the real npx skills CLI and only runs with explicit
// opt-in. All user directories are redirected to a temporary home.
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
prepareLiveSkillsIntegration(t)
// Phase 1: Verify the real npx skills CLI is available and seed the
// isolated global skills install.
localSkills := seedLiveSkillsGlobal(t)
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
@@ -1630,26 +1766,17 @@ func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
// not exist (cold start), the update command installs all official skills and
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
// because there is no previous state to preserve user deletions from.
// This is a live integration test that calls the real npx skills CLI; it is
// skipped when npx or the skills registry is unavailable.
// This is a live integration test that calls the real npx skills CLI and only
// runs with explicit opt-in. All user directories are redirected to a temporary
// home.
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
if _, err := exec.LookPath("npx"); err != nil {
t.Skipf("npx not found in PATH: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
t.Skipf("real skills CLI unavailable: %v", err)
}
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
if err != nil {
t.Skipf("real global skills CLI unavailable: %v", err)
}
localSkills := skillscheck.ParseSkillsList(string(globalOut))
if err := ctx.Err(); err != nil {
t.Skipf("real skills CLI availability check timed out: %v", err)
}
prepareLiveSkillsIntegration(t)
// Phase 1: Verify the real npx skills CLI is available and seed one known
// official skill into the isolated global install. Cold start means no
// skills-state.json — locally installed skills may still exist, and seeding
// one keeps the Phase 4 per-skill assertions from running zero times.
localSkills := seedLiveSkillsGlobal(t)
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

@@ -12,6 +12,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
)
// whoamiResult is the structured output of `lark-cli whoami`.
@@ -54,12 +55,22 @@ type Options struct {
// local-only; when an external credential provider manages tokens, resolving
// the identity may contact that provider.
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
return newCmdWhoami(f, nil)
}
// NewCmdWhoamiWithRecovery creates whoami with a build-local recovery
// presenter while preserving NewCmdWhoami's established function signature.
func NewCmdWhoamiWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
return newCmdWhoami(f, projector)
}
func newCmdWhoami(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
opts := &Options{Factory: f}
cmd := &cobra.Command{
Use: "whoami",
Short: "Show the current effective identity, app, profile, and token status (JSON)",
RunE: func(cmd *cobra.Command, args []string) error {
return whoamiRun(cmd, opts)
return whoamiRun(cmd, opts, projector)
},
}
cmdutil.DisableAuthCheck(cmd)
@@ -73,7 +84,7 @@ func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
return cmd
}
func whoamiRun(cmd *cobra.Command, opts *Options) error {
func whoamiRun(cmd *cobra.Command, opts *Options, projector *recovery.Projector) error {
f := opts.Factory
cfg, err := f.Config()
if err != nil {
@@ -96,7 +107,10 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
f.IdentityAutoDetected,
f.ResolveStrictMode(ctx).ForcedIdentity(),
)
diag := identitydiag.Diagnose(ctx, f, cfg, false)
diag := identitydiag.FilterRecovery(
identitydiag.Diagnose(ctx, f, cfg, false),
projector.CanReference,
)
res := buildResult(cfg, as, source, diag)
output.PrintJson(f.IOStreams.Out, res)
return nil

View File

@@ -10,7 +10,6 @@ import (
"os"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/affordance"
)
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
@@ -36,6 +35,6 @@ func init() {
if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil {
fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err)
} else {
affordance.SetSource(sub)
cmd.SetEmbeddedAffordanceContent(sub)
}
}

View File

@@ -509,6 +509,9 @@ Rare; the existing structs cover the 9 Categories with room. If you must:
1. In `errs/types.go`, add a new section with: the struct embedding `errs.Problem`, a nil-receiver-safe `Unwrap()` if it carries `Cause`, a `NewXxxError(subtype, format, args...)` constructor, and one chained `WithX` setter per extension field.
2. Add an `IsXxx` predicate in `errs/predicates.go`.
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_test.go`.
4. Add the concrete type and deep-copy handling to
`internal/recovery.CloneTyped`, then extend
`TestRenderClonesEveryConcreteTypedErrorAndPreservesWireExtensions`.
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
top-level wire fields are forbidden — per-Subtype data goes into the

View File

@@ -14,6 +14,7 @@ const (
const (
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
SubtypeCommandUnavailable Subtype = "command_unavailable" // command not included in this build (integrator-restricted distribution); absent, not gated
)
// CategoryAuthentication subtypes

107
events/application/menu.go Normal file
View File

@@ -0,0 +1,107 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/event"
)
// BotMenuOutput is the flattened shape for application.bot.menu_v6.
type BotMenuOutput struct {
Type string `json:"type" desc:"Event type; always application.bot.menu_v6"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
AppID string `json:"app_id,omitempty" desc:"Application ID from the event header"`
TenantKey string `json:"tenant_key,omitempty" desc:"Tenant key from the event header"`
EventKey string `json:"event_key,omitempty" desc:"Developer-defined bot menu event key"`
MenuTimestamp string `json:"menu_timestamp,omitempty" desc:"Menu click timestamp from the event body" kind:"timestamp_ms"`
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id; kept as a short alias of operator_open_id" kind:"open_id"`
OperatorOpenID string `json:"operator_open_id,omitempty" desc:"Operator open_id" kind:"open_id"`
OperatorUnionID string `json:"operator_union_id,omitempty" desc:"Operator union_id" kind:"union_id"`
OperatorUserID string `json:"operator_user_id,omitempty" desc:"Operator user_id" kind:"user_id"`
OperatorName string `json:"operator_name,omitempty" desc:"Operator display name"`
}
func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
AppID string `json:"app_id"`
TenantKey string `json:"tenant_key"`
} `json:"header"`
Event struct {
EventKey string `json:"event_key"`
Timestamp json.RawMessage `json:"timestamp"`
Operator struct {
OperatorID struct {
OpenID string `json:"open_id"`
UnionID string `json:"union_id"`
UserID string `json:"user_id"`
} `json:"operator_id"`
OperatorName string `json:"operator_name"`
} `json:"operator"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
menuTimestamp := timestampMillisString(envelope.Event.Timestamp)
timestamp := envelope.Header.CreateTime
if timestamp == "" {
timestamp = menuTimestamp
}
operatorID := envelope.Event.Operator.OperatorID.OpenID
out := &BotMenuOutput{
Type: eventTypeBotMenuV6,
EventID: envelope.Header.EventID,
Timestamp: timestamp,
AppID: envelope.Header.AppID,
TenantKey: envelope.Header.TenantKey,
EventKey: envelope.Event.EventKey,
MenuTimestamp: menuTimestamp,
OperatorID: operatorID,
OperatorOpenID: operatorID,
OperatorUnionID: envelope.Event.Operator.OperatorID.UnionID,
OperatorUserID: envelope.Event.Operator.OperatorID.UserID,
OperatorName: envelope.Event.Operator.OperatorName,
}
return json.Marshal(out)
}
func rawScalarString(raw json.RawMessage) string {
s := strings.TrimSpace(string(raw))
if s == "" || s == "null" {
return ""
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
return text
}
return s
}
func timestampMillisString(raw json.RawMessage) string {
s := rawScalarString(raw)
if len(s) == 10 && allDigits(s) {
return s + "000"
}
return s
}
func allDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return s != ""
}

View File

@@ -0,0 +1,227 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestKeysBotMenuMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 1 {
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
}
def := keys[0]
if def.Key != eventTypeBotMenuV6 {
t.Errorf("Key = %q, want %q", def.Key, eventTypeBotMenuV6)
}
if def.EventType != eventTypeBotMenuV6 {
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeBotMenuV6)
}
if def.SubscriptionType != "" {
t.Errorf("SubscriptionType = %q, want default event subscription", def.SubscriptionType)
}
if def.Schema.Custom == nil {
t.Fatal("Schema.Custom is nil")
}
if def.Schema.Custom.Type != reflect.TypeOf(BotMenuOutput{}) {
t.Errorf("custom type = %v, want BotMenuOutput", def.Schema.Custom.Type)
}
if def.Schema.Native != nil {
t.Fatal("Schema.Native must be nil for processed output")
}
if def.Process == nil {
t.Fatal("Process is nil")
}
if !reflect.DeepEqual(def.AuthTypes, []string{"bot"}) {
t.Errorf("AuthTypes = %#v", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeBotMenuV6}) {
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
}
}
func TestBotMenuRegistersCleanly(t *testing.T) {
const key = eventTypeBotMenuV6
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
for _, def := range Keys() {
event.RegisterKey(def)
}
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}
func TestProcessBotMenu(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_001",
"event_type": "application.bot.menu_v6",
"create_time": "1776409469273",
"app_id": "cli_test",
"tenant_key": "tenant_test"
},
"event": {
"event_key": "start_eval",
"timestamp": 1776409469000,
"operator": {
"operator_id": {
"open_id": "ou_operator",
"union_id": "on_operator",
"user_id": "user_operator"
},
"operator_name": "Test User"
}
}
}`
out := runBotMenu(t, payload)
if out.Type != eventTypeBotMenuV6 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
}
if out.EventID != "ev_menu_001" {
t.Errorf("EventID = %q", out.EventID)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
if out.EventKey != "start_eval" {
t.Errorf("EventKey = %q", out.EventKey)
}
if out.MenuTimestamp != "1776409469000" {
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
}
if out.OperatorID != "ou_operator" || out.OperatorOpenID != "ou_operator" {
t.Errorf("OperatorID/OperatorOpenID = %q/%q", out.OperatorID, out.OperatorOpenID)
}
if out.OperatorUnionID != "on_operator" {
t.Errorf("OperatorUnionID = %q", out.OperatorUnionID)
}
if out.OperatorUserID != "user_operator" {
t.Errorf("OperatorUserID = %q", out.OperatorUserID)
}
if out.OperatorName != "Test User" {
t.Errorf("OperatorName = %q", out.OperatorName)
}
if out.AppID != "cli_test" || out.TenantKey != "tenant_test" {
t.Errorf("AppID/TenantKey = %q/%q", out.AppID, out.TenantKey)
}
}
func TestProcessBotMenuStringTimestampFallback(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_002",
"event_type": "application.bot.menu_v6"
},
"event": {
"event_key": "start_eval",
"timestamp": "1776409469001",
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Timestamp != "1776409469001" {
t.Errorf("Timestamp fallback = %q", out.Timestamp)
}
if out.MenuTimestamp != "1776409469001" {
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
}
}
func TestProcessBotMenuSecondsTimestampFallback(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_seconds",
"event_type": "application.bot.menu_v6"
},
"event": {
"event_key": "start_eval",
"timestamp": 1694592375,
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Timestamp != "1694592375000" {
t.Errorf("Timestamp fallback = %q, want seconds normalized to milliseconds", out.Timestamp)
}
if out.MenuTimestamp != "1694592375000" {
t.Errorf("MenuTimestamp = %q, want seconds normalized to milliseconds", out.MenuTimestamp)
}
}
func TestProcessBotMenuTypeUsesLocalConstant(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_003",
"event_type": "unexpected.event_type",
"create_time": "1776409469275"
},
"event": {
"event_key": "start_eval",
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Type != eventTypeBotMenuV6 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
}
}
func TestProcessBotMenuMalformedPayload(t *testing.T) {
raw := &event.RawEvent{
EventID: "ev_bad",
EventType: eventTypeBotMenuV6,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processBotMenu(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
func runBotMenu(t *testing.T, payload string) BotMenuOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: eventTypeBotMenuV6,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processBotMenu(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("processBotMenu: %v", err)
}
var out BotMenuOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("unmarshal output: %v\n%s", err, got)
}
return out
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package application registers Application-domain EventKeys.
package application
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const eventTypeBotMenuV6 = "application.bot.menu_v6"
// Keys returns all Application-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeBotMenuV6,
DisplayName: "Bot menu",
Description: "Triggered when a user clicks a custom bot menu item whose action is configured as a push event.",
EventType: eventTypeBotMenuV6,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(BotMenuOutput{})},
},
Process: processBotMenu,
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{eventTypeBotMenuV6},
},
}
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
type approvalEventType string
type approvalSubscriptionPath string
type approvalSubscriptionConfig struct {
eventType approvalEventType
subscribePath approvalSubscriptionPath
}
func approvalSubscriptionPreConsume(cfg approvalSubscriptionConfig) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
eventType := string(cfg.eventType)
subscribePath := string(cfg.subscribePath)
subscriptionTypes, err := approvalSubscriptionTypes(eventType, params)
if err != nil {
return nil, err
}
registered := make([]string, 0, len(subscriptionTypes))
for _, subscriptionType := range subscriptionTypes {
body := map[string]string{"subscription_type": subscriptionType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, approvalSubscriptionRegistrationError(eventType, registered, subscriptionType, err)
}
registered = append(registered, subscriptionType)
}
// Approval subscriptions are durable user-auth relations. Consuming events
// should not cancel that relation when this local process exits.
return nil, nil
}
}
func approvalSubscriptionTypes(eventType string, params map[string]string) ([]string, error) {
raw := strings.TrimSpace(params["subscription_type"])
if raw == "" {
return append([]string(nil), approvalAllSubscriptionTypes...), nil
}
values, err := parseApprovalSubscriptionTypeValues(raw)
if err != nil {
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
}
selected := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
switch value {
case approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged:
selected[value] = true
default:
return nil, invalidApprovalSubscriptionTypeError(eventType, value)
}
}
result := make([]string, 0, len(selected))
for _, value := range approvalAllSubscriptionTypes {
if selected[value] {
result = append(result, value)
}
}
if len(result) == 0 {
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
}
return result, nil
}
func parseApprovalSubscriptionTypeValues(raw string) ([]string, error) {
if strings.HasPrefix(raw, "[") {
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, err
}
return values, nil
}
return strings.Split(raw, ","), nil
}
func approvalSubscriptionRegistrationError(eventType string, registered []string, failed string, err error) error {
if err == nil {
return nil
}
msg := fmt.Sprintf(
"approval subscription pre-consume failed for EventKey %s: failed subscription_type %s",
eventType,
failed,
)
hint := fmt.Sprintf(
"no approval subscription relation was registered for EventKey %s; fix the cause and retry",
eventType,
)
if len(registered) > 0 {
msg = fmt.Sprintf(
"approval subscription pre-consume partially completed for EventKey %s: registered subscription_type(s) [%s], failed subscription_type %s",
eventType,
strings.Join(registered, ", "),
failed,
)
hint = fmt.Sprintf(
"server-side approval subscription relation(s) already registered for EventKey %s: %s; after fixing the cause, retry with --param subscription_type=%s to register the failed relation",
eventType,
strings.Join(registered, ", "),
failed,
)
}
if p, ok := errs.ProblemOf(err); ok {
if upstream := strings.TrimSpace(p.Message); upstream != "" {
p.Message = msg + ": " + upstream
} else {
p.Message = msg
}
if upstreamHint := strings.TrimSpace(p.Hint); upstreamHint != "" {
p.Hint = upstreamHint + "\n" + hint
} else {
p.Hint = hint
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).
WithHint("%s", hint).
WithCause(err)
}
func invalidApprovalSubscriptionTypeError(eventType, value string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid subscription_type for EventKey %s: %q", eventType, value).
WithParam("--param").
WithHint("omit subscription_type to register both approval subscription relations, or pass --param subscription_type=%s, --param subscription_type=%s, or --param subscription_type=%s,%s; run `lark-cli event schema %s` for details",
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
eventType)
}

179
events/approval/register.go Normal file
View File

@@ -0,0 +1,179 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package approval registers Approval-domain EventKeys.
package approval
import (
"context"
"encoding/json"
"reflect"
"github.com/larksuite/cli/internal/event"
)
const (
eventTypeApprovalInstanceStatusChangedV4 = "approval.instance.status_changed_v4"
eventTypeApprovalTaskStatusChangedV4 = "approval.task.status_changed_v4"
pathApprovalInstancesSubscription = "/open-apis/approval/v4/instances/subscription"
pathApprovalTasksSubscription = "/open-apis/approval/v4/tasks/subscription"
approvalSubscriptionTypeInvolved = "INVOLVED_APPROVAL"
approvalSubscriptionTypeManaged = "MANAGED_APPROVAL"
)
var approvalAllSubscriptionTypes = []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
}
// Keys returns all Approval-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeApprovalInstanceStatusChangedV4,
DisplayName: "Approval instance status changed",
Description: "Triggered after an approval instance status becomes visible to the requester or approval participants",
EventType: eventTypeApprovalInstanceStatusChangedV4,
Params: approvalSubscriptionParams(),
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{})},
},
Process: processApprovalInstanceStatusChanged,
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
}),
Scopes: []string{"approval:instance:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeApprovalInstanceStatusChangedV4},
},
{
Key: eventTypeApprovalTaskStatusChangedV4,
DisplayName: "Approval task status changed",
Description: "Triggered after an approval task status becomes visible to the requester or task approver",
EventType: eventTypeApprovalTaskStatusChangedV4,
Params: approvalSubscriptionParams(),
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{})},
},
Process: processApprovalTaskStatusChanged,
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
}),
Scopes: []string{"approval:task:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeApprovalTaskStatusChangedV4},
},
}
}
func approvalSubscriptionParams() []event.ParamDef {
return []event.ParamDef{
{
Name: "subscription_type",
Type: event.ParamMulti,
Description: "Approval subscription relation type(s) to register for the current authorized user. Omit to register both involved and managed approval relations.",
Values: []event.ParamValue{
{
Value: approvalSubscriptionTypeInvolved,
Desc: "Receive events where the current user is the approval requester or approver.",
},
{
Value: approvalSubscriptionTypeManaged,
Desc: "Receive events under approval definitions managed by the current user.",
},
},
},
}
}
func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
if raw == nil {
return nil, nil
}
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
ApprovalCode string `json:"approval_code"`
InstanceCode string `json:"instance_code"`
ExternalID string `json:"external_id"`
Status string `json:"status"`
OperateTime string `json:"operate_time"`
StartUser *ApprovalUserID `json:"start_user"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
out := &ApprovalInstanceStatusChangedV4Output{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
ApprovalCode: envelope.Event.ApprovalCode,
InstanceCode: envelope.Event.InstanceCode,
ExternalID: envelope.Event.ExternalID,
Status: envelope.Event.Status,
OperateTime: envelope.Event.OperateTime,
StartUser: envelope.Event.StartUser,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}
func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
if raw == nil {
return nil, nil
}
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
ApprovalCode string `json:"approval_code"`
InstanceCode string `json:"instance_code"`
TaskID string `json:"task_id"`
ExternalID string `json:"external_id"`
TaskExternalID string `json:"task_external_id"`
AssignedUser *ApprovalUserID `json:"assigned_user"`
Status string `json:"status"`
OperateTime string `json:"operate_time"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
out := &ApprovalTaskStatusChangedV4Output{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
ApprovalCode: envelope.Event.ApprovalCode,
InstanceCode: envelope.Event.InstanceCode,
TaskID: envelope.Event.TaskID,
ExternalID: envelope.Event.ExternalID,
TaskExternalID: envelope.Event.TaskExternalID,
AssignedUser: envelope.Event.AssignedUser,
Status: envelope.Event.Status,
OperateTime: envelope.Event.OperateTime,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -0,0 +1,654 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
type recordedCall struct {
method string
path string
body interface{}
}
type fakeAPIClient struct {
calls []recordedCall
err error
errOnCall int
}
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
if f.err != nil && (f.errOnCall == 0 || f.errOnCall == len(f.calls)) {
return nil, f.err
}
return json.RawMessage(`{}`), nil
}
func TestKeysApprovalMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 2 {
t.Fatalf("len(Keys()) = %d, want 2", len(keys))
}
tests := []struct {
key string
scope string
schemaType reflect.Type
subscribe string
}{
{
key: eventTypeApprovalInstanceStatusChangedV4,
scope: "approval:instance:read",
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
subscribe: pathApprovalInstancesSubscription,
},
{
key: eventTypeApprovalTaskStatusChangedV4,
scope: "approval:task:read",
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
subscribe: pathApprovalTasksSubscription,
},
}
byKey := make(map[string]event.KeyDefinition, len(keys))
for _, def := range keys {
byKey[def.Key] = def
}
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
def, ok := byKey[tc.key]
if !ok {
t.Fatalf("missing key %s", tc.key)
}
if def.EventType != tc.key {
t.Errorf("EventType = %q, want %q", def.EventType, tc.key)
}
if def.Schema.Custom == nil || def.Schema.Custom.Type != tc.schemaType {
t.Fatalf("Custom schema Type = %v, want %v", def.Schema.Custom, tc.schemaType)
}
if def.Schema.Native != nil {
t.Fatal("approval events must use Custom schema while SDK event types are not exported")
}
if def.Process == nil {
t.Fatal("Process must flatten raw V2 envelopes")
}
if def.PreConsume == nil {
t.Fatal("PreConsume must subscribe approval user-auth events")
}
if !reflect.DeepEqual(def.Scopes, []string{tc.scope}) {
t.Errorf("Scopes = %#v, want %q", def.Scopes, tc.scope)
}
if !reflect.DeepEqual(def.AuthTypes, []string{"user"}) {
t.Errorf("AuthTypes = %#v, want user", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{tc.key}) {
t.Errorf("RequiredConsoleEvents = %#v, want %q", def.RequiredConsoleEvents, tc.key)
}
assertSubscriptionParam(t, def.Params)
})
}
}
func assertSubscriptionParam(t *testing.T, params []event.ParamDef) {
t.Helper()
if len(params) != 1 {
t.Fatalf("len(params) = %d, want 1", len(params))
}
p := params[0]
if p.Name != "subscription_type" || p.Type != event.ParamMulti || p.Required || p.SubscriptionKey {
t.Fatalf("subscription_type param = %+v, want optional multi non-subscription-key param", p)
}
got := map[string]string{}
for _, v := range p.Values {
got[v.Value] = v.Desc
}
for _, want := range []string{approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged} {
if got[want] == "" {
t.Errorf("subscription_type value %q missing or empty desc; values=%+v", want, p.Values)
}
}
}
type reflectedApprovalSchema struct {
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
}
type reflectedApprovalSchemaProperty struct {
Format string `json:"format"`
Enum []string `json:"enum"`
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
}
func TestApprovalSchemasAnnotations(t *testing.T) {
tests := []struct {
name string
schemaType reflect.Type
eventType string
statusValues []string
userField string
}{
{
name: "instance",
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
eventType: eventTypeApprovalInstanceStatusChangedV4,
statusValues: []string{"PENDING", "APPROVED", "REJECTED", "CANCELED", "DELETED", "REVERTED", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
userField: "start_user",
},
{
name: "task",
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
eventType: eventTypeApprovalTaskStatusChangedV4,
statusValues: []string{"REVERTED", "PENDING", "APPROVED", "REJECTED", "TRANSFERRED", "ROLLBACK", "DONE", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
userField: "assigned_user",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var schema reflectedApprovalSchema
if err := json.Unmarshal(schemas.FromType(tc.schemaType), &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
props := schema.Properties
eventTypeEnum := props["type"].Enum
if len(eventTypeEnum) != 1 || eventTypeEnum[0] != tc.eventType {
t.Fatalf("type enum = %v, want %s", eventTypeEnum, tc.eventType)
}
if got := props["timestamp"].Format; got != "timestamp_ms" {
t.Errorf("timestamp format = %v, want timestamp_ms", got)
}
assertEnumContains(t, props["status"].Enum, tc.statusValues)
if got := props["operate_time"].Format; got != "timestamp_ms" {
t.Errorf("event.operate_time format = %v, want timestamp_ms", got)
}
userProps := props[tc.userField].Properties
if got := userProps["open_id"].Format; got != "open_id" {
t.Errorf("%s.open_id format = %v, want open_id", tc.userField, got)
}
if got := userProps["union_id"].Format; got != "union_id" {
t.Errorf("%s.union_id format = %v, want union_id", tc.userField, got)
}
if got := userProps["user_id"].Format; got != "user_id" {
t.Errorf("%s.user_id format = %v, want user_id", tc.userField, got)
}
})
}
}
func assertEnumContains(t *testing.T, raw []string, wants []string) {
t.Helper()
got := make(map[string]bool, len(raw))
for _, v := range raw {
got[v] = true
}
for _, want := range wants {
if !got[want] {
t.Errorf("enum missing %q; enum=%v", want, raw)
}
}
}
func TestApprovalPreConsumeRegistersSubscriptionTypesWithoutCleanup(t *testing.T) {
tests := []struct {
name string
eventType string
subscribePath string
params map[string]string
wantTypes []string
}{
{
name: "instance omitted subscription_type registers both",
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
{
name: "task explicit single managed",
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
params: map[string]string{"subscription_type": approvalSubscriptionTypeManaged},
wantTypes: []string{approvalSubscriptionTypeManaged},
},
{
name: "task comma separated multi canonicalizes and deduplicates",
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
params: map[string]string{
"subscription_type": approvalSubscriptionTypeManaged + "," + approvalSubscriptionTypeInvolved + "," + approvalSubscriptionTypeManaged,
},
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
{
name: "instance json array multi",
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
params: map[string]string{
"subscription_type": `["MANAGED_APPROVAL","INVOLVED_APPROVAL"]`,
},
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: approvalEventType(tc.eventType),
subscribePath: approvalSubscriptionPath(tc.subscribePath),
})
rt := &fakeAPIClient{}
cleanup, err := pc(context.Background(), rt, tc.params)
if err != nil {
t.Fatalf("PreConsume returned error: %v", err)
}
if cleanup != nil {
t.Fatal("cleanup must be nil; approval consume must not unsubscribe on exit")
}
assertSubscriptionCalls(t, rt.calls, tc.subscribePath, tc.wantTypes)
})
}
}
func assertSubscriptionCalls(t *testing.T, got []recordedCall, wantPath string, wantTypes []string) {
t.Helper()
if len(got) != len(wantTypes) {
t.Fatalf("calls after pre-consume = %d, want %d; calls=%+v", len(got), len(wantTypes), got)
}
for i, wantType := range wantTypes {
assertCall(t, got[i], "POST", wantPath, map[string]string{"subscription_type": wantType})
}
}
func assertCall(t *testing.T, got recordedCall, wantMethod, wantPath string, wantBody interface{}) {
t.Helper()
if got.method != wantMethod {
t.Errorf("method = %q, want %q", got.method, wantMethod)
}
if got.path != wantPath {
t.Errorf("path = %q, want %q", got.path, wantPath)
}
if !reflect.DeepEqual(got.body, wantBody) {
t.Errorf("body = %#v, want %#v", got.body, wantBody)
}
}
func TestApprovalPreConsumeValidationErrors(t *testing.T) {
t.Run("nil runtime", func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
})
_, err := pc(context.Background(), nil, map[string]string{"subscription_type": approvalSubscriptionTypeInvolved})
if err == nil {
t.Fatal("expected nil runtime error")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryInternal {
t.Fatalf("err = %T/%v, want typed internal error", err, err)
}
})
for _, raw := range []string{"BAD", "[]", `["INVOLVED_APPROVAL",3]`} {
t.Run("invalid subscription type "+raw, func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
})
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{"subscription_type": raw})
if err == nil {
t.Fatal("expected invalid subscription_type error")
}
if cleanup != nil {
t.Fatal("cleanup must be nil on validation error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T/%v, want *errs.ValidationError", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
t.Errorf("subtype/param = %s/%q, want invalid_argument/--param", ve.Subtype, ve.Param)
}
if ve.Hint == "" {
t.Error("invalid subscription_type should carry a hint")
}
})
}
t.Run("partial registration failure reports registered and failed relation types", func(t *testing.T) {
upstream := errs.NewAPIError(errs.SubtypeServerError, "approval subscription API failed")
rt := &fakeAPIClient{err: upstream, errOnCall: 2}
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
})
cleanup, err := pc(context.Background(), rt, map[string]string{})
if err == nil {
t.Fatal("expected partial registration error")
}
if cleanup != nil {
t.Fatal("cleanup must be nil on registration error")
}
assertSubscriptionCalls(t, rt.calls, pathApprovalTasksSubscription, []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
t.Fatalf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
}
for _, want := range []string{
"registered subscription_type(s) [INVOLVED_APPROVAL]",
"failed subscription_type MANAGED_APPROVAL",
} {
if !strings.Contains(p.Message, want) {
t.Errorf("partial error message missing %q: %q", want, p.Message)
}
}
for _, want := range []string{
"already registered",
"--param subscription_type=MANAGED_APPROVAL",
} {
if !strings.Contains(p.Hint, want) {
t.Errorf("partial error hint missing %q: %q", want, p.Hint)
}
}
})
}
func TestApprovalSubscriptionRegistrationErrorVariants(t *testing.T) {
t.Run("nil error", func(t *testing.T) {
if err := approvalSubscriptionRegistrationError(eventTypeApprovalTaskStatusChangedV4, nil, approvalSubscriptionTypeInvolved, nil); err != nil {
t.Fatalf("nil cause returned error: %v", err)
}
})
t.Run("typed error with existing hint and empty message", func(t *testing.T) {
upstream := errs.NewAPIError(errs.SubtypeServerError, "").WithHint("retry later")
err := approvalSubscriptionRegistrationError(
eventTypeApprovalTaskStatusChangedV4,
nil,
approvalSubscriptionTypeInvolved,
upstream,
)
if err != upstream {
t.Fatalf("typed error should be annotated in place; got %T/%v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if !strings.Contains(p.Message, "failed subscription_type INVOLVED_APPROVAL") {
t.Errorf("message missing failed relation: %q", p.Message)
}
for _, want := range []string{"retry later", "no approval subscription relation was registered"} {
if !strings.Contains(p.Hint, want) {
t.Errorf("hint missing %q: %q", want, p.Hint)
}
}
})
t.Run("untyped error is wrapped with retry context", func(t *testing.T) {
cause := errors.New("transport closed")
err := approvalSubscriptionRegistrationError(
eventTypeApprovalTaskStatusChangedV4,
nil,
approvalSubscriptionTypeInvolved,
cause,
)
if !errors.Is(err, cause) {
t.Fatalf("wrapped error should preserve cause; got %T/%v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeSDKError {
t.Fatalf("category/subtype = %s/%s, want internal/sdk_error", p.Category, p.Subtype)
}
if !strings.Contains(p.Hint, "no approval subscription relation was registered") {
t.Errorf("hint missing no-registration context: %q", p.Hint)
}
})
}
func TestProcessApprovalInstanceStatusChanged(t *testing.T) {
out := runApprovalInstanceStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_instance_001",
"event_type": "approval.instance.status_changed_v4",
"create_time": "1710000000000"
},
"event": {
"approval_code": "approval_code_001",
"instance_code": "instance_code_001",
"external_id": "external_001",
"status": "PENDING",
"operate_time": "1666079207003",
"start_user": {
"open_id": "ou_start",
"union_id": "on_start",
"user_id": "user_start"
}
}
}`)
if out.Type != eventTypeApprovalInstanceStatusChangedV4 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalInstanceStatusChangedV4)
}
if out.EventID != "evt_approval_instance_001" || out.Timestamp != "1710000000000" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.ApprovalCode != "approval_code_001" || out.InstanceCode != "instance_code_001" {
t.Errorf("approval/instance code = %q/%q", out.ApprovalCode, out.InstanceCode)
}
if out.ExternalID != "external_001" || out.Status != "PENDING" || out.OperateTime != "1666079207003" {
t.Errorf("external/status/operate_time = %q/%q/%q", out.ExternalID, out.Status, out.OperateTime)
}
if out.StartUser == nil || out.StartUser.OpenID != "ou_start" || out.StartUser.UnionID != "on_start" || out.StartUser.UserID != "user_start" {
t.Fatalf("StartUser = %+v, want full user ids", out.StartUser)
}
}
func TestProcessApprovalTaskStatusChanged(t *testing.T) {
out := runApprovalTaskStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_task_001",
"event_type": "approval.task.status_changed_v4",
"create_time": "1710000000001"
},
"event": {
"approval_code": "approval_code_002",
"instance_code": "instance_code_002",
"task_id": "task_001",
"external_id": "external_002",
"task_external_id": "task_external_001",
"status": "APPROVED",
"operate_time": "1666079207004",
"assigned_user": {
"open_id": "ou_assignee",
"union_id": "on_assignee",
"user_id": "user_assignee"
}
}
}`)
if out.Type != eventTypeApprovalTaskStatusChangedV4 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalTaskStatusChangedV4)
}
if out.EventID != "evt_approval_task_001" || out.Timestamp != "1710000000001" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.ApprovalCode != "approval_code_002" || out.InstanceCode != "instance_code_002" || out.TaskID != "task_001" {
t.Errorf("approval/instance/task = %q/%q/%q", out.ApprovalCode, out.InstanceCode, out.TaskID)
}
if out.ExternalID != "external_002" || out.TaskExternalID != "task_external_001" || out.Status != "APPROVED" || out.OperateTime != "1666079207004" {
t.Errorf("external/task_external/status/operate_time = %q/%q/%q/%q", out.ExternalID, out.TaskExternalID, out.Status, out.OperateTime)
}
if out.AssignedUser == nil || out.AssignedUser.OpenID != "ou_assignee" || out.AssignedUser.UnionID != "on_assignee" || out.AssignedUser.UserID != "user_assignee" {
t.Fatalf("AssignedUser = %+v, want full user ids", out.AssignedUser)
}
}
func TestProcessApprovalStatusChangedUsesRawEventTypeFallback(t *testing.T) {
instance := runApprovalInstanceStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_instance_fallback",
"create_time": "1710000000002"
},
"event": {
"approval_code": "approval_code_fallback",
"instance_code": "instance_code_fallback",
"status": "APPROVED",
"operate_time": "1666079207005"
}
}`)
if instance.Type != eventTypeApprovalInstanceStatusChangedV4 {
t.Errorf("instance Type fallback = %q, want %q", instance.Type, eventTypeApprovalInstanceStatusChangedV4)
}
task := runApprovalTaskStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_task_fallback",
"create_time": "1710000000003"
},
"event": {
"approval_code": "approval_code_fallback",
"instance_code": "instance_code_fallback",
"task_id": "task_fallback",
"status": "DONE",
"operate_time": "1666079207006"
}
}`)
if task.Type != eventTypeApprovalTaskStatusChangedV4 {
t.Errorf("task Type fallback = %q, want %q", task.Type, eventTypeApprovalTaskStatusChangedV4)
}
}
func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) {
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"instance", eventTypeApprovalInstanceStatusChangedV4, processApprovalInstanceStatusChanged},
{"task", eventTypeApprovalTaskStatusChangedV4, processApprovalTaskStatusChanged},
} {
t.Run(tc.name, func(t *testing.T) {
raw := &event.RawEvent{
EventType: tc.eventType,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := tc.process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
})
}
}
func TestProcessApprovalStatusChangedNilRaw(t *testing.T) {
for _, tc := range []struct {
name string
process event.ProcessFunc
}{
{"instance", processApprovalInstanceStatusChanged},
{"task", processApprovalTaskStatusChanged},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.process(context.Background(), nil, nil, nil)
if err != nil {
t.Fatalf("Process nil raw returned error: %v", err)
}
if got != nil {
t.Fatalf("Process nil raw output = %s, want nil", string(got))
}
})
}
}
func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInstanceStatusChangedV4Output {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeApprovalInstanceStatusChangedV4,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processApprovalInstanceStatusChanged(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process returned error: %v", err)
}
var out ApprovalInstanceStatusChangedV4Output
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid instance JSON: %v\nraw=%s", err, string(got))
}
return out
}
func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStatusChangedV4Output {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeApprovalTaskStatusChangedV4,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processApprovalTaskStatusChanged(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process returned error: %v", err)
}
var out ApprovalTaskStatusChangedV4Output
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid task JSON: %v\nraw=%s", err, string(got))
}
return out
}
func TestApprovalKeysRegisterCleanly(t *testing.T) {
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
}
for _, def := range Keys() {
event.RegisterKey(def)
}
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}
}
var _ event.APIClient = (*fakeAPIClient)(nil)

42
events/approval/types.go Normal file
View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
// ApprovalUserID identifies a user in the three Lark ID formats included by
// approval status-change events.
type ApprovalUserID struct {
OpenID string `json:"open_id,omitempty" desc:"User open_id; prefixed with ou_" kind:"open_id"`
UnionID string `json:"union_id,omitempty" desc:"User union_id" kind:"union_id"`
UserID string `json:"user_id,omitempty" desc:"User id within the tenant" kind:"user_id"`
}
// ApprovalInstanceStatusChangedV4Output is the flattened shape for
// approval.instance.status_changed_v4.
type ApprovalInstanceStatusChangedV4Output struct {
Type string `json:"type" desc:"Event type; always approval.instance.status_changed_v4" enum:"approval.instance.status_changed_v4"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval instance id; present only for third-party approvals"`
Status string `json:"status,omitempty" desc:"Approval instance status" enum:"PENDING,APPROVED,REJECTED,CANCELED,DELETED,REVERTED,OVERTIME_CLOSE,OVERTIME_RECOVER"`
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
StartUser *ApprovalUserID `json:"start_user,omitempty" desc:"Approval instance starter; omitted when unavailable"`
}
// ApprovalTaskStatusChangedV4Output is the flattened shape for
// approval.task.status_changed_v4.
type ApprovalTaskStatusChangedV4Output struct {
Type string `json:"type" desc:"Event type; always approval.task.status_changed_v4" enum:"approval.task.status_changed_v4"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
TaskID string `json:"task_id,omitempty" desc:"Approval task id"`
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval external id; present only for third-party approvals"`
TaskExternalID string `json:"task_external_id,omitempty" desc:"Third-party approval task external id; present only when emitted by the upstream service"`
AssignedUser *ApprovalUserID `json:"assigned_user,omitempty" desc:"Task assignee or operator user ids; omitted for automatic flows without an operator"`
Status string `json:"status,omitempty" desc:"Approval task status" enum:"REVERTED,PENDING,APPROVED,REJECTED,TRANSFERRED,ROLLBACK,DONE,OVERTIME_CLOSE,OVERTIME_RECOVER"`
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
}

View File

@@ -5,6 +5,8 @@
package events
import (
"github.com/larksuite/cli/events/application"
"github.com/larksuite/cli/events/approval"
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/task"
@@ -16,6 +18,8 @@ import (
// Mail is intentionally omitted in this phase.
func init() {
all := [][]event.KeyDefinition{
application.Keys(),
approval.Keys(),
im.Keys(),
minutes.Keys(),
task.Keys(),

View File

@@ -37,21 +37,79 @@ Wire into a fork:
package main
import (
"os"
_ "github.com/me/myplugin" // blank import → init() runs
"github.com/larksuite/cli/cmd"
"os"
)
func main() { os.Exit(cmd.Execute()) }
func main() {
os.Exit(cmd.Execute())
}
```
```sh
go build -o larkx ./cmd/larkx && ./larkx config plugins show
go build -o lark-cli ./cmd/larkx && ./lark-cli config plugins show
```
You should see `audit` in the plugin list.
That is sufficient for a hook-only plugin such as the audit observer. A
wrapper main does not compile lark-cli's repository-root `content_embed.go`,
so distribution content is a separate, explicit host choice.
### Ship skills and command guidance
If the distribution exposes embedded skills or customizes them with
`EmbeddedSkills`, copy or generate both content trees under the wrapper
package and wire both:
```go
package main
import (
"embed"
"io/fs"
"os"
_ "github.com/me/myplugin"
"github.com/larksuite/cli/cmd"
)
//go:embed skills affordance
var distributionContent embed.FS
func main() {
skillTree, err := fs.Sub(distributionContent, "skills")
if err != nil {
panic(err)
}
affordanceTree, err := fs.Sub(distributionContent, "affordance")
if err != nil {
panic(err)
}
cmd.SetEmbeddedSkillContent(skillTree)
cmd.SetEmbeddedAffordanceContent(affordanceTree)
os.Exit(cmd.Execute())
}
```
`go:embed` only reads files in the package being compiled; it cannot reach
into the replaced `github.com/larksuite/cli` module. Each
`skills/<name>/` must contain `SKILL.md`. The `affordance/*.md` files are the
structured source for command help and canonical skill references; ship the
ones for the domains your distribution retains. Without
`SetEmbeddedSkillContent`, `skills list` has no base content and an `Allow` or
`Remove` overlay deliberately aborts startup. A plugin may instead provide a
complete `SkillsOverlay.Base`. Without `SetEmbeddedAffordanceContent`,
commands still run, but distribution-specific guidance and its skill pointers
are absent.
Keep the executable available as `lark-cli` on `PATH`: command-linked
guidance invokes that canonical name.
## What you can hook
| Hook | Fires | Can block? |
@@ -60,6 +118,7 @@ You should see `audit` in the plugin list.
| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) |
| `On(Startup/Shutdown)` | Process lifecycle | N/A |
| `Restrict(Rule)` | Bootstrap-time, ≥1 per plugin | Denies whole subtrees |
| `EmbeddedSkills(SkillsOverlay)` | Bootstrap-time, ≤1 per plugin | Build-integrity (fail-closed) |
### Plugin lifecycle
@@ -79,7 +138,7 @@ sequenceDiagram
Host->>SDK: InstallAll()
SDK->>Plugin: Capabilities()
SDK->>Plugin: Install(Registrar)
Plugin->>SDK: Observe / Wrap / Restrict / On(Startup,Shutdown)
Plugin->>SDK: Observe / Wrap / Restrict / EmbeddedSkills / On(Startup,Shutdown)
SDK->>Plugin: On(Startup) fire
Note over Host,Plugin: Each command dispatch
@@ -93,9 +152,8 @@ sequenceDiagram
SDK->>Plugin: On(Shutdown) fire
```
A `command_denied` decision (from `Restrict` or strict-mode) bypasses
the `Wrap` chain entirely — observers still fire so audit plugins see
the rejected dispatch.
A rule or strict-mode denial bypasses the `Wrap` chain entirely —
observers still fire so audit plugins see the rejected dispatch.
## Safety contract (read this)
@@ -113,10 +171,79 @@ the rejected dispatch.
widen another's policy). YAML policy at `~/.lark-cli/policy.yml` (which
may itself list several rules under `rules:`) is shadowed by any plugin
Restrict.
- A plugin may call `EmbeddedSkills()` at most once to customize the embedded
skill tree — `Allow` keeps only the listed skills (the allow-list
counterpart of `Rule.Allow`, so a CLI upgrade cannot widen the build;
`Remove` wins over `Allow`, and `Overlay` entries are exempt), `Remove`
drops skills, `Overlay` adds/replaces ones, or swap the whole `Base`
layered over the host-provided base skill tree. The repository's root
lark-cli binary wires its default in `content_embed.go`; an external fork
main must call `cmd.SetEmbeddedSkillContent` as shown above (unless its
plugin supplies `Base`) and should wire `cmd.SetEmbeddedAffordanceContent`
for command guidance. `EmbeddedSkills()` implies `FailClosed`: it
declares distribution assets, and silently falling back could republish
content the distribution explicitly removed or replaced. Removing a skill
drops its `skills read` content and every framework-owned structured help
block that depends on it; it does NOT disable matching commands (use
`Restrict()` for that). The inverse is also explicit: concealing a command
does not automatically delete its skill content. Command policy and
distribution assets are independent axes; use `EmbeddedSkills` when both
must be trimmed.
`ReferenceRemaps` can rename a whole referenced skill while preserving
relative paths, or override one exact reference:
```go
EmbeddedSkills(&platform.SkillsOverlay{
Base: customizedSkills,
ReferenceRemaps: []platform.SkillRefRemap{
platform.RemapSkillRef("lark-doc", "acme-docx"),
platform.RemapSkillRef(
"lark-doc/references/lark-doc-fetch.md",
"acme-docx/guides/fetch.md",
),
},
})
```
Remaps apply only to structured CLI help/affordance references; they never
scan or rewrite arbitrary prose or links inside Skill Markdown. An explicit
remap to a missing target aborts startup, while an unmapped canonical
reference removed from the final tree causes its complete dependent help
block to be omitted. Only ONE plugin per binary may
contribute a `SkillsOverlay`; two DISTINCT plugins is a deliberate
`multiple_skills_overlay_plugins` error. The top-level skill set and
each skill's owning FS are snapshotted during CLI build; files inside an
owned skill directory remain live. Both `Base` and `Overlay` must
contain only valid skill directories with `SKILL.md`.
- A command denied by a Rule is hidden from normal command discovery and
returns `validation/failed_precondition` with its policy source, rule, and
reason code in the recovery hint. This is the established `Restrict`
contract for both plugin and yaml sources. A distribution that wants
plugin-restricted commands to look absent must opt in from its wrapper main
with `cmd.ExecuteWithOptions(cmd.ConcealRestrictedCommands(...))`;
presentation is a host choice, not part of `Rule` or `Capabilities`. One
carve-out: a command already retired by the user's strict-mode setting
keeps its strict-mode identity error even when a plugin Rule also
matches it — strict-mode is a user-side security boundary and is never
re-labelled.
A wrapper may customize the absent-capability message:
```go
os.Exit(cmd.ExecuteWithOptions(
cmd.ConcealRestrictedCommands(
cmd.UnavailableMessage("capability not shipped by this distribution"),
),
))
```
- `config policy show` / `config plugins show` stay executable under any
plugin policy (hidden from help when their domain is denied) so an
operator can still inspect the rule that locked the build. A concealed
distribution can remove those escape hatches with the host-side
`cmd.HidePolicyDiagnostics()` presentation option.
- The `Wrap` factory runs **once per command dispatch**, not at
install time. Long-lived state (clients, caches, metrics counters)
must live on the Plugin struct or in package-level variables.
- Plugins cannot suppress a `command_denied`: the framework
- Plugins cannot suppress a denied dispatch: the framework
physically isolates denied commands from the Wrap chain (Observers
still fire).
- Commands missing a `risk_level` annotation are denied by default
@@ -131,13 +258,20 @@ the rejected dispatch.
## reason_code reference
Every install / dispatch failure emits a `command_denied` or
`plugin_install` envelope carrying a `detail.reason_code` from the
closed enum below. Use the code (not the human-readable message) when
matching errors in agents, CI scripts, or downstream tools — the
messages are localised and may change between releases.
Install and rule evaluation keep a closed `reason_code` taxonomy for
operator diagnostics and in-process errors. The established Restrict
presentation includes the reason code in the error hint. A distribution
that explicitly enables command concealment replaces that wire presentation
with `validation/command_unavailable`.
### Plugin install (`error.type = plugin_install`)
### Plugin installation/configuration diagnostics
Fail-closed bootstrap errors that reach the CLI dispatcher use
`error.type=validation` and `error.subtype=failed_precondition`. The
diagnostic `reason_code` values below currently appear in the human-readable
hint; they are not a separate `detail` field. In-process hosts should inspect
the wrapped platform error with `errors.As` / `errors.Is` when they need the
precise cause.
| reason_code | When it fires | Honours FailurePolicy? |
| --------------------------- | ------------------------------------------------------------------------------ | ---------------------- |
@@ -145,7 +279,7 @@ messages are localised and may change between releases.
| `plugin_name_panic` | `Plugin.Name()` panicked | No — always aborts |
| `duplicate_plugin_name` | Two plugins return the same `Name()` | No — always aborts |
| `capabilities_panic` | `Plugin.Capabilities()` panicked | Yes |
| `invalid_capability` | `Capabilities` malformed: bad `RequiredCLIVersion`, unknown `FailurePolicy` | No — always aborts |
| `invalid_capability` | `Capabilities` malformed: bad version/policy, or `EmbeddedSkills` contributed under `FailOpen` | No — always aborts |
| `capability_unmet` | Current CLI version doesn't satisfy `RequiredCLIVersion` | Yes |
| `restricts_mismatch` | `Restricts=true` without `FailClosed`, or `Restricts` flag inconsistent w/ Install | No — always aborts |
| `invalid_hook_name` | Hook name contains `.` or doesn't match the plugin namespace | Yes |
@@ -153,6 +287,8 @@ messages are localised and may change between releases.
| `invalid_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes |
| `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes |
| `multiple_restrict_plugins` | Two or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) | Yes |
| `invalid_skills_overlay` | Registration fault (`nil` / duplicate call), or invalid selection/content/reference remap | Registration honours policy; composition always aborts |
| `multiple_skills_overlay_plugins` | Two or more DISTINCT plugins each contributed a `SkillsOverlay` (only one may own skill content) | No — always aborts (dispatch guard) |
| `install_failed` | `Plugin.Install` returned a non-nil error | Yes |
| `install_panic` | `Plugin.Install` panicked | Yes |
@@ -161,7 +297,7 @@ the host can't honour the plugin's declared `FailurePolicy` because the
declaration itself is suspect (e.g. an `invalid_capability` plugin
might also be lying about being `FailOpen`).
### Command dispatch (`error.type = command_denied`)
### Command rule evaluation (internal/operator diagnostics)
| reason_code | Meaning |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------- |
@@ -175,20 +311,20 @@ might also be lying about being `FailOpen`).
| `no_matching_rule` | Several rules are active and the command satisfied none of them (the message summarises each rule's own rejection). Single-rule policies keep their specific reason_code instead |
| `aggregate_all_denied` | Aggregate stub installed on a parent group because every live child was denied |
The `detail.layer` field distinguishes who rejected the call:
`policy` (this SDK's user-layer engine) vs. `strict_mode`
(`cmd/prune.go`'s credential-hardening pass). Agents that want to
dispatch on "any denial" should match `error.type == "command_denied"`
and ignore the layer; agents that only care about user-policy denials
should additionally check `detail.layer == "policy"`.
These codes remain available to in-process hosts through the wrapped
`*platform.CommandDeniedError` cause. Operator commands expose the active
rule and shipped-tree summary. Agents consuming a host that explicitly
enabled concealment should match `error.type == "validation"` and
`error.subtype == "command_unavailable"` instead of branching on a
rule-specific reason.
## Where to go next
- [Runnable example: audit observer](./examples/audit-observer/)
- [Runnable example: read-only policy](./examples/readonly-policy/)
- Builder API: see [`builder.go`](./builder.go) for the full DSL
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `FailOpen`/`FailClosed`,
`MustBuild`).
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `EmbeddedSkills`,
`FailOpen`/`FailClosed`, `MustBuild`).
- Inventory diagnostic: run `lark-cli config plugins show` after
installing your plugin to see hooks/rules attributed to your plugin
name.

View File

@@ -28,6 +28,9 @@ import (
// - Restricts ↔ FailClosed consistency (calling Restrict() implies
// FailClosed, so plugin authors cannot accidentally ship a policy
// plugin under FailOpen)
// - EmbeddedSkills ↔ FailClosed consistency (declaring distribution
// assets is a build-integrity commitment; falling back to host defaults
// is never allowed)
// - Rule validation via ValidateRule analogues (delegated to
// internal/cmdpolicy at install time; Builder only fast-fails
// blatantly bad input)
@@ -36,8 +39,9 @@ type Builder struct {
version string
caps Capabilities
actions []func(Registrar)
rules []*Rule
actions []func(Registrar)
rules []*Rule
skillsOverlay *SkillsOverlay
hookNames map[string]bool
errs []error
@@ -68,14 +72,16 @@ func (b *Builder) RequireCLI(constraint string) *Builder {
}
// FailOpen sets Capabilities.FailurePolicy = FailOpen. Default when
// neither FailOpen nor FailClosed is called and Restrict is not used.
// neither FailOpen nor FailClosed is called and neither Restrict nor
// EmbeddedSkills is used. Build rejects a final FailOpen state after either
// safety-sensitive contribution.
func (b *Builder) FailOpen() *Builder {
b.caps.FailurePolicy = FailOpen
return b
}
// FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit
// when Restrict() is called.
// FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit when
// Restrict() or EmbeddedSkills() is called.
func (b *Builder) FailClosed() *Builder {
b.caps.FailurePolicy = FailClosed
return b
@@ -145,25 +151,68 @@ func (b *Builder) Restrict(rule *Rule) *Builder {
return b
}
// EmbeddedSkills contributes a SkillsOverlay (see SkillsOverlay) customizing
// the CLI's embedded skill content. It implies FailClosed: although skill
// content is not a command-enforcement boundary, the overlay is a distribution
// build-integrity declaration. Silently skipping it could republish host
// defaults that the distribution explicitly removed or replaced.
//
// Calling FailOpen before EmbeddedSkills is allowed; EmbeddedSkills overrides
// it to FailClosed, matching Restrict. Calling FailOpen afterward leaves an
// invalid final state that Build rejects. A later FailClosed restores a valid
// final state. A plugin owns at most one SkillsOverlay, so calling
// EmbeddedSkills more than once is a build error.
func (b *Builder) EmbeddedSkills(spec *SkillsOverlay) *Builder {
if spec == nil {
b.errs = append(b.errs, errors.New("EmbeddedSkills(nil): spec must not be nil"))
return b
}
if b.skillsOverlay != nil {
b.errs = append(b.errs, errors.New("EmbeddedSkills() called more than once; a plugin owns at most one SkillsOverlay"))
return b
}
b.caps.FailurePolicy = FailClosed
b.skillsOverlay = cloneSkillsOverlay(spec)
return b
}
// cloneSkillsOverlay snapshots the caller's spec so a later mutation of the
// same *SkillsOverlay cannot alter the staged copy. Selection and remap slices
// are copied; Overlay/Base are fs.FS handles retained by reference (an fs.FS is
// a read-only view, not caller-mutable state).
func cloneSkillsOverlay(spec *SkillsOverlay) *SkillsOverlay {
cp := *spec
cp.Allow = append([]string(nil), spec.Allow...)
cp.Remove = append([]string(nil), spec.Remove...)
cp.ReferenceRemaps = append([]SkillRefRemap(nil), spec.ReferenceRemaps...)
return &cp
}
// Build returns the configured Plugin, or an error if any builder
// step found a fault. MustBuild panics on the same error.
//
// The Restrict + FailOpen mismatch is checked here, not in the chained
// setters, because the two methods may be called in either order.
// FailOpen mismatches are checked against the final builder state, not in the
// chained setters, because FailOpen/FailClosed and the contributing methods may
// be called in either order.
func (b *Builder) Build() (Plugin, error) {
if len(b.rules) > 0 && b.caps.FailurePolicy == FailOpen {
b.errs = append(b.errs, errors.New(
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
}
if b.skillsOverlay != nil && b.caps.FailurePolicy == FailOpen {
b.errs = append(b.errs, errors.New(
"EmbeddedSkills() requires FailClosed; do not call FailOpen() after EmbeddedSkills()"))
}
if len(b.errs) > 0 {
return nil, errors.Join(b.errs...)
}
return &builtPlugin{
name: b.name,
version: b.version,
caps: b.caps,
actions: b.actions,
rules: b.rules,
name: b.name,
version: b.version,
caps: b.caps,
actions: b.actions,
rules: b.rules,
skillsOverlay: b.skillsOverlay,
}, nil
}
@@ -202,11 +251,12 @@ func (b *Builder) validateHookName(hookName, kind string) bool {
// builtPlugin is the Plugin implementation the builder emits.
type builtPlugin struct {
name string
version string
caps Capabilities
actions []func(Registrar)
rules []*Rule
name string
version string
caps Capabilities
actions []func(Registrar)
rules []*Rule
skillsOverlay *SkillsOverlay
}
func (p *builtPlugin) Name() string { return p.name }
@@ -216,6 +266,15 @@ func (p *builtPlugin) Install(r Registrar) error {
for _, rule := range p.rules {
r.Restrict(rule)
}
if p.skillsOverlay != nil {
sr, ok := r.(EmbeddedSkillsRegistrar)
if !ok {
// Fail closed: a declared skill customization must never be
// silently dropped by a host that cannot honour it.
return errors.New("host registrar does not support EmbeddedSkills")
}
sr.EmbeddedSkills(p.skillsOverlay)
}
for _, action := range p.actions {
action(r)
}

View File

@@ -14,11 +14,13 @@ import (
// recorder Registrar captures everything a builder schedules so the
// test can assert what Install produced without involving the host.
type recorder struct {
observers int
wrappers int
lifecycles int
rule *platform.Rule // last rule (existing single-rule assertions)
rules []*platform.Rule // every rule, in Restrict order
observers int
wrappers int
lifecycles int
rule *platform.Rule // last rule (existing single-rule assertions)
rules []*platform.Rule // every rule, in Restrict order
skillsOverlay *platform.SkillsOverlay
skillCalls int
}
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
@@ -30,6 +32,10 @@ func (r *recorder) Restrict(rule *platform.Rule) {
r.rule = rule
r.rules = append(r.rules, rule)
}
func (r *recorder) EmbeddedSkills(spec *platform.SkillsOverlay) {
r.skillsOverlay = spec
r.skillCalls++
}
// Restrict must snapshot each rule: a caller that reuses and mutates the
// same *Rule object across two Restrict calls must still get two distinct
@@ -211,3 +217,124 @@ func TestBuilder_failOpenThenRestrictOK(t *testing.T) {
t.Errorf("FailurePolicy = %v, want FailClosed", p.Capabilities().FailurePolicy)
}
}
// EmbeddedSkills() must snapshot selection and remap slices: a caller that
// mutates the same backing arrays after the call must still get the values
// staged at call time.
func TestBuilder_skillsInstalledAndCloned(t *testing.T) {
remove := []string{"lark-shared"}
remaps := []platform.SkillRefRemap{
platform.RemapSkillRef("lark-doc", "acme-docx"),
}
spec := &platform.SkillsOverlay{Remove: remove, ReferenceRemaps: remaps}
b := platform.NewPlugin("p", "0").EmbeddedSkills(spec)
remove[0] = "mutated"
remaps[0] = platform.RemapSkillRef("lark-doc", "mutated")
spec.ReferenceRemaps = nil
p, err := b.Build()
if err != nil {
t.Fatalf("Build: %v", err)
}
r := &recorder{}
if err := p.Install(r); err != nil {
t.Fatalf("Install: %v", err)
}
if r.skillCalls != 1 {
t.Fatalf("Skills calls = %d, want 1", r.skillCalls)
}
if r.skillsOverlay == nil || len(r.skillsOverlay.Remove) != 1 || r.skillsOverlay.Remove[0] != "lark-shared" {
t.Errorf("staged Remove leaked later mutation: %+v", r.skillsOverlay)
}
if len(r.skillsOverlay.ReferenceRemaps) != 1 {
t.Fatalf("staged ReferenceRemaps = %+v, want one mapping", r.skillsOverlay.ReferenceRemaps)
}
remap := r.skillsOverlay.ReferenceRemaps[0]
if remap.From() != "lark-doc" || remap.To() != "acme-docx" {
t.Errorf("staged remap = %q -> %q, want lark-doc -> acme-docx", remap.From(), remap.To())
}
}
func TestBuilder_skillsNilRejected(t *testing.T) {
_, err := platform.NewPlugin("p", "0").EmbeddedSkills(nil).Build()
if err == nil {
t.Fatal("EmbeddedSkills(nil) must produce error")
}
}
func TestBuilder_skillsTwiceRejected(t *testing.T) {
_, err := platform.NewPlugin("p", "0").
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-b"}}).
Build()
if err == nil {
t.Fatal("calling EmbeddedSkills() twice must produce error")
}
}
// EmbeddedSkills is a distribution build-integrity commitment: skipping it
// could silently restore content that the distribution intended to remove.
func TestBuilder_skillsForcesFailClosed(t *testing.T) {
p, err := platform.NewPlugin("p", "0").EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).Build()
if err != nil {
t.Fatalf("Build: %v", err)
}
caps := p.Capabilities()
if caps.Restricts {
t.Error("EmbeddedSkills() must not set Restricts")
}
if caps.FailurePolicy != platform.FailClosed {
t.Errorf("FailurePolicy = %v, want FailClosed", caps.FailurePolicy)
}
}
func TestBuilder_skillsFailurePolicyOrder(t *testing.T) {
spec := func() *platform.SkillsOverlay {
return &platform.SkillsOverlay{Remove: []string{"lark-a"}}
}
tests := []struct {
name string
build func() *platform.Builder
wantError bool
}{
{
name: "FailOpen before EmbeddedSkills is overridden",
build: func() *platform.Builder {
return platform.NewPlugin("p", "0").FailOpen().EmbeddedSkills(spec())
},
},
{
name: "FailOpen after EmbeddedSkills is invalid",
build: func() *platform.Builder {
return platform.NewPlugin("p", "0").EmbeddedSkills(spec()).FailOpen()
},
wantError: true,
},
{
name: "later FailClosed restores a valid final state",
build: func() *platform.Builder {
return platform.NewPlugin("p", "0").EmbeddedSkills(spec()).FailOpen().FailClosed()
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p, err := tc.build().Build()
if tc.wantError {
if err == nil {
t.Fatal("Build succeeded, want FailOpen+EmbeddedSkills rejection")
}
if !strings.Contains(err.Error(), "EmbeddedSkills() requires FailClosed") {
t.Fatalf("error = %v, want EmbeddedSkills FailClosed guidance", err)
}
return
}
if err != nil {
t.Fatalf("Build: %v", err)
}
if got := p.Capabilities().FailurePolicy; got != platform.FailClosed {
t.Fatalf("FailurePolicy = %v, want FailClosed", got)
}
})
}
}

View File

@@ -13,11 +13,12 @@ const (
// where missing audit data is preferable to a broken CLI.
FailOpen FailurePolicy = iota
// FailClosed — abort the entire CLI startup. Required for any
// plugin that contributes Restrict() (a missing policy plugin =
// missing security boundary) or that owns any safety-sensitive
// concern. Enforced by the framework: Capabilities.Restricts=true
// must pair with FailurePolicy=FailClosed.
// FailClosed — abort the entire CLI startup. Required for any plugin
// that contributes Restrict() (a missing policy plugin = missing
// security boundary), EmbeddedSkills() (silently dropping distribution
// assets would violate build integrity), or any other safety-sensitive
// concern. The Builder sets it automatically for Restrict and
// EmbeddedSkills; the host validates hand-written plugins after staging.
FailClosed
)
@@ -45,6 +46,6 @@ type Capabilities struct {
// FailurePolicy decides what happens on install failure. See the
// constants above; the framework requires FailClosed whenever
// Restricts=true.
// Restricts=true or Install contributes EmbeddedSkills.
FailurePolicy FailurePolicy
}

View File

@@ -32,7 +32,9 @@
// gives a comparable rank for the read < write < high-risk-write ordering
// - CommandDeniedError - structured error returned to denied callers
//
// Stability: every exported symbol here is part of the contract. Internal
// Stability: every exported symbol here is part of the contract. Interfaces
// never gain methods; new host capability surfaces arrive as optional
// extension interfaces (see EmbeddedSkillsRegistrar). Internal
// orchestration (staging, validation, RunE wrapping, denial guard) lives
// under internal/platform, internal/hook and internal/cmdpolicy and is not
// importable by third parties.

View File

@@ -1,8 +1,8 @@
# Example: read-only policy
A policy plugin that installs a `Rule` allowing only `docs/*` and
`im/*` read commands. Any write command produces a structured
`command_denied` envelope.
`im/*` read commands. Any denied command produces a structured
`failed_precondition` envelope with policy diagnostics.
## Build & run
@@ -15,26 +15,19 @@ go build -o readonly-cli .
# "source": "plugin",
# "source_name": "readonly",
# "denied_paths": N,
# "rule": {
# "rules": [{
# "name": "agent-readonly",
# "allow": ["docs/**", "im/**"],
# "deny": [],
# "max_risk": "read",
# "identities": [],
# "allow_unannotated": false
# }
# }]
# }
./readonly-cli docs +update --doc-token X --content Y
# {"ok":false,"error":{
# "type":"command_denied",
# "detail":{
# "layer":"policy",
# "policy_source":"plugin:readonly",
# "rule_name":"agent-readonly",
# "reason_code":"write_not_allowed"
# }
# }}
./readonly-cli docs +update --doc X --content Y
# {"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
# "hint":"denied by policy policy (source plugin:readonly, ... reason_code write_not_allowed); ..."}}
./readonly-cli docs +fetch --doc-token X
# Normal read response (assuming credentials)
@@ -51,6 +44,8 @@ go build -o readonly-cli .
- `AllowUnannotated` is left default (false): unannotated commands
are denied with `risk_not_annotated`. Set it to true if you need
a gradual-adoption window for the lark-cli main tree.
- A fork that wants denied commands to present as absent can opt in from
`main` with `cmd.ExecuteWithOptions(cmd.ConcealRestrictedCommands(...))`.
## Caveats

View File

@@ -3,14 +3,15 @@
// Command readonly-policy is a runnable fork of lark-cli that
// installs a Rule permitting only docs/* and im/* read commands.
// Any write command produces a structured command_denied envelope.
// Any write command is rejected with the established Restrict policy
// envelope.
//
// Build & run:
//
// cd extension/platform/examples/readonly-policy
// go build -o readonly-cli .
// ./readonly-cli docs +update --doc-token X --content Y
// # {"ok":false,"error":{"type":"command_denied", ...}}
// # {"ok":false,"error":{"type":"validation","subtype":"failed_precondition",...}}
//
// ./readonly-cli config policy show
// # shows the active Rule with source=plugin:readonly

View File

@@ -36,3 +36,24 @@ type Registrar interface {
// plugins both calling Restrict abort startup.
Restrict(r *Rule)
}
// EmbeddedSkillsRegistrar is the optional extension a host registrar
// implements to accept embedded-skill customization. It is deliberately NOT
// part of Registrar: every exported symbol in this package is a stability
// contract, and widening Registrar would break existing third-party
// implementations (fakes, decorators, custom hosts). A Builder-built plugin
// type-asserts for this interface at Install time and fails closed when the
// host lacks it -- a declared customization is never silently dropped.
//
// Skill content has a single owner: a second customizing plugin, a FailOpen
// declaration, or a SkillsOverlay that cannot compose aborts startup
// unconditionally. EmbeddedSkills is a distribution build-integrity boundary:
// silently dropping it could republish host defaults the distribution removed.
// Removing a skill drops it from skills list/read and from structured
// framework-owned pointers, but does not disable any command; use Restrict to
// actually block a command.
type EmbeddedSkillsRegistrar interface {
// EmbeddedSkills contributes a SkillsOverlay customizing the CLI's
// embedded skill content (see SkillsOverlay).
EmbeddedSkills(spec *SkillsOverlay)
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform
import (
"context"
"testing"
)
// legacyRegistrarFake is a downstream Registrar implementation written
// BEFORE EmbeddedSkills existed. It must keep compiling: doc.go declares
// every exported symbol a stability contract, so Registrar can never widen.
// If this file goes red, a method was added to Registrar -- move it to an
// optional extension interface instead (see EmbeddedSkillsRegistrar).
type legacyRegistrarFake struct{}
func (legacyRegistrarFake) Observe(When, string, Selector, Observer) {}
func (legacyRegistrarFake) Wrap(string, Selector, Wrapper) {}
func (legacyRegistrarFake) On(LifecycleEvent, string, LifecycleHandler) {}
func (legacyRegistrarFake) Restrict(*Rule) {}
var _ Registrar = legacyRegistrarFake{}
// skillsRegistrarFake opts into the optional extension.
type skillsRegistrarFake struct {
legacyRegistrarFake
got *SkillsOverlay
}
func (f *skillsRegistrarFake) EmbeddedSkills(spec *SkillsOverlay) { f.got = spec }
var _ EmbeddedSkillsRegistrar = (*skillsRegistrarFake)(nil)
// Drive the legacy surface through the interface so the fake stays a
// faithful stand-in for downstream usage (and none of it reads as dead code).
func TestLegacyRegistrarFake_ImplementsContractSurface(t *testing.T) {
var r Registrar = legacyRegistrarFake{}
r.Observe(Before, "x.obs", All(), func(context.Context, Invocation) {})
r.Wrap("x.wrap", All(), func(next Handler) Handler { return next })
r.On(Startup, "x.boot", func(context.Context, *LifecycleContext) error { return nil })
r.Restrict(&Rule{Deny: []string{"config/**"}})
}
// A plugin that declared EmbeddedSkills fails closed against a host whose
// registrar lacks the optional extension, and succeeds against one that has it.
func TestBuiltPlugin_embeddedSkillsRequiresOptionalInterface(t *testing.T) {
p := NewPlugin("acme", "1.0").
EmbeddedSkills(&SkillsOverlay{Remove: []string{"lark-a"}}).
MustBuild()
if err := p.Install(legacyRegistrarFake{}); err == nil {
t.Error("Install must fail closed when the host cannot honour EmbeddedSkills")
}
host := &skillsRegistrarFake{}
if err := p.Install(host); err != nil {
t.Fatalf("Install: %v", err)
}
if host.got == nil || len(host.got.Remove) != 1 || host.got.Remove[0] != "lark-a" {
t.Errorf("SkillsOverlay must reach the opted-in host, got %+v", host.got)
}
}

View File

@@ -6,8 +6,9 @@ package platform
// Rule is the declarative policy rule data structure. yaml files and
// Plugin.Restrict() both produce the same Rule.
//
// At any moment there is at most one effective Rule -- the resolver decides
// which source wins (Plugin > yaml > none). This package only defines the
// At any moment there is at most one effective SOURCE of rules -- the
// resolver decides which wins (Plugin > yaml > none); the winning source
// may contribute several scoped rules. This package only defines the
// shape; selection lives in internal/cmdpolicy.
//
// The four filter fields are joined by AND. See the engine's Evaluate for

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