Compare commits

..

32 Commits

Author SHA1 Message Date
liangshuo-1
a2dde84158 chore(release): v1.0.43 (#1161) 2026-05-28 21:46:02 +08:00
hugang-lark
21998b9ca8 feat: support note generated event (#1159) 2026-05-28 21:10:14 +08:00
liangshuo-1
ce2abff8ae fix(config): propagate Lang across credential boundary; respect CurrentApp in priorLang (#1157)
Two issues caught in review of #1132 that the existing tests missed because
they constructed RuntimeContext/CliConfig directly, bypassing the credential
edge where the bug lives.

P1 — Lang dropped at credential boundary
  credential.Account had no Lang field, so AccountFromCliConfig and
  ToCliConfig silently dropped cfg.Lang. The production Factory builds
  CliConfig via acct.ToCliConfig() (factory_default.go Phase 3), which
  meant RuntimeContext.Lang() always returned "" in production and
  shortcuts/mail/mail_signature.go always fell back to zh_cn — defeating
  the whole point of persisting --lang.

  Fix: add Lang i18n.Lang to Account and copy it in both directions.

  Regression test: TestFullChain_LangSurvivesProductionPath walks the
  real path (SaveMultiAppConfig -> DefaultAccountProvider.ResolveAccount
  -> ToCliConfig) and asserts Lang survives, so any future field added
  to CliConfig forces the same audit.

P2 — priorLang ignored CurrentApp in multi-profile workspaces
  priorLang scanned all Apps and returned the first non-empty Lang. If a
  user had multiple profiles and the active one disagreed with Apps[0],
  a re-bind without --lang would silently inherit the wrong profile's
  preference.

  Fix: read multi.CurrentAppConfig("").Lang instead.

  Regression tests cover CurrentApp wins over Apps[0], single-app
  fallback, and malformed bytes.

Change-Id: If7a276605f84f398cec329c2c942b471b4c32749
2026-05-28 20:53:15 +08:00
sammi-bytedance
893555a1b1 perf(im): parallelize reactions, thread_replies, and merge_forward fetches (#1146)
Follow-up to #1095. The reactions auto-enrichment shipped, but on busy chats the strictly-serial per-resource fetches in EnrichReactions, ExpandThreadReplies, and merge_forward expansion stretched the command's wall time above 14s — enough that wrapper agents (30–60s wall-clock budgets) saw timeouts even though the CLI itself never errored. This PR parallelizes all three with the same bounded-concurrency pattern, batches the follow-up contact-API sender resolution so it doesn't fan back out into a serial stall, and fixes two correctness bugs that surfaced during review. Scoped to convert_lib/{reactions,thread,merge,content_convert}.go + tests + the 4 shortcut Execute hooks + the reference doc.

Change-Id: I0206d10ad204382170bd42aec67f82578923736e
2026-05-28 19:25:11 +08:00
YangJunzhou-01
8d496b8a48 docs: update IM skill urgent APIs (#1153)
Add support for IM urgent messages.
Change-Id: Ide2416af6d3d47d35cfd4c60b31e2137889081c6
2026-05-28 19:22:41 +08:00
HanShaoshuai-k
01fe71d7db fix(config): allow lark-channel bind source override (#1154)
Change-Id: I406ea13e372e6bdd5f3d9d6210b04ebdf0354182
2026-05-28 18:56:36 +08:00
luozhixiong01
3b770558e5 feat: decouple --lang preference from TUI display language (#1132) 2026-05-28 18:55:40 +08:00
Kyalpha
3cd84fca90 test(drive): drop redundant CONFIG_DIR isolation in inspect Execute tests (#1121)
The six TestDriveInspectExecute_* tests set
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) but build the CLI via
cmdutil.TestFactory(t, cfg), which provides an in-memory config closure
(func() (*core.CliConfig, error) { return config, nil }) and never reads the
filesystem. Per the repo learning from PR #343, this env var should only be
set for tests exercising the real NewDefault() factory path. None of these
tests use NewDefault(), so the calls are dead and removed.

No behavior change; all TestDriveInspect* tests still pass.

Co-authored-by: kyalpha313 <kyalpha313@users.noreply.github.com>
2026-05-28 17:32:31 +08:00
YangJunzhou-01
c2e737434c fix(im): clarify messages-send dry-run chat membership (#1150)
clarify messages-send dry-run chat membership
2026-05-28 16:39:00 +08:00
zgz2048
b91f6a23f3 fix: include log_id in base attachment media errors (#1133) 2026-05-28 11:54:18 +08:00
bubbmon233
bbef3cbfb1 feat(mail): HTML lint library + Larksuite-native autofix + lark-mail … (#1019)
* feat(mail): HTML lint library + Larksuite-native autofix + lark-mail skill

为 lark-cli mail 域写信链路引入 HTML lint 能力,提升邮件 HTML 的兼容性、
安全性与 Larksuite-native 格式适配。

lint 库(shortcuts/mail/lint/):
- 四档分类:pass / native-autofix / warn-autofix / error-strip
- 安全规则覆盖 script / iframe / on* 事件处理器 / javascript: 及其它
  危险 URL scheme 等 XSS 向量,未知 scheme 一律删除并归 error
- Larksuite-native 格式自动修复:双层 div 段落、原生多级列表结构、
  灰边引用、Larksuite 蓝链接
- cleaned_html 输出确定性稳定(位置索引派生 data-ol-id),便于
  golden-file 测试与缓存

+lint-html 独立预检 shortcut:
- 只读、不调 API、不建草稿,供 AI / 用户 / CI 在写信前预览 lint 结果

写入路径内置 lint(6 个 compose shortcut):
- +send / +draft-create / +draft-edit / +reply / +reply-all / +forward
  在 emlbuilder 之前强制 lint 净化 HTML
- 默认 envelope 对 lint 改动透明(无 lint 字段),保持小巧供 AI 消费;
  --show-lint-details 显式取证返回 lint_applied[] / original_blocked[]
- --body-file 支持从文件读取 body(32MB 上限),与 --body 互斥

预制 HTML 邮件模板(skills/lark-mail/assets/templates/):
- 资讯周报 / 个人周报 / 团队周报 / 调研报告 / 求职简历 5 套
- 按 Larksuite mail-editor 原生格式编写,含正确的多级列表嵌套结构

lark-mail skill 文档:
- references/lark-mail-html.md:邮件 HTML 写法指南(24 个格式 section
  + 颜色调色盘 + URL scheme + 官方模板套用流程)
- references/lark-mail-lint-html.md:+lint-html 用法
- SKILL.md 顶部 CRITICAL 引导

* fix(mail): remove unused readAttr func and apply gofmt

Drop the unused `readAttr` helper in shortcuts/mail/lint/linter.go
that was flagged by golangci-lint (unused linter). Apply gofmt to
linter.go and rules.go which had minor formatting issues.

* fix(mail): address compose lint and guidance
2026-05-27 22:23:32 +08:00
liangshuo-1
cdae999541 chore(release): v1.0.42 (#1137)
Change-Id: Id4478295cf364a01b712b7ddcd4a6cbdc264e28d
2026-05-27 20:52:24 +08:00
raistlin042
36ff632a13 fix(apps): update miaoda scopes after platform consolidation (#1127)
妙搭/spark consolidated the apps domain onto spark:app:read / spark:app:write.
The standalone spark:app:publish and spark:app.access_scope:* scopes are retired.

- +html-publish:      spark:app:publish            -> spark:app:write
- +access-scope-get:  spark:app.access_scope:read  -> spark:app:read
- +access-scope-set:  spark:app.access_scope:write -> spark:app:write

Verified against the official docs for upload_html_code_and_release,
get_app_visibility and update_app_visibility. +create/+update/+list were
already correct (spark:app:write / spark:app:read).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:51:59 +08:00
xukuncx
ab94ee9f54 feat(mail): add +draft-send shortcut for batch draft sending (#1017)
Add `lark-cli mail +draft-send` shortcut that takes one or more existing
draft IDs and sends each via POST /drafts/:draft_id/send sequentially.
Per-draft failures are isolated and aggregated into a structured output;
fatal failures (auth, permission, network, mailbox quota) abort the
entire batch immediately while recoverable failures honor --stop-on-error.

Also extend internal/output with six mail-send-specific errno constants
(LarkErrMailboxNotFound=4013, LarkErrMailSendQuota{User,UserExt,TenantExt},
LarkErrMailQuota, LarkErrTenantStorageLimit) consumed by isFatalSendErr.

Risk is "high-risk-write" so the framework's --yes gate applies; the
shortcut declares only the minimal mail:user_mailbox.message:send scope
to avoid asking users for permissions it does not need.
2026-05-27 18:12:41 +08:00
sammi-bytedance
30327abacb feat(im): enrich messages with reactions + output update_time (#1095)
- Pull messages now auto-call im.reactions.batch_query and attach a
  reactions block (counts + details) to each message. Stops AI from
  misjudging "user already reacted" as "no response yet" and
  re-sending duplicate reactions. Server caps queries[] at 20 per
  call, so messages are split into batches of size <= 20.
- Edited messages additionally surface update_time. The server echoes
  update_time == create_time for unedited messages too, so the field
  is only emitted when updated == true; otherwise every message
  output would look "edited". The value is read via an explicit
  string assertion + TrimSpace so empty strings are filtered properly
  (the previous `v != ""` was a no-op for non-string types).
- All four message-pulling shortcuts (+messages-mget,
  +chat-messages-list, +messages-search, +threads-messages-list) get
  a --no-reactions opt-out flag for callers that want to skip the
  extra round-trip.
- Each shortcut declares im:message.reactions:read on its
  UserScopes/BotScopes (or Scopes for the user-only search command) so
  the auth flow covers the new dependency.
- Each shortcut's --dry-run output now lists the
  reactions/batch_query call (or omits it when --no-reactions is set),
  so callers can audit the full set of API calls before execution.
- Warnings go through runtime.IO().ErrOut (forbidigo lint requires
  IOStreams over os.Stderr in shortcut code).
- Duplicate message_id inputs (e.g. mget --message-ids om_a,om_a)
  attach the reactions block to every entry while still querying the
  API only once per distinct id.
- EnrichReactions walks msg["thread_replies"] recursively, and mget/
  chat-messages-list call it after ExpandThreadReplies, so replies
  receive reactions in the same batched call as their parent message.
- When the batch_query call fails or returns per-message failures,
  the affected messages get reactions_error=true (mirroring the
  thread_replies_error flag from thread.go) so consumers can
  distinguish "fetch failed" from "no reactions exist" by reading
  stdout alone, without depending on the stderr warning channel.
- lark-im skill docs: the default-enrichment contract lives in a
  standalone references/lark-im-message-enrichment.md so the generated
  SKILL.md can't strand it on regeneration. The four read references
  and the raw reactions API reference link to it, and the template
  source skill-template/domains/im.md carries a durable pointer.

Change-Id: Ia9ea74b11945644262bb25c6503fb9b2003c6c98
2026-05-27 18:06:36 +08:00
sang-neo03
70081f62b1 feat: use description and command in affordance example schema (#1126)
Affordance examples previously carried a title plus a structured input
object mirroring the inputSchema. Replace that with a description plus a
command string holding a ready-to-run lark-cli invocation, which is what
an AI agent driving the CLI actually consumes.

No affordance data exists in the registry yet, so this only reshapes the
consuming AffordanceCase type and its tests; the data pipeline
(registry-config.yaml -> gen-registry.py -> meta_data.json) forwards the
new keys verbatim.
2026-05-27 16:08:21 +08:00
AlbertSun
17cbc13fcb refactor(auth): drop duplicate top-level user fields in status (#1128)
* opt: trim duplicate auth status info

* fix: update signals of auth status workflow
2026-05-27 16:07:21 +08:00
fangshuyu-768
e98471ce26 docs: document block anchor URLs in lark-doc skill (#1120) 2026-05-27 14:32:46 +08:00
sang-neo03
9e2be14301 feat(schema): output json spec envelope for all API commands (#1048)
* feat(schema): add envelope types and ordered properties container

* feat(schema): build meta_data.json key-order index for property ordering

* feat(schema): implement convertProperty with file/enum/range/nested handling

* feat(schema): build inputSchema with x-in / file binary / yes injection

* feat(schema): build outputSchema wrapping responseBody

* feat(schema): build _meta with scopes/risk/access_tokens normalization

* feat(schema): scaffold affordance overlay loader (PR-1 stub)

* feat(schema): wire up AssembleEnvelope main entry point

* feat(schema): parse dotted and space-separated path arguments

* feat(schema): batch envelope assembly with optional method filter

* feat(schema): implement L1-L3 envelope lint (structure/type/cross-field)

* feat(schema): measure L4 coverage and gate all envelopes through L1-L3

* feat(schema): add golden test harness with UPDATE_GOLDEN refresh

* test(schema): seed 20 golden envelopes covering edge cases

* feat(schema): output MCP envelope as default JSON, preserve pretty mode

Rewrites cmd/schema/schema.go so the default --format json branch emits
MCP-spec envelopes via schema.AssembleAll/AssembleService/AssembleEnvelope.
The legacy --format pretty branch is preserved verbatim and still uses
printServices / printResourceList / printMethodDetail.

Args max raised from 1 to 8 so the path can be supplied either as a single
dotted argument (im.reactions.list) or as space-separated segments
(im reactions list); both forms route through schema.ParsePath and produce
byte-identical output.

The completeSchemaPath function is extended to drive tab-completion for
both forms: legacy dotted prefix when len(args) == 0, and per-segment
resource/method completion when args already contains earlier segments.

BREAKING CHANGE: default JSON output shape changes from the raw meta_data
structure to an MCP envelope array/object. Existing scripts parsing the
old shape must either pin --format pretty or migrate to the new envelope
fields (name, description, inputSchema, outputSchema, _meta).

* test(schema): cover envelope JSON output, space-form path, yes injection

Replaces TestSchemaCmd_NoArgs with two variants reflecting the new default
shape: TestSchemaCmd_NoArgs_Pretty asserts the legacy "Available services"
text appears only under --format pretty, and TestSchemaCmd_NoArgs_JSON_IsArray
asserts the default JSON output parses as an envelope array with at least 180
entries.

Adds six new tests:
- TestSchemaCmd_JSONIsEnvelope: single-method output has name / description
  / inputSchema / outputSchema / _meta keys and envelope_version "1.0".
- TestSchemaCmd_SpaceSeparatedPath_EqualsDotted: dotted and space forms
  produce identical output bytes for the same command path.
- TestSchemaCmd_ServiceListIsArray: schema <service> returns a JSON array
  whose every entry's name starts with "<service> ".
- TestSchemaCmd_HighRiskYesInjection: high-risk-write commands inject
  inputSchema.properties.yes.
- TestSchemaCmd_NoYesForReadRisk: read-risk commands do not inject yes.
- TestSchemaCmd_PrettyUnchanged_KeyTextPresent: --format pretty still
  surfaces the legacy section markers (Parameters:, Response:, Identity:,
  Scopes:, CLI:).

* feat(schema): assemble envelope from embedded data only for stability

* chore(schema): lint cleanup

* fix(schema): preserve dotted resource segments in envelope name

Nested resources whose meta_data key contains a dot (e.g. chat.members,
user_mailbox.templates) were previously split on '.' and rejoined with
spaces, producing envelope names like 'im chat members bots'. AI
consumers doing name.split(' ') and feeding the result back as argv
got 'lark-cli im chat members bots' which the CLI rejects — the actual
invocation form is 'lark-cli im chat.members bots'.

Pass the dotted resource key as a single argv segment so the envelope
name 'im chat.members bots' round-trips through name.split(' ') back
to the CLI. Mirror the same convention in the golden harness so its
single-method assembly matches the live AssembleService walk.

* fix(schema): align MCP envelope output with JSON Schema 2020-12 contract

- coerce enum literals to typed JSON values (integer to int64,
  number to float64, boolean to bool) so type:"integer" fields no
  longer emit string enums; sort numeric/boolean enums while
  preserving meta_data order for string enums that carry semantic
  priority
- translate non-standard meta_data type:"list" to JSON Schema
  type:"array" with items:{} fallback when element shape is absent
  (covers the two mail attachment_ids fields)
- render inputSchema.required even when empty so consumers see a
  stable envelope shape ("[]" means no required fields, not "field
  is missing")
- reject trailing path segments in both JSON and pretty modes so
  schema im.messages.delete.foo errors instead of silently
  returning the delete method
- drop dead "list type" entry from lint_test isKnownDataInconsistency
  whitelist now that list values are translated upstream

* fix(schema): address CodeRabbit findings and stabilize CI tests

CI fix
- Replace hard-coded absolute key-order assertions in TestKeyOrderIndex_*
  and TestBuildInputSchema_* with set-membership and propagation invariants;
  the upstream meta_data API does not guarantee stable JSON key order across
  fetches, so the old tests were flaky on CI by design.
- Skip byte-level TestGoldenEnvelopes when CI=true; golden snapshots are a
  manual refresh artefact tied to a specific meta_data fetch, not a CI gate.
- Add TestMain to isolate registry-backed tests from any host ~/.lark-cli
  cache (LARKSUITE_CLI_CONFIG_DIR + LARKSUITE_CLI_REMOTE_META=off) so the
  suite gives the same answer on every machine.

CodeRabbit review actionables
- EmbeddedServiceNames returns a defensive copy so callers cannot mutate
  the package-level slice and affect subsequent assembly determinism.
- coerceEnumValue is now also applied to default literals: integer fields
  no longer ship default: "500" — they ship default: 500 (same idea as the
  earlier enum coercion fix).
- options-branch string enums preserve meta_data source order, matching the
  enum-branch policy; only numeric/boolean enums get sorted.
- validatePropertyTypes now validates the array element schema itself
  (type, nested items), not only items.properties — previously a primitive
  element with an invalid type (e.g. items.type="list") slipped past lint.
- OrderedProps.MarshalJSON falls back to alphabetical key order when Map
  has entries but Order is empty, instead of silently emitting {}.

Tests pass locally and with CI=true env (simulating GitHub Actions).

* chore(schema): refresh golden envelopes after meta_data drift

Re-generated with UPDATE_GOLDEN=1 against the current meta_data.json
snapshot. The bulk of the diff is upstream noise (description wording,
enum entries, field order) which the CI snapshot diff can no longer
reasonably gate (see previous commit). Side-effects of the code fixes
in the parent commit are also captured:

  - integer-typed defaults now emit numeric literals (e.g. page_size
    default 500, not "500") thanks to coerceEnumValue
  - mail.user_mailbox.templates.create _meta.risk corrects to "write"
    (assembler already emitted "write"; the old golden was stale)

* fix(schema): address CodeRabbit round-3 review findings

- TestMain: cleanup now runs reliably. os.Exit skips deferred functions,
  so the previous defer os.RemoveAll(dir) never executed. Replace defer
  with explicit cleanup, and fail fast if MkdirTemp errors instead of
  silently running against the host cache (which defeats isolation).
- convertProperty default coercion: when the literal cannot be coerced to
  the declared type (e.g. default:"" on integer field, used by meta_data
  to mean "no default"), omit the field entirely rather than emit a
  type-mismatched default. Removes a contract violation flagged on
  im.reactions.list.json#page_size.

* feat(schema): wire affordance overlay into envelope _meta

Replace the loadAffordance stub (which always returned nil and read
from an empty embedded annotations/ directory) with parseAffordance,
which lifts the affordance block from method["affordance"]. The block
is authored under larksuite-cli-registry's registry-config.yaml in the
overrides: section and flows through gen-registry.py's deep_merge into
the embedded meta_data.json.

Simplify buildMeta signature: the service/resourcePath/method args
existed only to feed the old dotted-path lookup.

Refresh 9 golden envelopes for unrelated upstream meta_data.json drift.

* refactor(schema): drop x-in extension from inputSchema

x-in (path/query/body) was an HTTP-shape leak in a CLI-facing tool spec.
AI consumers call the CLI by name with named args — they never construct
HTTP requests directly, so the path-vs-body-vs-query distinction is the
CLI's internal concern, not part of the contract.

Execution path (cmd/service/service.go) already reads location from
meta_data.json directly, so removing x-in does not affect routing.

Drop:
- Property.XIn field
- validXIn map and the two lint rules that depend on x-in
  (L1 "top-level missing x-in" and L2 "path field must be in required")
- contains() helper, no longer referenced after the path-required rule
  went away

Refresh 20 goldens for the now-absent x-in lines.

* refactor(schema): wrap inputSchema into params/data/flags sub-objects

Replace the flat inputSchema with a 3-bucket nested structure that mirrors
the CLI's actual flag layout, so AI consumers can directly map envelope
fields to lark-cli invocation:

  inputSchema:
    properties:
      params: { ...path + query fields  }   → CLI --params JSON
      data:   { ...body fields           }   → CLI --data   JSON
      flags:  { yes: ... }                  → CLI --yes (only for high-risk-write)

Each sub-object only appears when the method has the corresponding source,
so read-only GETs have a single `params` block, body-only POSTs have a
single `data` block, etc.

The `flags` wrapper carries an explicit description marking it as a CLI
control bucket (not API fields), so AI does not confuse `yes` with a
backend parameter.

Lint:
- L2 walkForL2 helper recurses into params/data sub-objects so leaf
  invariants (format:binary on non-string, min<max, required-in-properties)
  still apply.
- L3 yes-presence check now navigates flags.properties.yes.

Refresh all 20 goldens for the new shape.

* refactor(schema): drop flags wrapper, put yes at top level alongside params/data

The flags wrapper added one extra layer for a single field. Flatten so
inputSchema.properties has three siblings:

  inputSchema:
    properties:
      params: { ...path + query    }   → CLI --params
      data:   { ...body            }   → CLI --data
      yes:    { boolean, default:false }   → CLI --yes (only when risk == high-risk-write)

`yes` description strengthened to mark it as a CLI confirmation gate
(consumed by lark-cli, not sent to the backend), so AI can still
distinguish it from API fields without needing a wrapper.

Lint L3 yes-presence check goes back to top-level Properties.Map["yes"].
Refresh 20 goldens.

* feat(schema): add `file` top-level sub-object for binary upload fields

Splits file fields out of `data` into their own sibling, so the four
top-level slots in inputSchema map 1:1 to CLI flag dispatch:

  inputSchema.properties:
    params  { path + query fields }                   → --params JSON
    data    { non-file body fields }                  → --data   JSON
    file    { type:file body fields, format:binary }  → --file <key>=<path>
    yes     boolean                                   → --yes (only when risk == high-risk-write)

Each slot is conditional: only registered when the method actually has
fields for that source. This matches the CLI's own conditional flag
registration (cmd/service/service.go:170-195), so what AI sees in the
schema is exactly what flags exist for that method.

The file sub-object carries a description explaining its semantics so AI
knows to use --file for those fields rather than embedding the binary
in --data JSON.

Refresh im.images.create golden (the only file-upload method in the
golden set).

* test(schema): cover L2 lint recursion into params/data sub-objects

Add two negative test cases that stuff bad values inside the wrapped
inputSchema sub-objects (rather than at top-level), to lock in
walkForL2's recursive coverage:

  - format:binary on a non-string field nested under params
  - sub-object Required referencing a key not in its Properties

Regression guard so future walkForL2 refactors do not silently lose
recursion and let leaf-field violations slip past lint.

* fix(schema): coerce example, aggregate nested required, fix path hint

- coerce `example` literal to the declared JSON Schema type (rename
  coerceEnumValue -> coerceLiteral, drop on coerce failure to match the
  `default` policy). Without this, integer/boolean/number fields emitted
  string examples and failed strict validators.
- aggregate child field `required:true` into the enclosing nested
  object's `required[]` (both object and array-items shapes). Previously
  only the top-level params/data sub-objects scanned `required`, so
  envelopes silently under-reported the real call contract.
- check method existence before reporting trailing-segment failure in
  both JSON and pretty `schema` paths. A typo like `schema im messages
  typo extra` now reports "Unknown method: im.messages.typo" instead of
  the misleading "Method 'typo' exists but trailing segments ..." hint.
- extract risk level constants (RiskRead / RiskWrite / RiskHighRiskWrite)
  in internal/cmdutil/risk.go; replace literal usages in schema, lint,
  and confirm helpers so the typo radius is one file.
- reconcile AssembleEnvelope docstring with implementation reality (the
  package-level currentMethodOrder + assembleMu serialize concurrent
  callers; output is deterministic per inputs).
- drop testdata/golden/ and golden_test harness. End-to-end envelope
  shape regression now relies on real CLI invocations and the existing
  property-level unit + lint coverage.

* fix(schema): emit items:{} for all typeless arrays, restore lint gate

The list→array fallback only added items:{} when the source type was
"list", leaving ~64 natively-typed array fields (e.g.
approval.instances.cc.cc_user_ids) as {type:"array"} with no items.
These violated the L1 lint rule, but TestAllEnvelopesPass skipped the
"array missing items" error as a known data inconsistency, so the MCP
tool contract was not actually lint-clean.

Relax the fallback to cover every array lacking element shape regardless
of source type, and drop the lint-test skip so the gate is hard again.
2026-05-27 12:04:01 +08:00
hugang-lark
367cfc9d06 feat: support vc,note,minute event (#1113) 2026-05-26 22:17:54 +08:00
caojie0621
e182b01f68 feat(drive): add secure label shortcuts (#985) 2026-05-26 22:06:12 +08:00
SunPeiYang996
1135fc2767 fix: remove unsupported docs fetch text format (#1109)
Change-Id: I1241ba6feede813c5bfec3e6820bc0886e39dc68
2026-05-26 21:55:42 +08:00
syh-cpdsss
68d78d5067 feat: better whiteboard svg/mermaid instructions (#1097)
* feat: better whiteboard svg/mermaid instructions

Change-Id: I615cdf405840fca6bbaea1f95a37ec655fd6aedf

* fix: PR issue

Change-Id: I0a8ee556f33f0ba65812a3d73fc9c4a5266abbcd
2026-05-26 21:18:08 +08:00
liangshuo-1
b783561965 chore(release): v1.0.41 (#1108)
Change-Id: I3559c31109a5a5a7c3cfc3e54f60aff4043bfefc
2026-05-26 20:54:54 +08:00
fangshuyu-768
f00261da9f fix(drive): support doubao drive inspect URL variants (#1106) 2026-05-26 19:51:47 +08:00
zhangheng023
137176e8b0 fix: sync skills incrementally during update (#1042) 2026-05-26 19:23:08 +08:00
zhangjun-bytedance
0bf590d01a feat: get minutes keywords (#1079)
Parse keywords from minutes artifacts API in vc +notes and document
the field in lark-vc skill references.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 18:42:29 +08:00
calendar-assistant
cf40945bbc feat(minutes): add minutes edit shortcuts (#1036) 2026-05-26 18:41:50 +08:00
fangshuyu-768
b9e5b50251 docs(skills): fix agent routing for doubao.com URLs (#1082) 2026-05-26 17:41:26 +08:00
ILUO
049ddf771b docs(task): require --complete=false for pending standup summaries (#1101)
The standup workflow and the +get-my-tasks reference both implied a
"pending todo summary" use case but did not pass --complete=false in
the example commands. As a result, completed tasks were surfaced into
standup/daily summaries as if they were still pending.

This change updates the workflow and reference docs only — the
underlying command behavior is unchanged.

Closes #993
2026-05-26 16:56:40 +08:00
AlbertSun
f12d279fc2 feat: add config keychain-downgrade subcommand (macOS) (#1085)
* feat(config): add command to explicitly dowgrade keychain storage to use file

* feat(config): add command to explicitly dowgrade keychain storage to use file

* fix(lint): use the corresponding vfs.Xxx() from internal/vfs

* fix: optimize scanError && osReadDir

* opt: remove CmdConfigKeychainDowngrade wrapper & runF

* fix: add downgrade hint on keychain blocked

* opt: remove redundant ErrOrphanedCredentials

* opt: fix suggested concurrent platformSet issue
2026-05-26 16:20:33 +08:00
liangshuo-1
83adbac2b2 docs: clarify contributor guidance (#1096)
(cherry picked from commit 406e0dee6a)

Co-authored-by: JulyanXu <1581085037@qq.com>
2026-05-26 15:51:00 +08:00
199 changed files with 18464 additions and 1632 deletions

View File

@@ -9,7 +9,7 @@
## Test Plan
<!-- Describe how this change was verified. -->
- [ ] Unit tests pass
- [ ] Manual local verification confirms the `lark xxx` command works as expected
- [ ] Manual local verification confirms the `lark-cli <domain> <command>` flow works as expected
## Related Issues
<!-- Link related issues. Use Closes/Fixes to close them automatically. -->

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# Build output
/lark-cli
/lark-cli*
.cache/
dist/
bin/

View File

@@ -2,6 +2,79 @@
All notable changes to this project will be documented in this file.
## [v1.0.43] - 2026-05-28
### Features
- **event**: Support `note` generated event (#1159)
- **config**: Decouple `--lang` preference from TUI display language (#1132)
- **mail**: Add HTML lint library with Larksuite-native autofix for `lark-mail` (#1019)
### Bug Fixes
- **config**: Propagate `Lang` across credential boundary; respect `CurrentApp` in priorLang (#1157)
- **config**: Allow lark-channel bind source override (#1154)
- **im**: Clarify `messages-send` dry-run chat membership (#1150)
- **base**: Include `log_id` in attachment media errors (#1133)
### Performance
- **im**: Parallelize reactions, thread_replies, and merge_forward fetches (#1146)
### Documentation
- **im**: Update IM skill urgent APIs (#1153)
## [v1.0.42] - 2026-05-27
### Features
- **mail**: Add `+draft-send` shortcut for batch draft sending (#1017)
- **im**: Enrich messages with reactions and output `update_time` (#1095)
- **schema**: Output JSON spec envelope for all API commands (#1048)
- **event**: Support `vc` / `note` / `minute` events (#1113)
- **drive**: Add secure label shortcuts (#985)
- **affordance**: Use description and command in affordance example schema (#1126)
### Bug Fixes
- **docs**: Remove unsupported `fetch` text format (#1109)
### Refactor
- **auth**: Drop duplicate top-level user fields in `status` (#1128)
### Documentation
- **doc**: Document block anchor URLs in `lark-doc` skill (#1120)
- **whiteboard**: Improve SVG/Mermaid instructions (#1097)
## [v1.0.41] - 2026-05-26
### Features
- **minutes**: Add minutes edit shortcuts (#1036)
- **minutes**: Get minutes keywords (#1079)
- **slides**: Support importing pptx as slides (#1068)
- **config**: Add `keychain-downgrade` subcommand (macOS) (#1085)
- **errors**: Add structured CLI error contract (#984)
- **apps**: Replace `+html-publish` cwd hard-reject with credential-file scan (#1072)
### Bug Fixes
- **drive**: Support doubao drive inspect URL variants (#1106)
- **skills**: Sync skills incrementally during update (#1042)
- **apps**: Read app object from `data.app` for `+create` and `+update` (#1087)
- **common**: Escape special chars in multipart form filenames (#1037)
- **auth**: Remove fenced code block guidance from auth URL output hints (#1088)
### Documentation
- **skills**: Fix agent routing for doubao.com URLs (#1082)
- **task**: Require `--complete=false` for pending standup summaries (#1101)
- **base**: Document UI-only field settings (#1078)
- **contributing**: Clarify contributor guidance (#1096)
## [v1.0.40] - 2026-05-25
### Features
@@ -860,6 +933,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42
[v1.0.41]: https://github.com/larksuite/cli/releases/tag/v1.0.41
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39
[v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38

View File

@@ -279,6 +279,8 @@ Community contributions are welcome! If you find a bug or have feature suggestio
For major changes, we recommend discussing with us first via an Issue.
Before opening a PR, see [AGENTS.md](./AGENTS.md) for the local build, test, and PR checklist used by contributors and AI agents.
## License
This project is licensed under the **MIT License**.

View File

@@ -280,6 +280,8 @@ lark-cli schema im.messages.delete
对于较大的改动,建议先通过 Issue 与我们讨论。
提交 PR 前,请先阅读 [AGENTS.md](./AGENTS.md),其中列出了贡献者和 AI Agent 使用的本地构建、测试和 PR 检查清单。
## 许可证
本项目基于 **MIT 许可证** 开源。

View File

@@ -16,6 +16,7 @@ import (
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
@@ -121,7 +122,7 @@ func authLoginRun(opts *LoginOptions) error {
}
// Determine UI language from saved config
lang := "zh"
var lang i18n.Lang
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
if app := multi.FindApp(config.ProfileName); app != nil {
lang = app.Lang
@@ -177,7 +178,7 @@ func authLoginRun(opts *LoginOptions) error {
if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand)
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand)
if err != nil {
return err
}

View File

@@ -3,6 +3,8 @@
package auth
import "github.com/larksuite/cli/internal/i18n"
type loginMsg struct {
// Interactive UI (login_interactive.go)
SelectDomains string
@@ -115,8 +117,8 @@ var loginMsgEn = &loginMsg{
}
// getLoginMsg returns the login message bundle for the given language.
func getLoginMsg(lang string) *loginMsg {
if lang == "en" {
func getLoginMsg(lang i18n.Lang) *loginMsg {
if lang.IsEnglish() {
return loginMsgEn
}
return loginMsgZh

View File

@@ -8,6 +8,8 @@ import (
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/i18n"
)
func TestGetLoginMsg_Zh(t *testing.T) {
@@ -31,7 +33,7 @@ func TestGetLoginMsg_En(t *testing.T) {
}
func TestGetLoginMsg_DefaultsToZh(t *testing.T) {
for _, lang := range []string{"", "fr", "ja", "unknown"} {
for _, lang := range []i18n.Lang{"", "fr_fr", "ja_jp", "unknown"} {
msg := getLoginMsg(lang)
if msg != loginMsgZh {
t.Errorf("getLoginMsg(%q) should default to zh", lang)
@@ -61,7 +63,7 @@ func assertLoginMsgAllFieldsNonEmpty(t *testing.T, msg *loginMsg, label string)
}
func TestLoginMsg_FormatStrings(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
msg := getLoginMsg(lang)
// LoginSuccess should contain two %s placeholders (userName, openId)
@@ -102,10 +104,10 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
// --device-code split-flow, and (c) non-streaming harnesses must end the turn
// after presenting the URL instead of blocking in the same turn.
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
hint := getLoginMsg(lang).AgentTimeoutHint
for _, want := range []string{"--no-wait", "--device-code", "turn"} {
if lang == "zh" && want == "turn" {
if lang == i18n.LangZhCN && want == "turn" {
want = "本轮"
}
if !strings.Contains(hint, want) {

View File

@@ -61,7 +61,6 @@ func authStatusRun(opts *StatusOptions) error {
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
result["identities"] = diagnostics
result["identity"] = effectiveIdentity(diagnostics)
addLegacyUserFields(result, diagnostics.User)
addEffectiveVerification(result, diagnostics)
addStatusNote(result, diagnostics)
@@ -86,29 +85,6 @@ func effectiveIdentity(d identitydiag.Result) string {
}
}
func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) {
if user.OpenID == "" {
return
}
result["userName"] = user.UserName
result["userOpenId"] = user.OpenID
if user.TokenStatus != "" {
result["tokenStatus"] = user.TokenStatus
}
if user.Scope != "" {
result["scope"] = user.Scope
}
if user.ExpiresAt != "" {
result["expiresAt"] = user.ExpiresAt
}
if user.RefreshExpiresAt != "" {
result["refreshExpiresAt"] = user.RefreshExpiresAt
}
if user.GrantedAt != "" {
result["grantedAt"] = user.GrantedAt
}
}
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
switch result["identity"] {
case identityUser:

View File

@@ -14,6 +14,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
@@ -37,8 +38,10 @@ type BindOptions struct {
// this flag because its own prompts already require human confirmation.
Force bool
Lang string
langExplicit bool // true when --lang was explicitly passed
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateBindFlags
langExplicit bool // true when --lang was explicitly passed
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
// Brand holds the resolved Lark product brand ("feishu" | "lark") for
// the account being bound. Populated after resolveAccount; TUI stages
@@ -55,7 +58,7 @@ type BindOptions struct {
// NewCmdConfigBind creates the config bind subcommand.
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
opts := &BindOptions{Factory: f}
opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN}
cmd := &cobra.Command{
Use: "bind",
@@ -102,7 +105,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)")
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmdutil.SetRisk(cmd, "write")
return cmd
@@ -147,7 +150,7 @@ func configBindRun(opts *BindOptions) error {
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
return err
}
applyPreferences(appConfig, opts)
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
noticeUserDefaultRisk(opts)
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
@@ -202,16 +205,18 @@ func finalizeSource(opts *BindOptions) (string, error) {
// TUI: prompt for language before any downstream prompts. The source
// selection itself may still be skipped entirely if --source or the
// env already pinned it.
// env already pinned it. Picker offers 2 options (中文 / English) and
// drives BOTH opts.Lang (preference) and opts.UILang (TUI rendering).
if opts.IsTUI && !opts.langExplicit {
lang, err := promptLangSelection("")
lang, err := promptLangSelection()
if err != nil {
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", err
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
}
opts.Lang = lang
opts.Lang = string(lang)
opts.UILang = lang
}
if explicit != "" {
@@ -245,7 +250,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi
return existingBinding{}, err
}
if action == "cancel" {
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
return existingBinding{Cancelled: true}, nil
}
@@ -329,7 +334,7 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
if !hasStrictBotLock(previousConfigBytes) {
return nil
}
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
return output.ErrWithHint(output.ExitValidation, "bind",
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
}
@@ -347,14 +352,23 @@ func noticeUserDefaultRisk(opts *BindOptions) {
if opts.IsTUI || opts.Identity != "user-default" {
return
}
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
}
// applyPreferences expands the chosen identity preset into the underlying
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
// profile's intent survives later changes to global strict-mode settings.
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
// preferredLang resolves the language to persist: the requested value when set,
// otherwise the prior one — so an unset --lang never clears a stored preference.
func preferredLang(requested, prior i18n.Lang) i18n.Lang {
if requested != "" {
return requested
}
return prior
}
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) {
switch opts.Identity {
case "bot-only":
sm := core.StrictModeBot
@@ -365,9 +379,23 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
appConfig.StrictMode = &sm
appConfig.DefaultAs = core.AsUser
}
if opts.Lang != "" {
appConfig.Lang = opts.Lang
appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior)
}
// priorLang returns the language preference recorded in a previous config, or
// "" if there is none / the bytes don't parse. Reads from CurrentApp (or Apps[0]
// fallback) — scanning all apps for the first non-empty Lang would leak the
// wrong profile's preference into a re-bind when the workspace holds multiple
// named profiles and the active one disagrees with Apps[0].
func priorLang(previousConfigBytes []byte) i18n.Lang {
var multi core.MultiAppConfig
if json.Unmarshal(previousConfigBytes, &multi) != nil {
return ""
}
if app := multi.CurrentAppConfig(""); app != nil {
return app.Lang
}
return ""
}
// commitBinding finalizes the bind: atomic write of the new workspace config,
@@ -393,7 +421,10 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
}
replaced := previousConfigBytes != nil
msg := getBindMsg(opts.Lang)
// uiMsg renders human-facing TUI text (stderr success banner). Follows
// opts.UILang — zh by default; picker can flip it to en. --lang does
// not influence the TUI language.
uiMsg := getBindMsg(opts.UILang)
display := sourceDisplayName(source)
if replaced {
@@ -401,7 +432,11 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
}
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
fmt.Sprintf(msg.BindSuccessHeader, display)+"\n"+msg.BindSuccessNotice)
fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+uiMsg.BindSuccessNotice)
if opts.langExplicit && opts.Lang != "" {
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(uiMsg.LangPreferenceSet, opts.Lang))
}
// TUI mode is a human sitting at a terminal; the BindSuccess notice on
// stderr is enough and a machine-readable JSON dump on stdout is just
@@ -419,12 +454,17 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
"replaced": replaced,
"identity": opts.Identity,
}
brand := brandDisplay(string(appConfig.Brand), opts.Lang)
// JSON "message" follows the effective preference on disk (appConfig.Lang),
// not the raw --lang value: when --lang is omitted on re-bind, preferredLang
// has already inherited the prior preference into appConfig.Lang, and the
// message should respect that inherited choice. stderr above follows UILang.
prefMsg := getBindMsg(appConfig.Lang)
brand := brandDisplay(string(appConfig.Brand), appConfig.Lang)
switch opts.Identity {
case "bot-only":
envelope["message"] = fmt.Sprintf(msg.MessageBotOnly, appConfig.AppId, display, brand)
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand)
case "user-default":
envelope["message"] = fmt.Sprintf(msg.MessageUserDefault, appConfig.AppId, display, display)
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display)
}
resultJSON, _ := json.Marshal(envelope)
@@ -461,7 +501,7 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core
// tuiSelectSource prompts user to choose bind source.
func tuiSelectSource(opts *BindOptions) (string, error) {
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
var source string
// Pre-select based on detected env signals
@@ -486,7 +526,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
huh.NewGroup(
huh.NewSelect[string]().
Title(msg.SelectSource).
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.Lang))).
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.UILang))).
Options(
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
@@ -508,7 +548,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
// tuiSelectApp prompts the user to choose from multiple account candidates.
// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode.
func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) {
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
options := make([]huh.Option[int], 0, len(candidates))
for i, c := range candidates {
label := c.AppID
@@ -522,7 +562,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[int]().
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.Lang))).
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.UILang))).
Options(options...).
Value(&selected),
),
@@ -539,7 +579,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
// tuiConflictPrompt shows existing binding and asks user to Force or Cancel.
func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) {
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
// Build existing binding summary
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
@@ -591,6 +631,11 @@ func validateBindFlags(opts *BindOptions) error {
return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity)
}
}
lang, err := cmdutil.ParseLangFlag(opts.Lang)
if err != nil {
return err
}
opts.Lang = string(lang)
return nil
}
@@ -606,8 +651,8 @@ func validateBindFlags(opts *BindOptions) error {
// DescriptionFunc approach breaks here because a longer description on
// hover pushes options out of the field's initial viewport.
func tuiSelectIdentity(opts *BindOptions) (string, error) {
msg := getBindMsg(opts.Lang)
brand := brandDisplay(opts.Brand, opts.Lang)
msg := getBindMsg(opts.UILang)
brand := brandDisplay(opts.Brand, opts.UILang)
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
var value string

View File

@@ -3,6 +3,8 @@
package config
import "github.com/larksuite/cli/internal/i18n"
// bindMsg holds all TUI text for config bind, supporting zh/en via --lang.
//
// Brand-aware strings use a %s slot where the UI-friendly product name
@@ -84,6 +86,11 @@ type bindMsg struct {
// require in-flow human confirmation.
IdentityEscalationMessage string
IdentityEscalationHint string
// LangPreferenceSet is printed to stderr after a successful bind when the
// user explicitly passed --lang. Format: language code. Not printed when
// --lang was not explicit (i.e., the cobra default zh stayed in effect).
LangPreferenceSet string
}
var bindMsgZh = &bindMsg{
@@ -116,6 +123,8 @@ var bindMsgZh = &bindMsg{
IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。",
IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`",
LangPreferenceSet: "语言偏好已设置:%s",
}
var bindMsgEn = &bindMsg{
@@ -150,10 +159,13 @@ var bindMsgEn = &bindMsg{
IdentityEscalationMessage: "you are switching from bot-only to user-default — the AI will then act under your Feishu identity for all operations (docs, messages, calendar, etc.). ⚠️ Don't share this bot with others or add it to group chats. It has access to your personal Feishu data.",
IdentityEscalationHint: "if the user confirms the switch, re-run with --force: `lark-cli config bind --identity user-default --force`",
LangPreferenceSet: "Language preference set to: %s",
}
func getBindMsg(lang string) *bindMsg {
if lang == "en" {
// getBindMsg picks the zh/en TUI bundle; non-English falls back to zh.
func getBindMsg(lang i18n.Lang) *bindMsg {
if lang.IsEnglish() {
return bindMsgEn
}
return bindMsgZh
@@ -164,11 +176,11 @@ func getBindMsg(lang string) *bindMsg {
// "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in en —
// this is the safe default when the brand hasn't been resolved yet (for
// example, on the pre-binding source-selection screen).
func brandDisplay(brand, lang string) string {
func brandDisplay(brand string, lang i18n.Lang) string {
if brand == "lark" || brand == "Lark" || brand == "LARK" {
return "Lark"
}
if lang == "en" {
if lang.IsEnglish() {
return "Feishu"
}
return "飞书"

View File

@@ -16,6 +16,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
@@ -120,14 +121,229 @@ func TestConfigBindCmd_LangDefault(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Lang != "zh" {
t.Errorf("Lang = %q, want default %q", gotOpts.Lang, "zh")
if gotOpts.Lang != "" {
t.Errorf("Lang = %q, want default %q (unset)", gotOpts.Lang, "")
}
if gotOpts.langExplicit {
t.Error("expected langExplicit=false when --lang not passed")
}
}
// TestConfigBindRun_InvalidLang verifies a non-empty --lang is strictly
// validated: wrong case, typos, and removed codes all exit with
// ExitValidation (code 2) and a message identifying the offending value.
// (Empty is not invalid — see TestConfigBindRun_EmptyLangIsNoOp.)
func TestConfigBindRun_InvalidLang(t *testing.T) {
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
cases := []struct {
name string
lang string
}{
{"wrong case ZH", "ZH"},
{"typo frr", "frr"},
{"removed code ar", "ar"},
{"unknown xx", "xx"},
{"hyphen form zh-CN", "zh-CN"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Lang: tc.lang,
langExplicit: true,
})
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
}
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
}
})
}
}
// TestConfigBindRun_EmptyLangIsNoOp verifies that an empty --lang (omitted or
// explicit "") is unset: it neither errors nor persists a language, while a
// non-empty short code or Feishu locale both canonicalize to the same locale.
func TestConfigBindRun_EmptyLangIsNoOp(t *testing.T) {
cases := []struct {
name string
lang string
explicit bool
wantLang i18n.Lang
}{
{"omitted", "", false, ""},
{"explicit empty", "", true, ""},
{"short code", "ja", true, i18n.LangJaJP},
{"feishu locale", "ja_jp", true, i18n.LangJaJP},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Lang: tc.lang,
langExplicit: tc.explicit,
}); err != nil {
t.Fatalf("configBindRun(--lang %q) = %v, want nil", tc.lang, err)
}
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
app := multi.CurrentAppConfig("")
if app == nil {
t.Fatal("no app persisted")
}
if app.Lang != tc.wantLang {
t.Errorf("persisted Lang = %q, want %q", app.Lang, tc.wantLang)
}
})
}
}
// TestConfigBindRun_OmitLangPreservesPrior guards against a re-bind without
// --lang silently dropping a previously stored preference (appConfig is rebuilt
// fresh, so commitBinding must inherit the prior Lang).
func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f1, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "ja", langExplicit: true}); err != nil {
t.Fatalf("first bind (--lang ja): %v", err)
}
f2, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil {
t.Fatalf("re-bind (no --lang): %v", err)
}
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
if app := multi.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
t.Errorf("Lang after re-bind = %v, want %q (preserved)", app, i18n.LangJaJP)
}
}
// TestPriorLang_RespectsCurrentApp guards against priorLang scanning all apps
// and silently returning a non-current profile's Lang. In a multi-profile
// workspace (set up via `profile add` before a re-bind), the active profile's
// Lang must win over a sibling profile that happens to sit earlier in the slice.
func TestPriorLang_RespectsCurrentApp(t *testing.T) {
multi := core.MultiAppConfig{
CurrentApp: "active",
Apps: []core.AppConfig{
{Name: "stale", AppId: "cli_stale", Lang: i18n.LangJaJP},
{Name: "active", AppId: "cli_active", Lang: i18n.LangEnUS},
},
}
bytes, err := json.Marshal(multi)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if got := priorLang(bytes); got != i18n.LangEnUS {
t.Errorf("priorLang = %q, want %q (must follow CurrentApp, not Apps[0])", got, i18n.LangEnUS)
}
}
// TestPriorLang_FallsBackToFirstAppWhenCurrentUnset covers the legacy
// single-app shape (no CurrentApp): CurrentAppConfig falls back to Apps[0],
// so a bind-written config (which always has exactly one app and no
// CurrentApp field) still inherits its Lang.
func TestPriorLang_FallsBackToFirstAppWhenCurrentUnset(t *testing.T) {
multi := core.MultiAppConfig{
Apps: []core.AppConfig{
{AppId: "cli_only", Lang: i18n.LangJaJP},
},
}
bytes, err := json.Marshal(multi)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if got := priorLang(bytes); got != i18n.LangJaJP {
t.Errorf("priorLang = %q, want %q", got, i18n.LangJaJP)
}
}
// TestPriorLang_MalformedReturnsEmpty exercises the unparseable-bytes branch.
func TestPriorLang_MalformedReturnsEmpty(t *testing.T) {
if got := priorLang([]byte("not json")); got != "" {
t.Errorf("priorLang(malformed) = %q, want \"\"", got)
}
}
// TestConfigBindRun_EnvelopeMessageFollowsInheritedLang guards the JSON envelope
// "message" field against regressing to opts.Lang: when --lang is omitted on
// re-bind, the inherited preference (appConfig.Lang) must drive the message
// language and the embedded brand display — otherwise an AI agent that set
// English on first bind sees Chinese in every subsequent re-bind envelope.
func TestConfigBindRun_EnvelopeMessageFollowsInheritedLang(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f1, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "en", langExplicit: true}); err != nil {
t.Fatalf("first bind (--lang en): %v", err)
}
f2, stdout, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil {
t.Fatalf("re-bind (no --lang): %v", err)
}
envelope := map[string]any{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
msg, _ := envelope["message"].(string)
enMsg := getBindMsg(i18n.LangEnUS)
wantMsg := fmt.Sprintf(enMsg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", i18n.LangEnUS))
if msg != wantMsg {
t.Errorf("envelope.message = %q,\nwant %q (must follow inherited appConfig.Lang=en_us, not raw opts.Lang)", msg, wantMsg)
}
}
// ── Run function tests (aligned with TestConfigShowRun pattern) ──
func TestConfigBindRun_InvalidSource(t *testing.T) {
@@ -1474,10 +1690,14 @@ func TestGetBindMsg_En(t *testing.T) {
}
}
func TestGetBindMsg_UnknownLang_DefaultsToZh(t *testing.T) {
msg := getBindMsg("fr")
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
t.Errorf("fr (default) SelectSource = %q, want %q", msg.SelectSource, want)
func TestGetBindMsg_NonEnLang_FallsBackToZh(t *testing.T) {
// Only zh and en TUI bundles exist; any non-English language (canonical
// locale, short code, or unrecognized value) falls back to zh.
for _, lang := range []i18n.Lang{"fr_fr", "ja_jp", "ko", "unknown", ""} {
msg := getBindMsg(lang)
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
t.Errorf("getBindMsg(%q) SelectSource = %q, want %q (zh fallback)", lang, msg.SelectSource, want)
}
}
}
@@ -1640,3 +1860,36 @@ func TestHasStrictBotLock(t *testing.T) {
})
}
}
// TestConfigBindRun_LangExplicit_PrintsConfirmation covers the flag-mode
// confirmation line: when --lang is explicit, bind prints "language preference
// set" to stderr (rendered in the TUI language, embedding the preference value).
func TestConfigBindRun_LangExplicit_PrintsConfirmation(t *testing.T) {
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Identity: "bot-only",
Lang: "en",
langExplicit: true,
})
if err != nil {
t.Fatalf("expected success, got error: %v", err)
}
// The short --lang en is canonicalized to en_us before the confirmation
// echoes it back; the TUI language stays zh (flag mode, no picker).
want := fmt.Sprintf(getBindMsg(i18n.LangZhCN).LangPreferenceSet, "en_us")
if got := stderr.String(); !strings.Contains(got, want) {
t.Errorf("stderr = %q, want it to contain confirmation %q", got, want)
}
}

View File

@@ -389,10 +389,12 @@ func resolveHermesEnvPath() string {
}
// resolveLarkChannelConfigPath returns the path to lark-channel-bridge's
// config.json. Mirrors the bridge's src/config/paths.ts which hardcodes
// ~/.lark-channel/config.json with no env override — multi-instance is not
// a supported scenario today.
// source config. LARK_CHANNEL_CONFIG lets a host point bind at a projected
// single-account config without changing lark-cli's target config directory.
func resolveLarkChannelConfigPath() string {
if p := os.Getenv("LARK_CHANNEL_CONFIG"); strings.TrimSpace(p) != "" {
return expandHome(p)
}
home, err := vfs.UserHomeDir()
if err != nil || home == "" {
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)

View File

@@ -4,6 +4,7 @@
package config
import (
"path/filepath"
"reflect"
"testing"
@@ -173,3 +174,27 @@ func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) {
}
assertCandidate(t, got, Candidate{AppID: "cli_b"})
}
func TestResolveLarkChannelConfigPath_Default(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("LARK_CHANNEL_CONFIG", "")
got := resolveLarkChannelConfigPath()
want := filepath.Join(home, ".lark-channel", "config.json")
if got != want {
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
}
}
func TestResolveLarkChannelConfigPath_EnvOverride(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("LARK_CHANNEL_CONFIG", "~/bridge/projection.json")
got := resolveLarkChannelConfigPath()
want := filepath.Join(home, "bridge", "projection.json")
if got != want {
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
}
}

View File

@@ -33,6 +33,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
return cmd
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
@@ -151,8 +152,9 @@ func TestConfigInitCmd_LangFlag(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Lang != "en" {
t.Errorf("expected Lang en, got %s", gotOpts.Lang)
// --lang en is canonicalized to en_us in RunE before runF captures opts.
if gotOpts.Lang != string(i18n.LangEnUS) {
t.Errorf("expected Lang en_us, got %s", gotOpts.Lang)
}
if !gotOpts.langExplicit {
t.Error("expected langExplicit=true when --lang is passed")
@@ -173,14 +175,82 @@ func TestConfigInitCmd_LangDefault(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Lang != "zh" {
t.Errorf("expected default Lang zh, got %s", gotOpts.Lang)
if gotOpts.Lang != "" {
t.Errorf("expected default Lang to be unset (\"\"), got %q", gotOpts.Lang)
}
if gotOpts.langExplicit {
t.Error("expected langExplicit=false when --lang is not passed")
}
}
// TestSaveInitConfig_OmitLangPreservesPrior guards the single-app replace path:
// re-running init without --lang must inherit the prior preference, not clear it.
func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
existing := &core.MultiAppConfig{Apps: []core.AppConfig{
{AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP},
}}
if err := core.SaveMultiAppConfig(existing); err != nil {
t.Fatalf("seed config: %v", err)
}
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
t.Fatalf("saveInitConfig (no --lang): %v", err)
}
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
if app := got.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
t.Errorf("Lang after re-init = %v, want %q (preserved)", app, i18n.LangJaJP)
}
}
// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is
// strictly validated the same way bind validates: wrong-case / typo / removed
// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.)
func TestConfigInitCmd_InvalidLang(t *testing.T) {
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
lang string
}{
{"wrong case ZH", "ZH"},
{"typo frr", "frr"},
{"removed code ar", "ar"},
{"unknown xx", "xx"},
{"hyphen form zh-CN", "zh-CN"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigInit(f, nil)
f.IOStreams.In = strings.NewReader("sec\n")
cmd.SetArgs([]string{"--lang", tc.lang, "--app-id", "x", "--app-secret-stdin"})
err := cmd.Execute()
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
}
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
}
})
}
}
func TestHasAnyNonInteractiveFlag(t *testing.T) {
tests := []struct {
name string
@@ -412,3 +482,59 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
})
}
}
// TestValidateInitLang covers the --lang contract: empty (omitted or explicit)
// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to
// the same locale; an unrecognized value errors.
func TestValidateInitLang(t *testing.T) {
t.Run("empty is a no-op", func(t *testing.T) {
for _, explicit := range []bool{false, true} {
opts := &ConfigInitOptions{Lang: "", langExplicit: explicit}
if err := validateInitLang(opts); err != nil {
t.Fatalf("explicit=%v: expected nil error, got %v", explicit, err)
}
if opts.Lang != "" {
t.Errorf("explicit=%v: Lang = %q, want \"\" (unset)", explicit, opts.Lang)
}
}
})
t.Run("short and locale canonicalize alike", func(t *testing.T) {
for _, in := range []string{"ja", "ja_jp"} {
opts := &ConfigInitOptions{Lang: in, langExplicit: true}
if err := validateInitLang(opts); err != nil {
t.Fatalf("--lang %q: unexpected error %v", in, err)
}
if opts.Lang != string(i18n.LangJaJP) {
t.Errorf("--lang %q normalized to %q, want %q", in, opts.Lang, i18n.LangJaJP)
}
}
})
}
// TestPrintLangPreferenceConfirmation covers the confirmation helper: it prints
// to stderr only when --lang explicitly set a non-empty preference.
func TestPrintLangPreferenceConfirmation(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Run("explicit non-empty prints confirmation", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: true})
got := stderr.String()
if !strings.Contains(got, "语言偏好") || !strings.Contains(got, "en_us") {
t.Errorf("stderr = %q, want confirmation mentioning the preference and en_us", got)
}
})
t.Run("implicit prints nothing", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: false})
if got := stderr.String(); got != "" {
t.Errorf("stderr = %q, want empty when --lang is implicit", got)
}
})
t.Run("explicit empty prints nothing", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "", UILang: i18n.LangZhCN, langExplicit: true})
if got := stderr.String(); got != "" {
t.Errorf("stderr = %q, want empty when --lang is empty", got)
}
})
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
@@ -31,9 +32,13 @@ type ConfigInitOptions struct {
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
Brand string
New bool
Lang string
langExplicit bool // true when --lang was explicitly passed
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
langExplicit bool // true when --lang was explicitly passed
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
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
@@ -45,7 +50,7 @@ type ConfigInitOptions struct {
// NewCmdConfigInit creates the config init subcommand.
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
opts := &ConfigInitOptions{Factory: f}
opts := &ConfigInitOptions{Factory: f, UILang: i18n.LangZhCN}
cmd := &cobra.Command{
Use: "init",
@@ -63,6 +68,9 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
opts.langExplicit = cmd.Flags().Changed("lang")
if err := validateInitLang(opts); err != nil {
return err
}
if err := guardAgentWorkspace(opts); err != nil {
return err
}
@@ -77,7 +85,7 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
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")
cmdutil.SetRisk(cmd, "write")
@@ -85,6 +93,25 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
return cmd
}
// printLangPreferenceConfirmation echoes the set preference to stderr, only
// when --lang explicitly set a non-empty value.
func printLangPreferenceConfirmation(opts *ConfigInitOptions) {
if !opts.langExplicit || opts.Lang == "" {
return
}
msg := getInitMsg(opts.UILang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(msg.LangPreferenceSet, opts.Lang))
}
func validateInitLang(opts *ConfigInitOptions) error {
lang, err := cmdutil.ParseLangFlag(opts.Lang)
if err != nil {
return err
}
opts.Lang = string(lang)
return nil
}
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
// Hermes Agent context, because the Agent has already provisioned an app
// and 'config bind' is the right tool for hooking lark-cli into it.
@@ -132,7 +159,7 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
config := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{},
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
}},
}
return core.SaveMultiAppConfig(config)
@@ -146,7 +173,13 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
}
cleanupOldConfig(existing, f, appId)
return saveAsOnlyApp(appId, secret, brand, lang)
var prior i18n.Lang
if existing != nil {
if app := existing.CurrentAppConfig(""); app != nil {
prior = app.Lang
}
}
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
}
// saveAsProfile appends or updates a named profile in the config.
@@ -167,11 +200,10 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
}
multi.Apps[idx].Users = []core.AppUser{}
}
// Update existing profile
multi.Apps[idx].AppId = appId
multi.Apps[idx].AppSecret = secret
multi.Apps[idx].Brand = brand
multi.Apps[idx].Lang = lang
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
} else {
if findAppIndexByAppID(multi, profileName) >= 0 {
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
@@ -182,7 +214,7 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: lang,
Lang: i18n.Lang(lang),
Users: []core.AppUser{},
})
}
@@ -238,7 +270,7 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
app.AppId = appID
app.Brand = brand
app.Lang = lang
app.Lang = preferredLang(i18n.Lang(lang), app.Lang)
return core.SaveMultiAppConfig(existing)
}
@@ -283,29 +315,27 @@ func configInitRun(opts *ConfigInitOptions) error {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
return nil
}
// For interactive modes, prompt language selection if --lang was not explicitly set
// For interactive modes, prompt language selection if --lang was not explicitly set.
// Picker offers 2 options (中文 / English) and drives BOTH opts.Lang
// (preference) and opts.UILang (TUI rendering).
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
savedLang := ""
if existing != nil {
if app := existing.CurrentAppConfig(""); app != nil {
savedLang = app.Lang
}
}
lang, err := promptLangSelection(savedLang)
lang, err := promptLangSelection()
if err != nil {
if err == huh.ErrUserAborted {
return output.ErrBare(1)
}
return err
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
}
opts.Lang = lang
opts.Lang = string(lang)
opts.UILang = lang
}
msg := getInitMsg(opts.Lang)
msg := getInitMsg(opts.UILang)
// Mode 3: Create new app directly (--new)
if opts.New {
@@ -324,6 +354,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
return nil
}
@@ -366,6 +397,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if result.Mode == "existing" {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
}
printLangPreferenceConfirmation(opts)
return nil
}
@@ -452,5 +484,6 @@ func configInitRun(opts *ConfigInitOptions) error {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/i18n"
)
type initMsg struct {
@@ -26,6 +27,10 @@ type initMsg struct {
DetectedLarkTenant string
AppCreated string
ConfigSaved string
// LangPreferenceSet is printed to stderr after a successful init when the
// user explicitly passed --lang. Format: language code.
LangPreferenceSet string
}
var initMsgZh = &initMsg{
@@ -43,6 +48,7 @@ var initMsgZh = &initMsg{
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
LangPreferenceSet: "语言偏好已设置:%s",
}
var initMsgEn = &initMsg{
@@ -60,29 +66,27 @@ var initMsgEn = &initMsg{
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
AppCreated: "App configured! App ID: %s",
ConfigSaved: "App configured! App ID: %s",
LangPreferenceSet: "Language preference set to: %s",
}
func getInitMsg(lang string) *initMsg {
if lang == "en" {
// getInitMsg picks the zh/en TUI bundle; non-English falls back to zh.
func getInitMsg(lang i18n.Lang) *initMsg {
if lang.IsEnglish() {
return initMsgEn
}
return initMsgZh
}
// promptLangSelection shows an interactive language picker and returns the chosen lang code.
// savedLang is used as the pre-selected default (from existing config).
func promptLangSelection(savedLang string) (string, error) {
lang := savedLang
if lang != "en" {
lang = "zh"
}
// promptLangSelection shows the 中文/English picker and returns the chosen locale.
func promptLangSelection() (i18n.Lang, error) {
lang := i18n.LangZhCN
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
huh.NewSelect[i18n.Lang]().
Title("Language / 语言").
Options(
huh.NewOption("中文", "zh"),
huh.NewOption("English", "en"),
huh.NewOption("中文", i18n.LangZhCN),
huh.NewOption("English", i18n.LangEnUS),
).
Value(&lang),
),

View File

@@ -6,6 +6,8 @@ package config
import (
"fmt"
"testing"
"github.com/larksuite/cli/internal/i18n"
)
func TestGetInitMsg_Zh(t *testing.T) {
@@ -29,7 +31,7 @@ func TestGetInitMsg_En(t *testing.T) {
}
func TestGetInitMsg_DefaultsToZh(t *testing.T) {
for _, lang := range []string{"", "fr", "ja", "unknown"} {
for _, lang := range []i18n.Lang{"", "unknown", "xyz", "invalid"} {
msg := getInitMsg(lang)
if msg != initMsgZh {
t.Errorf("getInitMsg(%q) should default to zh", lang)
@@ -62,6 +64,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
"DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved,
"LangPreferenceSet": msg.LangPreferenceSet,
}
for name, val := range fields {
if val == "" {
@@ -71,7 +74,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
}
func TestInitMsg_FormatStrings(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
msg := getInitMsg(lang)
// AppCreated and ConfigSaved should contain %s for App ID
got := fmt.Sprintf(msg.AppCreated, "cli_test123")
@@ -84,3 +87,37 @@ func TestInitMsg_FormatStrings(t *testing.T) {
}
}
}
func TestGetInitMsg_BilingualCollapse(t *testing.T) {
// The TUI is bilingual (zh + en). Only English-bucket languages return the
// English struct — by canonical locale ("en_us") or legacy short ("en").
// Everything else (zh, the other codes, invalid, "") returns Chinese.
tests := []struct {
lang i18n.Lang
shouldBeEn bool
}{
{i18n.LangZhCN, false},
{i18n.LangEnUS, true},
{"en", true}, // legacy short value
{i18n.LangJaJP, false},
{"fr_fr", false},
{"invalid", false},
{"", false},
}
for _, tt := range tests {
t.Run(string(tt.lang), func(t *testing.T) {
msg := getInitMsg(tt.lang)
if msg == nil {
t.Fatal("getInitMsg returned nil")
}
want := initMsgZh
if tt.shouldBeEn {
want = initMsgEn
}
if msg != want {
t.Errorf("getInitMsg(%q) returned wrong struct", tt.lang)
}
})
}
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build darwin
package config
import (
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
// NewCmdConfigKeychainDowngrade creates the macOS-only subcommand that pins
// the master key to the local file fallback (master.key.file) so subsequent
// operations bypass the OS Keychain. Useful inside sandboxes like Codex
// where the system Keychain is unreachable.
func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "keychain-downgrade",
Short: "Downgrade keychain storage to a local file (macOS only)",
Long: `Materialize the master key from the macOS system Keychain into a local file
under ~/Library/Application Support/lark-cli/master.key.file, then pin all
subsequent reads to that file.
Intended workflow: run this once from an interactive Terminal session on
macOS (where the system Keychain is reachable). After it finishes,
sandboxed / automation / CI runs of lark-cli on the same machine will read
the master key from the local file and no longer need the OS Keychain.
This is the supported fix for environments like the Codex sandbox where the
system Keychain is blocked. Running keychain-downgrade from inside such a
sandbox will itself fail with "keychain access blocked" — that is expected;
run it from an interactive macOS session instead.
The OS Keychain entry is preserved as a cold backup; nothing is deleted there.
The command is idempotent: re-running it on an already-downgraded install
reports "already downgraded" and exits 0.`,
RunE: func(cmd *cobra.Command, args []string) error {
return configKeychainDowngradeRun(f)
},
}
cmdutil.SetRisk(cmd, "write")
return cmd
}
func configKeychainDowngradeRun(f *cmdutil.Factory) error {
service := keychain.LarkCliService
keyPath := keychain.MasterKeyFilePath(service)
result, err := keychain.DowngradeMasterKeyToFile(service)
if err != nil {
return output.ErrWithHint(
output.ExitAPI,
"config",
fmt.Sprintf("keychain downgrade failed: %v", err),
"This command must be run from an interactive macOS session (e.g. Terminal.app or iTerm) where the system Keychain is reachable. Running it from inside a sandbox / automation context that blocks Keychain access cannot succeed by design.",
)
}
switch result {
case keychain.DowngradeAlreadyDone:
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("keychain already downgraded; subsequent operations read from %s", keyPath))
case keychain.DowngradeUsedKeychainKey:
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("downgraded: copied master key from system Keychain to %s. Subsequent operations will read from file, bypassing the OS Keychain (useful inside sandboxes like Codex).", keyPath))
case keychain.DowngradeCreatedNewKey:
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("system Keychain was empty; generated a new master key and wrote it to %s. The OS Keychain was not modified.", keyPath))
}
return nil
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !darwin
package config
import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
// NewCmdConfigKeychainDowngrade is registered on all platforms so that
// `lark-cli config --help` reads the same everywhere. On non-macOS it
// refuses with a clear message.
func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
_ = f
cmd := &cobra.Command{
Use: "keychain-downgrade",
Short: "Downgrade keychain storage to a local file (macOS only)",
Long: `Downgrade keychain storage to a local file. This subcommand is only supported on macOS; on this platform the keychain layer already uses local files.`,
RunE: func(cmd *cobra.Command, args []string) error {
return output.ErrValidation("keychain-downgrade is only supported on macOS")
},
}
return cmd
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
@@ -40,7 +41,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().StringVar(&appID, "app-id", "", "App ID (required)")
cmd.Flags().BoolVar(&appSecretStdin, "app-secret-stdin", false, "read App Secret from stdin")
cmd.Flags().StringVar(&brand, "brand", "feishu", "feishu or lark")
cmd.Flags().StringVar(&lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().StringVar(&lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding")
_ = cmd.MarkFlagRequired("name")
@@ -55,6 +56,12 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
return output.ErrValidation("%v", err)
}
langPref, err := cmdutil.ParseLangFlag(lang)
if err != nil {
return err
}
lang = string(langPref)
// Read secret from stdin
if !appSecretStdin {
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
@@ -115,7 +122,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
AppId: appID,
AppSecret: secret,
Brand: parsedBrand,
Lang: lang,
Lang: i18n.Lang(lang),
Users: []core.AppUser{},
})

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs"
)
@@ -51,6 +52,56 @@ func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
}
}
// TestProfileAddRun_Lang covers the unified --lang contract on profile add:
// short codes and Feishu locales both canonicalize to the same stored locale,
// empty stores no preference, and an unrecognized value errors.
func TestProfileAddRun_Lang(t *testing.T) {
t.Run("short and locale canonicalize and persist alike", func(t *testing.T) {
for _, in := range []string{"ja", "ja_jp"} {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
if err := profileAddRun(f, "p", "app-p", true, "feishu", in, false); err != nil {
t.Fatalf("--lang %q: profileAddRun() error = %v", in, err)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if app := saved.FindApp("p"); app == nil || app.Lang != i18n.LangJaJP {
t.Errorf("--lang %q: stored Lang = %v, want %q", in, app, i18n.LangJaJP)
}
}
})
t.Run("empty stores no preference", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
if err := profileAddRun(f, "p", "app-p", true, "feishu", "", false); err != nil {
t.Fatalf("profileAddRun() error = %v", err)
}
saved, _ := core.LoadMultiAppConfig()
if app := saved.FindApp("p"); app == nil || app.Lang != "" {
t.Errorf("stored Lang = %v, want \"\" (unset)", app)
}
})
t.Run("invalid lang errors", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "p", "app-p", true, "feishu", "ZH", false)
if err == nil {
t.Fatal("expected validation error for --lang ZH, got nil")
}
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Code != output.ExitValidation {
t.Fatalf("expected ExitValidation, got %T: %v", err, err)
}
})
}
func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{

View File

@@ -384,11 +384,8 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
})
}
// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp
// produces no skills key in the composed notice. Users who installed
// skills via `npx skills add` (no stamp) must not see the misleading
// "not installed" notice — only `lark-cli update` users opt into the
// drift tracker.
// TestSetupNotices_ColdStart_NoNotice verifies that missing state
// produces no skills key in the composed notice.
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
@@ -419,13 +416,13 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
}
}
// TestSetupNotices_InSync verifies that a matching stamp produces no
// TestSetupNotices_InSync verifies that matching state produces no
// skills key in the composed notice.
func TestSetupNotices_InSync(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
@@ -452,13 +449,13 @@ func TestSetupNotices_InSync(t *testing.T) {
}
}
// TestSetupNotices_Drift verifies a mismatching stamp produces the
// TestSetupNotices_Drift verifies mismatching state produces the
// drift message with both current and target populated.
func TestSetupNotices_Drift(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
t.Fatal(err)
}
@@ -507,7 +504,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
t.Fatal(err)
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/schema"
"github.com/larksuite/cli/internal/util"
"github.com/spf13/cobra"
)
@@ -24,7 +25,8 @@ type SchemaOptions struct {
Ctx context.Context
// Positional args
Path string
Path string // first positional, when only one is given
ExtraArgs []string // 2nd+ positional args (space-separated form)
// Flags
Format string
@@ -359,13 +361,16 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
opts := &SchemaOptions{Factory: f}
cmd := &cobra.Command{
Use: "schema [path]",
Use: "schema [path | service resource method]",
Short: "View API method parameters, types, and scopes",
Args: cobra.MaximumNArgs(1),
Args: cobra.MaximumNArgs(8),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
opts.Path = args[0]
}
if len(args) > 1 {
opts.ExtraArgs = args[1:]
}
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
@@ -380,60 +385,108 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, "read")
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// completeSchemaPath provides tab-completion for the schema path argument.
// It handles dotted resource names (e.g. app.table.fields) by iterating all
// resources and classifying each as a prefix-match or fully-matched.
// It handles both legacy dotted resource names (e.g. app.table.fields) and the
// newer space-separated form (e.g. `schema im messages reply`).
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
mode := f.ResolveStrictMode(cmd.Context())
parts := strings.Split(toComplete, ".")
// Level 1: complete service names
if len(parts) <= 1 {
var completions []string
for _, s := range registry.ListFromMetaProjects() {
if strings.HasPrefix(s, toComplete) {
completions = append(completions, s+".")
// Case 1: legacy "single dotted arg" path — no previous args yet
if len(args) == 0 {
parts := strings.Split(toComplete, ".")
if len(parts) <= 1 {
var completions []string
for _, s := range registry.ListFromMetaProjects() {
if strings.HasPrefix(s, toComplete) {
completions = append(completions, s+".")
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
spec = filterSpecByStrictMode(spec, mode)
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
afterService := strings.Join(parts[1:], ".")
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
allTrailingDot := len(completions) > 0
for _, c := range completions {
if !strings.HasSuffix(c, ".") {
allTrailingDot = false
break
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
directive := cobra.ShellCompDirectiveNoFileComp
if allTrailingDot {
directive |= cobra.ShellCompDirectiveNoSpace
}
return completions, directive
}
serviceName := parts[0]
// Case 2: space-form, args already has segments
// Walk down service -> resource(s) -> method based on existing args
serviceName := args[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
mode := f.ResolveStrictMode(cmd.Context())
spec = filterSpecByStrictMode(spec, mode)
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
afterService := strings.Join(parts[1:], ".")
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
allTrailingDot := len(completions) > 0
for _, c := range completions {
if !strings.HasSuffix(c, ".") {
allTrailingDot = false
break
// args[1:] are resource path segments (possibly partial); current
// toComplete is the next segment under cursor.
consumed := args[1:]
resource, _, remaining := findResourceByPath(resources, consumed)
if resource == nil {
// Suggest top-level resource names that match toComplete
var completions []string
for resName := range resources {
if strings.HasPrefix(resName, toComplete) {
completions = append(completions, resName)
}
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
}
if len(remaining) > 0 {
// Already typed past the resource — suggest methods
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
var completions []string
for mName := range methods {
if strings.HasPrefix(mName, toComplete) {
completions = append(completions, mName)
}
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
}
// Resource matched exactly, suggest methods
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
var completions []string
for mName := range methods {
if strings.HasPrefix(mName, toComplete) {
completions = append(completions, mName)
}
}
directive := cobra.ShellCompDirectiveNoFileComp
if allTrailingDot {
directive |= cobra.ShellCompDirectiveNoSpace
}
return completions, directive
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
}
}
@@ -469,94 +522,231 @@ func schemaRun(opts *SchemaOptions) error {
out := opts.Factory.IOStreams.Out
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
if opts.Path == "" {
printServices(out)
return nil
// args may have arrived as a single string (legacy single-arg path) or
// split into multiple — normalize to a single args slice.
var rawArgs []string
if opts.Path != "" {
rawArgs = []string{opts.Path}
}
parts := strings.Split(opts.Path, ".")
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown service: %s", serviceName),
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
}
if len(parts) == 1 {
if opts.Format == "pretty" {
printResourceList(out, spec, mode)
if len(opts.ExtraArgs) > 0 {
if opts.Path != "" {
rawArgs = append([]string{opts.Path}, opts.ExtraArgs...)
} else {
output.PrintJson(out, filterSpecByStrictMode(spec, mode))
rawArgs = append([]string(nil), opts.ExtraArgs...)
}
return nil
}
parts := schema.ParsePath(rawArgs)
if opts.Format == "pretty" {
return runPrettyMode(out, parts, mode)
}
return runJSONMode(out, parts, mode)
}
// runJSONMode dispatches list/single envelope output based on parts.
// JSON mode uses embedded data only (bypasses remote overlay) so envelope
// output is deterministic across machines.
func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error {
filter := strictModeFilter(mode)
switch len(parts) {
case 0:
envs := schema.AssembleAll(filter)
output.PrintJson(out, envs)
return nil
case 1:
spec := registry.EmbeddedSpec(parts[0])
if spec == nil {
return errUnknownEmbeddedService(parts[0])
}
envs := schema.AssembleService(parts[0], spec, filter)
output.PrintJson(out, envs)
return nil
default:
return runJSONForPath(out, parts, filter)
}
}
// runJSONForPath handles len(parts) >= 2: try resource match first, fallback
// to single-method match. Uses embedded data only.
func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error {
serviceName := parts[0]
spec := registry.EmbeddedSpec(serviceName)
if spec == nil {
return errUnknownEmbeddedService(serviceName)
}
resources, _ := spec["resources"].(map[string]interface{})
resource, resName, remaining := findResourceByPath(resources, parts[1:])
if resource == nil {
var resNames []string
var names []string
for k := range resources {
resNames = append(resNames, k)
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
fmt.Sprintf("Available: %s", strings.Join(resNames, ", ")))
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) == 0 {
if opts.Format == "pretty" {
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
for _, mName := range sortedKeys(methods) {
m, _ := methods[mName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
desc := registry.GetStrFromMap(m, "description")
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
}
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
} else {
// For JSON output, filter methods in a copy to avoid mutating the registry.
if mode.IsActive() {
filtered := make(map[string]interface{})
for k, v := range resource {
filtered[k] = v
}
if methods, ok := resource["methods"].(map[string]interface{}); ok {
filtered["methods"] = filterMethodsByStrictMode(methods, mode)
}
output.PrintJson(out, filtered)
} else {
output.PrintJson(out, resource)
}
}
// Resource-scoped envelope array
envs := assembleResource(serviceName, resName, resource, filter)
output.PrintJson(out, envs)
return nil
}
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var names []string
for k := range methods {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) > 1 {
// Method exists but caller appended extra segments — reject so they
// don't silently get this method's schema when they typo'd the path.
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown path: %s.%s.%s",
serviceName, resName, strings.Join(remaining, ".")),
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
methodName, strings.Join(remaining[1:], ".")))
}
if filter != nil && !filter(method) {
// Method exists in spec but filtered out by strict mode
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName),
"Use --as user / --as bot to switch")
}
env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method)
output.PrintJson(out, env)
return nil
}
func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope {
methods, _ := resource["methods"].(map[string]interface{})
resourcePath := []string{resName}
var envs []schema.Envelope
for methodName, raw := range methods {
method, ok := raw.(map[string]interface{})
if !ok {
continue
}
if filter != nil && !filter(method) {
continue
}
envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method))
}
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
return envs
}
// runPrettyMode preserves the existing legacy pretty rendering verbatim.
// All printServices/printResourceList/printMethodDetail calls stay unchanged.
func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
if len(parts) == 0 {
printServices(out)
return nil
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return errUnknownService(serviceName)
}
if len(parts) == 1 {
printResourceList(out, spec, mode)
return nil
}
resources, _ := spec["resources"].(map[string]interface{})
resource, resName, remaining := findResourceByPath(resources, parts[1:])
if resource == nil {
var names []string
for k := range resources {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) == 0 {
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
for _, mName := range sortedKeys(methods) {
m, _ := methods[mName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
desc := registry.GetStrFromMap(m, "description")
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
}
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
return nil
}
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var mNames []string
var names []string
for k := range methods {
mNames = append(mNames, k)
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
fmt.Sprintf("Available: %s", strings.Join(mNames, ", ")))
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if opts.Format == "pretty" {
printMethodDetail(out, spec, resName, methodName, method)
} else {
output.PrintJson(out, method)
if len(remaining) > 1 {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown path: %s.%s.%s",
serviceName, resName, strings.Join(remaining, ".")),
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
methodName, strings.Join(remaining[1:], ".")))
}
printMethodDetail(out, spec, resName, methodName, method)
return nil
}
// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns
// nil if strict mode is not active.
func strictModeFilter(mode core.StrictMode) schema.MethodFilter {
if !mode.IsActive() {
return nil
}
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
return func(method map[string]interface{}) bool {
tokens, _ := method["accessTokens"].([]interface{})
if tokens == nil {
return true // permissive when meta_data lacks accessTokens
}
for _, t := range tokens {
if s, _ := t.(string); s == token {
return true
}
}
return false
}
}
func errUnknownService(name string) error {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown service: %s", name),
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
}
// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded
// services (no overlay) because JSON mode itself bypasses overlay; suggesting
// overlay-only services would mislead callers when those services subsequently
// fail to resolve in envelope output.
func errUnknownEmbeddedService(name string) error {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown service: %s", name),
fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", ")))
}
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
// filtered by strict mode. Returns the original spec when strict mode is off.
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {

View File

@@ -5,6 +5,7 @@ package schema
import (
"bytes"
"encoding/json"
"strings"
"testing"
@@ -33,17 +34,165 @@ func TestSchemaCmd_FlagParsing(t *testing.T) {
}
}
func TestSchemaCmd_NoArgs(t *testing.T) {
func TestSchemaCmd_NoArgs_Pretty(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{})
err := cmd.Execute()
if err != nil {
cmd.SetArgs([]string{"--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "Available services") {
t.Error("expected service list output")
t.Error("expected service list in pretty mode")
}
}
func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{}) // default --format json
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := strings.TrimSpace(stdout.String())
if !strings.HasPrefix(out, "[") {
head := out
if len(head) > 80 {
head = head[:80]
}
t.Errorf("expected JSON array root, first 80 chars:\n%s", head)
}
var envs []map[string]interface{}
if err := json.Unmarshal([]byte(out), &envs); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if len(envs) < 193 {
t.Errorf("envelopes count = %d, want >= 193", len(envs))
}
}
func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im.images.create", "--format", "json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("not valid JSON: %v\n%s", err, stdout.String())
}
if env["name"] != "im images create" {
t.Errorf("name = %v, want \"im images create\"", env["name"])
}
for _, key := range []string{"description", "inputSchema", "outputSchema", "_meta"} {
if _, ok := env[key]; !ok {
t.Errorf("missing top-level key: %s", key)
}
}
meta, _ := env["_meta"].(map[string]interface{})
if meta["envelope_version"] != "1.0" {
t.Errorf("envelope_version = %v, want \"1.0\"", meta["envelope_version"])
}
}
func TestSchemaCmd_SpaceSeparatedPath_EqualsDotted(t *testing.T) {
f1, out1, _, _ := cmdutil.TestFactory(t, nil)
cmd1 := NewCmdSchema(f1, nil)
cmd1.SetArgs([]string{"im", "images", "create"})
if err := cmd1.Execute(); err != nil {
t.Fatalf("space form failed: %v", err)
}
f2, out2, _, _ := cmdutil.TestFactory(t, nil)
cmd2 := NewCmdSchema(f2, nil)
cmd2.SetArgs([]string{"im.images.create"})
if err := cmd2.Execute(); err != nil {
t.Fatalf("dotted form failed: %v", err)
}
if out1.String() != out2.String() {
t.Errorf("space and dotted forms produced different output")
}
}
func TestSchemaCmd_ServiceListIsArray(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envs []map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envs); err != nil {
t.Fatalf("unmarshal failed: %v\n%s", err, stdout.String())
}
if len(envs) == 0 {
t.Fatal("expected non-empty array for service im")
}
for _, e := range envs {
name, _ := e["name"].(string)
if !strings.HasPrefix(name, "im ") {
t.Errorf("envelope name %q does not start with \"im \"", name)
}
}
}
func TestSchemaCmd_HighRiskYesInjection(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im.messages.delete"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
is, _ := env["inputSchema"].(map[string]interface{})
props, _ := is["properties"].(map[string]interface{})
if _, ok := props["yes"]; !ok {
t.Errorf("inputSchema.properties.yes missing for high-risk-write command")
}
}
func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im.reactions.list"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
is, _ := env["inputSchema"].(map[string]interface{})
props, _ := is["properties"].(map[string]interface{})
if _, ok := props["yes"]; ok {
t.Errorf("yes property should not appear for risk=read command")
}
}
func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im.images.create", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Existing pretty rendering surfaces these markers — they must still appear
for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} {
if !strings.Contains(out, want) {
t.Errorf("pretty output missing marker %q", want)
}
}
}

View File

@@ -31,15 +31,18 @@ var (
currentVersion = func() string { return build.Version }
currentOS = runtime.GOOS
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { return skillscheck.SyncSkills(opts) }
)
func isWindows() bool { return currentOS == osWindows }
// normalizeVersion canonicalizes a version string for stamp comparison.
// normalizeVersion canonicalizes a version string for state comparison.
// Strips a leading "v" so versions written from Makefile (git describe →
// "v1.0.0") and npm (no prefix → "1.0.0") compare equal.
func normalizeVersion(s string) string {
return strings.TrimPrefix(strings.TrimSpace(s), "v")
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "v")
return strings.TrimPrefix(s, "V")
}
func releaseURL(version string) string {
@@ -121,7 +124,9 @@ func updateRun(opts *UpdateOptions) error {
cur := currentVersion()
updater := newUpdater()
updater.CleanupStaleFiles()
if !opts.Check {
updater.CleanupStaleFiles()
}
output.PendingNotice = nil
// 1. Fetch latest version
@@ -137,13 +142,9 @@ func updateRun(opts *UpdateOptions) error {
// 3. Compare versions
if !opts.Force && !update.IsNewer(latest, cur) {
// Run skills sync before returning — covers the case where the
// binary is already current but skills were never synced.
// Stamp dedup makes this a no-op if skills are already in sync.
// Skip side-effects under --check (pure report path per spec §3.6).
var skillsResult *selfupdate.NpmResult
var skillsResult *skillscheck.SyncResult
if !opts.Check {
skillsResult = runSkillsAndStamp(updater, io, cur, opts.Force)
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
}
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
}
@@ -185,16 +186,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
"url": releaseURL(latest), "changelog": changelogURL(),
}
// skills_status: pure report, no side effect, no stamp write.
// ReadStamp errors are silently swallowed — if we can't read the
// stamp we just omit the block rather than fail the --check.
if stamp, err := skillscheck.ReadStamp(); err == nil {
out["skills_status"] = map[string]interface{}{
"current": stamp,
"target": cur,
"in_sync": stamp == cur,
}
}
applySkillsStatus(out, cur)
output.PrintJson(io.Out, out)
return nil
}
@@ -210,7 +202,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
}
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
skillsResult := runSkillsAndStamp(updater, io, cur, opts.Force)
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
reason := detect.ManualReason()
if opts.JSON {
@@ -288,10 +280,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
return output.ErrBare(output.ExitAPI)
}
// Skills update (best-effort) — uses runSkillsAndStamp so the
// stamp gets persisted on success and dedup applies if a previous
// run already stamped this version.
skillsResult := runSkillsAndStamp(updater, io, latest, opts.Force)
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
if opts.JSON {
result := map[string]interface{}{
@@ -328,27 +317,21 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the
// stamp on success. Skips the npx invocation when the stamp already
// matches stampVersion (unless force is true). The stamp write failure
// emits a warning to io.ErrOut but does NOT fail the update command —
// best-effort. ReadStamp errors are swallowed (fail-closed: treated as
// out-of-sync, so npx re-runs). Returns nil iff skipped due to stamp
// dedup; otherwise returns the underlying *NpmResult with Err semantics
// from RunSkillsUpdate.
func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stampVersion string, force bool) *selfupdate.NpmResult {
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
if !force {
if existing, _ := skillscheck.ReadStamp(); normalizeVersion(existing) == normalizeVersion(stampVersion) {
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
return nil
}
}
r := updater.RunSkillsUpdate()
if r.Err == nil {
if err := skillscheck.WriteStamp(stampVersion); err != nil {
fmt.Fprintf(io.ErrOut, "warning: skills synced but stamp not written: %v\n", err)
}
result := syncSkills(skillscheck.SyncOptions{
Version: stateVersion,
Force: force,
Runner: updater,
})
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
}
return r
return result
}
// reportAlreadyUpToDate emits the JSON / pretty output for the
@@ -356,7 +339,7 @@ func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stamp
// fields derived from skillsResult. When check is true, this is the pure
// report path (spec §3.6): no side-effects, JSON envelope uses
// skills_status (spec §4.2) instead of skills_action.
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *selfupdate.NpmResult, check bool) error {
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *skillscheck.SyncResult, check bool) error {
if opts.JSON {
out := map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": cur,
@@ -364,16 +347,7 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
}
if check {
// Pure report — read stamp directly, emit skills_status block.
// ReadStamp errors are silently swallowed — if we can't read
// the stamp we just omit the block rather than fail the --check.
if stamp, err := skillscheck.ReadStamp(); err == nil {
out["skills_status"] = map[string]interface{}{
"current": stamp,
"target": cur,
"in_sync": stamp == cur,
}
}
applySkillsStatus(out, cur)
} else {
applySkillsResult(out, skillsResult)
}
@@ -387,36 +361,70 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
return nil
}
// applySkillsResult mutates the JSON envelope to include skills_action
// (and skills_warning when failed). nil result = "in_sync" (dedup hit).
func applySkillsResult(env map[string]interface{}, r *selfupdate.NpmResult) {
func applySkillsStatus(env map[string]interface{}, target string) {
state, readable, err := skillscheck.ReadState()
if err != nil || !readable || state.Version == "" {
return
}
status := map[string]interface{}{
"current": state.Version,
"target": target,
"in_sync": normalizeVersion(state.Version) == normalizeVersion(target),
}
if len(state.OfficialSkills) > 0 {
status["official"] = len(state.OfficialSkills)
}
if len(state.UpdatedSkills) > 0 {
status["updated"] = len(state.UpdatedSkills)
}
if len(state.SkippedDeletedSkills) > 0 {
status["skipped_deleted"] = state.SkippedDeletedSkills
}
env["skills_status"] = status
}
func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
switch {
case r == nil:
env["skills_action"] = "in_sync"
case r.Err != nil:
env["skills_action"] = "failed"
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
env["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
}
env["skills_summary"] = skillsSummary(r)
default:
env["skills_action"] = "synced"
env["skills_summary"] = skillsSummary(r)
}
}
// emitSkillsTextHints prints human-readable feedback about the skills
// sync result for non-JSON output.
func emitSkillsTextHints(io *cmdutil.IOStreams, r *selfupdate.NpmResult) {
func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
summary := map[string]interface{}{
"official": len(r.Official),
"updated": len(r.Updated),
"added": len(r.Added),
"skipped_deleted": len(r.SkippedDeleted),
}
if len(r.Failed) > 0 {
summary["failed"] = r.Failed
}
return summary
}
func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
switch {
case r == nil:
// dedup hit — silent (already up to date)
case r.Err != nil:
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %v\n", symWarn(), r.Err)
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, maxStderrDetail))
if len(r.Failed) > 0 {
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
}
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
case r.Force:
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
default:
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
if len(r.SkippedDeleted) > 0 {
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
}
}
}

View File

@@ -5,13 +5,14 @@ package cmdupdate
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"os/exec"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -28,7 +29,6 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
}
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later.
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
t.Helper()
origNew := newUpdater
@@ -41,22 +41,53 @@ func mockDetect(t *testing.T, result selfupdate.DetectResult) {
}
// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once.
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult,
npmFn func(string) *selfupdate.NpmResult,
skillsFn func() *selfupdate.NpmResult) {
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(string) *selfupdate.NpmResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.NpmInstallOverride = npmFn
u.SkillsUpdateOverride = skillsFn
u.VerifyOverride = func(string) error { return nil }
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
return func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
switch strings.Join(args, " ") {
case "-y skills add https://open.feishu.cn --list":
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
case "-y skills ls -g":
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
default:
}
return r
}
}
func TestNormalizeVersion(t *testing.T) {
tests := []struct {
input string
want string
}{
{input: "1.2.3", want: "1.2.3"},
{input: "v1.2.3", want: "1.2.3"},
{input: "V1.2.3", want: "1.2.3"},
{input: " v1.2.3 ", want: "1.2.3"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
if got := normalizeVersion(tt.input); got != tt.want {
t.Fatalf("normalizeVersion(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
@@ -168,9 +199,7 @@ func TestUpdateManual_Human(t *testing.T) {
}
func TestUpdateNpm_JSON(t *testing.T) {
// Isolate config dir: this test mocks fetchLatest="2.0.0" and lets
// runSkillsAndStamp → WriteStamp succeed, which without isolation would
// clobber the real ~/.lark-cli/skills.stamp with "2.0.0".
// Isolate config dir because skills sync writes skills-state.json.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -186,7 +215,6 @@ func TestUpdateNpm_JSON(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -216,7 +244,6 @@ func TestUpdateNpm_Human(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -230,7 +257,7 @@ func TestUpdateNpm_Human(t *testing.T) {
}
func TestUpdateForce_JSON(t *testing.T) {
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
// Same state-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -246,7 +273,6 @@ func TestUpdateForce_JSON(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -323,7 +349,7 @@ func TestUpdateInvalidVersion_JSON(t *testing.T) {
}
func TestUpdateDevVersion_JSON(t *testing.T) {
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
// Same state-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -339,7 +365,6 @@ func TestUpdateDevVersion_JSON(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -451,8 +476,8 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
u.RestoreAvailableOverride = func() bool { return false }
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
t.Fatal("skills update should not run when binary verification fails")
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
t.Fatal("skills sync should not run when binary verification fails")
return nil
}
return u
@@ -649,7 +674,7 @@ func TestPermissionHint(t *testing.T) {
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
// With the rename trick, Windows npm installs can now auto-update.
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
// Same state-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -668,7 +693,6 @@ func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -750,7 +774,6 @@ func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -785,8 +808,7 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
// Skills update fails
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
r.Err = fmt.Errorf("exit status 127")
@@ -812,8 +834,8 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
if !strings.Contains(out, "skills_warning") {
t.Errorf("expected skills_warning in output, got: %s", out)
}
if !strings.Contains(out, "skills_detail") {
t.Errorf("expected skills_detail in output, got: %s", out)
if !strings.Contains(out, "skills_summary") {
t.Errorf("expected skills_summary in output, got: %s", out)
}
}
@@ -838,7 +860,7 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
r.Err = fmt.Errorf("exit status 127")
@@ -861,100 +883,96 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
if !strings.Contains(out, "Skills update failed") {
t.Errorf("expected skills failure warning, got: %s", out)
}
if !strings.Contains(out, "npx -y skills add") {
t.Errorf("expected manual skills command hint, got: %s", out)
if !strings.Contains(out, "lark-cli update --force") {
t.Errorf("expected force retry hint, got: %s", out)
}
}
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers, suitable
// for direct calls to internals like runSkillsAndStamp that write to
// io.ErrOut.
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers.
func newTestIO() *cmdutil.IOStreams {
return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
}
func TestRunSkillsAndStamp_DedupHit(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
func TestRunSkillsAndState_DedupHit(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
called := false
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
called = true
return &selfupdate.NpmResult{}
},
}
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
if got != nil {
t.Errorf("runSkillsAndStamp() = %+v, want nil for dedup hit", got)
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
}
if called {
t.Error("SkillsUpdateOverride called, want skipped due to dedup")
t.Error("SkillsCommandOverride called, want skipped due to dedup")
}
}
func TestRunSkillsAndStamp_DedupForceBypass(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
called := false
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
called = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", true)
if got == nil {
t.Fatal("runSkillsAndStamp(force=true) = nil, want non-nil")
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
}
if !called {
t.Error("SkillsUpdateOverride not called with force=true")
t.Error("SkillsCommandOverride not called with force=true")
}
}
func TestRunSkillsAndStamp_SuccessWritesStamp(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
return &selfupdate.NpmResult{}
},
}
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.21" {
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.21" {
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
}
}
func TestRunSkillsAndStamp_FailureKeepsOldStamp(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
t.Fatal(err)
}
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("npx failed")
return r
},
}
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
if got == nil || got.Err == nil {
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with non-nil Err", got)
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.20" {
t.Errorf("stamp = %q, want \"1.0.20\" (failure must not overwrite)", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.20" {
t.Errorf("state.Version = %q, want \"1.0.20\" (failure must not overwrite)", state.Version)
}
}
@@ -973,8 +991,7 @@ func TestTruncate(t *testing.T) {
}
func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
origFetch := fetchLatest
origCur := currentVersion
@@ -987,9 +1004,9 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
}
@@ -1000,17 +1017,19 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
t.Fatalf("updateRun() err = %v, want nil", err)
}
if !skillsCalled {
t.Error("RunSkillsUpdate not called in already-up-to-date branch (cold stamp), want called")
t.Error("skills sync not called in already-up-to-date branch")
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.21" {
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.21" {
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
}
}
func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
origFetch := fetchLatest
origCur := currentVersion
@@ -1029,9 +1048,9 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
ResolvedPath: "/usr/local/bin/lark-cli",
}
},
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
}
@@ -1042,17 +1061,19 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
t.Fatalf("updateRun() err = %v, want nil", err)
}
if !skillsCalled {
t.Error("RunSkillsUpdate not called in manual branch, want called")
t.Error("skills sync not called in manual branch")
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.21" {
t.Errorf("stamp = %q, want \"1.0.21\" (manual path stamps cur)", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.21" {
t.Errorf("state.Version = %q, want \"1.0.21\" (manual path records current binary)", state.Version)
}
}
func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
origFetch := fetchLatest
origCur := currentVersion
@@ -1075,9 +1096,9 @@ func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
return &selfupdate.NpmResult{}
},
VerifyOverride: func(expectedVersion string) error { return nil },
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
}
@@ -1088,18 +1109,25 @@ func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
t.Fatalf("updateRun() err = %v, want nil", err)
}
if !skillsCalled {
t.Error("RunSkillsUpdate not called in npm branch")
t.Error("skills sync not called in npm branch")
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.22" {
t.Errorf("stamp = %q, want \"1.0.22\" (npm path stamps latest)", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.22" {
t.Errorf("state.Version = %q, want \"1.0.22\" (npm path records latest binary)", state.Version)
}
}
func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{
Version: "1.0.20",
OfficialSkills: []string{"lark-calendar", "lark-mail"},
UpdatedSkills: []string{"lark-calendar"},
SkippedDeletedSkills: []string{"lark-mail"},
}); err != nil {
t.Fatal(err)
}
@@ -1117,9 +1145,9 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
},
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
}
@@ -1130,7 +1158,7 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
t.Fatalf("updateRun(--check) err = %v, want nil", err)
}
if skillsCalled {
t.Error("RunSkillsUpdate called under --check, want skipped (pure report)")
t.Error("skills sync called under --check, want skipped")
}
var env map[string]interface{}
@@ -1144,12 +1172,14 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false {
t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status)
}
if status["official"] != float64(2) || status["updated"] != float64(1) {
t.Errorf("skills_status counts = %+v, want official:2 updated:1", status)
}
}
func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
t.Fatal(err)
}
@@ -1164,9 +1194,9 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
}
@@ -1177,12 +1207,15 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
t.Fatalf("updateRun(--check, already-latest) err = %v, want nil", err)
}
if skillsCalled {
t.Error("RunSkillsUpdate called under --check (already-latest), want skipped (pure report)")
t.Error("skills sync called under --check (already-latest), want skipped")
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.20" {
t.Errorf("stamp mutated to %q under --check, want \"1.0.20\" (pure report must not write stamp)", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.20" {
t.Errorf("state.Version mutated to %q under --check, want \"1.0.20\"", state.Version)
}
var env map[string]interface{}
@@ -1204,39 +1237,248 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
}
}
// TestRunSkillsAndStamp_StampWriteFailureWarns verifies the stderr warning
// emission when RunSkillsUpdate succeeds but WriteStamp fails.
func TestRunSkillsAndStamp_StampWriteFailureWarns(t *testing.T) {
// Force WriteStamp to fail by pointing config dir at a path that exists
// as a regular file (so MkdirAll fails).
tmp := t.TempDir()
badPath := filepath.Join(tmp, "blocker")
if err := os.WriteFile(badPath, []byte("not-a-dir"), 0o644); err != nil {
t.Fatal(err)
func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
origSync := syncSkills
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
return &skillscheck.SyncResult{Err: fmt.Errorf("skills synced but state not written: denied")}
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", badPath)
t.Cleanup(func() { syncSkills = origSync })
f, _, stderr := newTestFactory(t)
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
return &selfupdate.NpmResult{} // success
},
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
if got == nil || got.Err == nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
}
got := runSkillsAndStamp(updater, f.IOStreams, "1.0.21", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
}
if !strings.Contains(stderr.String(), "warning: skills synced but stamp not written") {
if !strings.Contains(stderr.String(), "warning: skills synced but state not written") {
t.Errorf("stderr does not contain warning: %q", stderr.String())
}
}
// TestEmitSkillsTextHints_Success verifies the "Skills updated" success
// message is printed to ErrOut on a successful (Err == nil) result.
func TestEmitSkillsTextHints_Success(t *testing.T) {
f, _, stderr := newTestFactory(t)
emitSkillsTextHints(f.IOStreams, &selfupdate.NpmResult{}) // Err==nil → success
emitSkillsTextHints(f.IOStreams, &skillscheck.SyncResult{Official: []string{"lark-calendar"}, Updated: []string{"lark-calendar"}})
if !strings.Contains(stderr.String(), "Skills updated") {
t.Errorf("stderr does not contain 'Skills updated': %q", stderr.String())
}
}
// 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)
}
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)
}
// 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
// intentionally removed them while they were still official skills.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
before := skillscheck.SkillsState{
Version: "1.0.19",
OfficialSkills: []string{"lark-approval", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"},
UpdatedSkills: []string{"lark-approval", "lark-apps", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"},
AddedOfficialSkills: []string{},
SkippedDeletedSkills: []string{},
UpdatedAt: "2026-05-20T00:00:00Z",
}
if err := skillscheck.WriteState(before); err != nil {
t.Fatal(err)
}
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() before update = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.19" {
t.Fatalf("state.Version before update = %q, want 1.0.19", state.Version)
}
// Phase 3: Mock version functions so the update command believes it has
// upgraded from 1.0.19 to 1.0.20, then execute "lark-cli update --json".
// This triggers SyncSkills which calls the real npx skills add command.
origFetch := fetchLatest
origVersion := currentVersion
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion })
fetchLatest = func() (string, error) { return "1.0.20", nil }
currentVersion = func() string { return "1.0.20" }
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("lark-cli update --json err = %v, want nil", err)
}
// Phase 4: Verify the state file was rewritten with the new version,
// non-empty official/updated skill lists, and a refreshed timestamp.
state, readable, err = skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.20" {
t.Errorf("state.Version after update = %q, want 1.0.20", state.Version)
}
if len(state.OfficialSkills) == 0 {
t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state)
}
if len(state.UpdatedSkills) == 0 {
t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state)
}
if state.UpdatedAt == "" || state.UpdatedAt == before.UpdatedAt {
t.Errorf("state.UpdatedAt = %q, want refreshed non-empty timestamp", state.UpdatedAt)
}
// Verify that previously-skipped skills are handled correctly:
// - If locally installed → should appear in UpdatedSkills (updated to latest)
// - If locally absent → should NOT be force-restored in UpdatedSkills,
// and should remain in SkippedDeletedSkills
for _, skill := range []string{"lark-doc", "lark-mail"} {
if containsString(localSkills, skill) {
if !containsString(state.UpdatedSkills, skill) {
t.Errorf("state.UpdatedSkills = %v, want installed skill %q updated", state.UpdatedSkills, skill)
}
continue
}
if containsString(state.UpdatedSkills, skill) {
t.Errorf("state.UpdatedSkills = %v, want deleted skill %q not restored without --force", state.UpdatedSkills, skill)
}
if !containsString(state.SkippedDeletedSkills, skill) {
t.Errorf("state.SkippedDeletedSkills = %v, want deleted skill %q preserved when still official", state.SkippedDeletedSkills, skill)
}
}
// Phase 5: Verify the JSON output structure is parseable and contains
// the expected action fields for AI agent consumption.
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String())
}
if env["action"] != "already_up_to_date" {
t.Errorf("action = %v, want already_up_to_date", env["action"])
}
if env["skills_action"] != "synced" {
t.Errorf("skills_action = %v, want synced", env["skills_action"])
}
}
// TestUpdateCommand_SkillsSyncColdStart verifies that when skills-state.json does
// 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.
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)
}
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if _, readable, _ := skillscheck.ReadState(); readable {
t.Fatal("skills-state.json should not exist before update")
}
// Phase 3: Mock version functions so the update command believes it is at
// v1.0.20, then execute "lark-cli update --json". This triggers SyncSkills
// which calls the real npx skills add command.
origFetch := fetchLatest
origVersion := currentVersion
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion })
fetchLatest = func() (string, error) { return "1.0.20", nil }
currentVersion = func() string { return "1.0.20" }
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("lark-cli update --json err = %v, want nil", err)
}
// Phase 4: Verify the state file was created with all official skills in
// UpdatedSkills and nothing in SkippedDeletedSkills (cold start = no prior
// deletions to honor). Locally installed skills should appear in UpdatedSkills.
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.20" {
t.Errorf("state.Version = %q, want 1.0.20", state.Version)
}
if len(state.OfficialSkills) == 0 {
t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state)
}
if len(state.UpdatedSkills) == 0 {
t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state)
}
if state.UpdatedAt == "" {
t.Error("state.UpdatedAt is empty, want non-empty timestamp")
}
// All locally installed official skills must appear in UpdatedSkills.
officialSet := map[string]bool{}
for _, s := range state.OfficialSkills {
officialSet[s] = true
}
for _, skill := range localSkills {
if !officialSet[skill] {
continue
}
if !containsString(state.UpdatedSkills, skill) {
t.Errorf("state.UpdatedSkills = %v, want locally installed official skill %q updated", state.UpdatedSkills, skill)
}
}
// No skill should be in SkippedDeletedSkills on cold start — there is no
// previous state recording a user deletion to preserve.
if len(state.SkippedDeletedSkills) != 0 {
t.Errorf("state.SkippedDeletedSkills = %v, want empty on cold start", state.SkippedDeletedSkills)
}
// Phase 5: Verify the JSON output structure is parseable and contains
// the expected action fields for AI agent consumption.
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String())
}
if env["action"] != "already_up_to_date" {
t.Errorf("action = %v, want already_up_to_date", env["action"])
}
if env["skills_action"] != "synced" {
t.Errorf("skills_action = %v, want synced", env["skills_action"])
}
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/validate"
)
const (
minutesDetailRetryDelay = 500 * time.Millisecond
minutesDetailMaxRetries = 2
)
// MinutesMinuteSourceOutput is the flattened minute source payload.
type MinutesMinuteSourceOutput struct {
SourceType string `json:"source_type,omitempty" desc:"Minute source type"`
SourceEntityID string `json:"source_entity_id,omitempty" desc:"Source entity ID"`
}
// MinutesMinuteGeneratedOutput is the flattened shape for minutes.minute.generated_v1.
type MinutesMinuteGeneratedOutput struct {
Type string `json:"type" desc:"Event type; always minutes.minute.generated_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
MinuteToken string `json:"minute_token,omitempty" desc:"Minute token"`
Title string `json:"title,omitempty" desc:"Minute title"`
MinuteSource *MinutesMinuteSourceOutput `json:"minute_source,omitempty" desc:"Minute source metadata"`
}
func processMinutesMinuteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
MinuteToken string `json:"minute_token"`
MinuteSource struct {
SourceType string `json:"source_type"`
SourceEntityID string `json:"source_entity_id"`
} `json:"minute_source"`
} `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 := &MinutesMinuteGeneratedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
MinuteToken: envelope.Event.MinuteToken,
}
if out.Type == "" {
out.Type = raw.EventType
}
if src := envelope.Event.MinuteSource; src.SourceType != "" || src.SourceEntityID != "" {
out.MinuteSource = &MinutesMinuteSourceOutput{
SourceType: src.SourceType,
SourceEntityID: src.SourceEntityID,
}
}
if rt != nil && out.MinuteToken != "" {
fillMinutesMinuteGeneratedDetails(ctx, rt, out)
}
return json.Marshal(out)
}
func fillMinutesMinuteGeneratedDetails(ctx context.Context, rt event.APIClient, out *MinutesMinuteGeneratedOutput) {
if rt == nil || out == nil || out.MinuteToken == "" {
return
}
path := fmt.Sprintf(pathMinuteDetailFmt, validate.EncodePathSegment(out.MinuteToken))
type minuteDetailResp struct {
Data struct {
Minute struct {
Title string `json:"title"`
} `json:"minute"`
} `json:"data"`
}
for attempt := 0; attempt <= minutesDetailMaxRetries; attempt++ {
if attempt > 0 {
time.Sleep(minutesDetailRetryDelay)
}
raw, err := rt.CallAPI(ctx, "GET", path, nil)
if err != nil {
continue
}
var resp minuteDetailResp
if err := json.Unmarshal(raw, &resp); err != nil {
continue
}
if resp.Data.Minute.Title == "" {
continue
}
out.Title = resp.Data.Minute.Title
return
}
}

View File

@@ -0,0 +1,353 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"encoding/json"
"fmt"
"os"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/validate"
)
type stubAPIClient struct {
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
}
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
if s.callFn == nil {
return nil, nil
}
return s.callFn(ctx, method, path, body)
}
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
t.Helper()
want := map[string]string{"event_type": wantEventType}
if !reflect.DeepEqual(gotBody, want) {
t.Fatalf("request body = %#v, want %#v", gotBody, want)
}
}
func TestMain(m *testing.M) {
for _, k := range Keys() {
event.RegisterKey(k)
}
os.Exit(m.Run())
}
func TestMinutesKeys_ProcessedMinuteGeneratedRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeMinuteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for processed key")
}
if def.PreConsume == nil {
t.Error("PreConsume must not be nil for processed key")
}
if len(def.Scopes) != 1 || def.Scopes[0] != "minutes:minutes.basic:read" {
t.Errorf("Scopes = %v", def.Scopes)
}
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v", def.AuthTypes)
}
}
func TestProcessMinutesMinuteGenerated(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
var gotMethod, gotPath string
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
gotMethod = method
gotPath = path
if body != nil {
t.Fatalf("GET detail body = %#v, want nil", body)
}
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"minute": {
"token": "<doc_token_001>",
"title": "产品周会的视频会议",
"note_id": "7616590025794260496"
}
}
}`), nil
},
}
out := runMinuteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_minute_001",
"event_type": "minutes.minute.generated_v1",
"create_time": "1608725989000"
},
"event": {
"minute_token": "<doc_token_001>",
"minute_source": {
"source_type": "meeting",
"source_entity_id": "6911188411934433028"
}
}
}`)
if gotMethod != "GET" {
t.Errorf("detail method = %q, want GET", gotMethod)
}
if gotPath != fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment("<doc_token_001>")) {
t.Errorf("detail path = %q", gotPath)
}
if out.Type != eventTypeMinuteGenerated {
t.Errorf("Type = %q", out.Type)
}
if out.EventID != "ev_minute_001" || out.Timestamp != "1608725989000" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.MinuteToken != "<doc_token_001>" {
t.Errorf("MinuteToken = %q", out.MinuteToken)
}
if out.Title != "产品周会的视频会议" {
t.Errorf("Title = %q", out.Title)
}
if out.MinuteSource == nil {
t.Fatal("MinuteSource should not be nil")
}
if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "6911188411934433028" {
t.Errorf("MinuteSource = %+v", out.MinuteSource)
}
}
func TestProcessMinutesMinuteGenerated_DetailFailureFallsBackToBaseFields(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
called++
return nil, context.DeadlineExceeded
},
}
out := runMinuteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_minute_002",
"event_type": "minutes.minute.generated_v1",
"create_time": "1608725989001"
},
"event": {
"minute_token": "<doc_token_004>",
"minute_source": {
"source_type": "meeting",
"source_entity_id": "7641156270787481117"
}
}
}`)
wantCalls := 1 + minutesDetailMaxRetries
if called != wantCalls {
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
}
if out.MinuteToken != "<doc_token_004>" {
t.Errorf("MinuteToken = %q", out.MinuteToken)
}
if out.Title != "" {
t.Errorf("Title = %q, want empty", out.Title)
}
if out.MinuteSource == nil {
t.Fatal("MinuteSource should remain from event payload")
}
if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "7641156270787481117" {
t.Errorf("MinuteSource = %+v", out.MinuteSource)
}
}
func TestProcessMinutesMinuteGenerated_EmptyTitleRetriesAndSucceeds(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
called++
if called <= 1 {
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"minute": {
"title": ""
}
}
}`), nil
}
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"minute": {
"title": "delayed title"
}
}
}`), nil
},
}
out := runMinuteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_minute_retry",
"event_type": "minutes.minute.generated_v1",
"create_time": "1608725989000"
},
"event": {
"minute_token": "<doc_token_003>"
}
}`)
if called != 2 {
t.Fatalf("detail API called %d times, want 2 (1 initial + 1 retry)", called)
}
if out.Title != "delayed title" {
t.Errorf("Title = %q, want delayed title", out.Title)
}
}
func TestProcessMinutesMinuteGenerated_EmptyTitleExhaustsRetries(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
called++
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"minute": {
"title": ""
}
}
}`), nil
},
}
out := runMinuteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_minute_exhaust",
"event_type": "minutes.minute.generated_v1",
"create_time": "1608725989000"
},
"event": {
"minute_token": "<doc_token_002>"
}
}`)
wantCalls := 1 + minutesDetailMaxRetries
if called != wantCalls {
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
}
if out.Title != "" {
t.Errorf("Title = %q, want empty after exhausted retries", out.Title)
}
}
func TestMinutesMinuteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeMinuteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
}
type call struct {
method string
path string
body any
}
var calls []call
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
calls = append(calls, call{method: method, path: path, body: body})
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
},
}
cleanup, err := def.PreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("PreConsume error: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup must not be nil")
}
if len(calls) != 1 {
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
}
if calls[0].method != "POST" || calls[0].path != pathMinuteSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, eventTypeMinuteGenerated)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathMinuteUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, eventTypeMinuteGenerated)
}
func TestProcessMinutesMinuteGenerated_MalformedPayload(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
raw := &event.RawEvent{
EventType: eventTypeMinuteGenerated,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processMinutesMinuteGenerated(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 runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) MinutesMinuteGeneratedOutput {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeMinuteGenerated,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processMinutesMinuteGenerated(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out MinutesMinuteGeneratedOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid MinutesMinuteGeneratedOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/internal/event"
)
const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
if rt == nil {
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
}
body := map[string]string{"event_type": eventType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, err
}
return func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
}, nil
}
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package minutes registers Minutes-domain EventKeys.
package minutes
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const (
eventTypeMinuteGenerated = "minutes.minute.generated_v1"
pathMinuteSubscribe = "/open-apis/minutes/v1/minutes/subscription"
pathMinuteUnsubscribe = "/open-apis/minutes/v1/minutes/unsubscription"
pathMinuteDetailFmt = "/open-apis/minutes/v1/minutes/%s"
)
// Keys returns all Minutes-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeMinuteGenerated,
DisplayName: "Minute generated",
Description: "Triggered when a minute has been generated",
EventType: eventTypeMinuteGenerated,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(MinutesMinuteGeneratedOutput{})},
},
Process: processMinutesMinuteGenerated,
PreConsume: subscriptionPreConsume(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe),
Scopes: []string{"minutes:minutes.basic:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeMinuteGenerated},
},
}
}

View File

@@ -6,13 +6,17 @@ package events
import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/internal/event"
)
// Mail is intentionally omitted: only IM is wired up this phase.
// Mail is intentionally omitted in this phase.
func init() {
all := [][]event.KeyDefinition{
im.Keys(),
minutes.Keys(),
vc.Keys(),
}
for _, keys := range all {
for _, k := range keys {

156
events/vc/note_generated.go Normal file
View File

@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
)
const (
vcNoteArtifactTypeNote = 1
vcNoteArtifactTypeVerbatim = 2
vcNoteDetailRetryDelay = 500 * time.Millisecond
vcNoteDetailMaxRetries = 2
vcNoteDetailNotFoundCode = 121004
)
// VCNoteSourceOutput is the flattened note source payload.
type VCNoteSourceOutput struct {
SourceType string `json:"source_type,omitempty" desc:"Note source type"`
SourceEntityID string `json:"source_entity_id,omitempty" desc:"Source entity ID"`
}
// VCNoteGeneratedOutput is the flattened shape for vc.note.generated_v1.
type VCNoteGeneratedOutput struct {
Type string `json:"type" desc:"Event type; always vc.note.generated_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
NoteID string `json:"note_id,omitempty" desc:"Note ID"`
NoteToken string `json:"note_token,omitempty" desc:"Generated note document token"`
VerbatimToken string `json:"verbatim_token,omitempty" desc:"Generated verbatim document token"`
NoteSource *VCNoteSourceOutput `json:"note_source,omitempty" desc:"Note source metadata"`
}
func processVCNoteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
NoteID string `json:"note_id"`
} `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 := &VCNoteGeneratedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
NoteID: envelope.Event.NoteID,
}
if out.Type == "" {
out.Type = raw.EventType
}
if rt != nil && out.NoteID != "" {
fillVCNoteGeneratedDetails(ctx, rt, out)
}
return json.Marshal(out)
}
func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VCNoteGeneratedOutput) {
if rt == nil || out == nil || out.NoteID == "" {
return
}
path := fmt.Sprintf(pathNoteDetailFmt, validate.EncodePathSegment(out.NoteID))
type noteDetailResp struct {
Data struct {
Note struct {
Artifacts []struct {
ArtifactType int `json:"artifact_type"`
DocToken string `json:"doc_token"`
} `json:"artifacts"`
NoteSource struct {
SourceEntityID string `json:"source_entity_id"`
SourceType string `json:"source_type"`
} `json:"note_source"`
} `json:"note"`
} `json:"data"`
}
for attempt := 0; attempt <= vcNoteDetailMaxRetries; attempt++ {
if attempt > 0 {
time.Sleep(vcNoteDetailRetryDelay)
}
raw, err := rt.CallAPI(ctx, "GET", path, nil)
if err != nil {
if isLarkCode(err, vcNoteDetailNotFoundCode) {
continue
}
return
}
var resp noteDetailResp
if err := json.Unmarshal(raw, &resp); err != nil {
continue
}
var noteToken, verbatimToken string
for _, artifact := range resp.Data.Note.Artifacts {
switch artifact.ArtifactType {
case vcNoteArtifactTypeNote:
if noteToken == "" {
noteToken = artifact.DocToken
}
case vcNoteArtifactTypeVerbatim:
if verbatimToken == "" {
verbatimToken = artifact.DocToken
}
}
}
if noteToken == "" && verbatimToken == "" {
continue
}
if noteToken != "" {
out.NoteToken = noteToken
}
if verbatimToken != "" {
out.VerbatimToken = verbatimToken
}
if src := resp.Data.Note.NoteSource; src.SourceType != "" || src.SourceEntityID != "" {
out.NoteSource = &VCNoteSourceOutput{
SourceType: src.SourceType,
SourceEntityID: src.SourceEntityID,
}
}
return
}
}
func isLarkCode(err error, code int) bool {
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return exitErr.Detail.Code == code
}
return false
}

View File

@@ -0,0 +1,328 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestVCKeys_ProcessedNoteGeneratedRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeNoteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeNoteGenerated)
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for processed key")
}
if def.PreConsume == nil {
t.Error("PreConsume must not be nil for processed key")
}
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:note:read" {
t.Errorf("Scopes = %v", def.Scopes)
}
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v", def.AuthTypes)
}
}
func TestProcessVCNoteGenerated(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
var gotMethod, gotPath string
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
gotMethod = method
gotPath = path
if body != nil {
t.Fatalf("GET detail body = %#v, want nil", body)
}
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"note": {
"artifacts": [
{"artifact_type": 1, "doc_token": "note_doc_token"},
{"artifact_type": 2, "doc_token": "verbatim_doc_token"}
],
"note_source": {
"source_type": "meeting",
"source_entity_id": "6911188411934433028"
}
}
}
}`), nil
},
}
out := runNoteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_note_001",
"event_type": "vc.note.generated_v1",
"create_time": "1608725989000"
},
"event": {
"note_id": "6943848821689040898"
}
}`)
if gotMethod != "GET" {
t.Errorf("detail method = %q, want GET", gotMethod)
}
if gotPath != "/open-apis/vc/v1/notes/6943848821689040898" {
t.Errorf("detail path = %q", gotPath)
}
if out.Type != eventTypeNoteGenerated {
t.Errorf("Type = %q", out.Type)
}
if out.EventID != "ev_vc_note_001" || out.Timestamp != "1608725989000" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.NoteID != "6943848821689040898" {
t.Errorf("NoteID = %q", out.NoteID)
}
if out.NoteToken != "note_doc_token" {
t.Errorf("NoteToken = %q", out.NoteToken)
}
if out.VerbatimToken != "verbatim_doc_token" {
t.Errorf("VerbatimToken = %q", out.VerbatimToken)
}
if out.NoteSource == nil {
t.Fatal("NoteSource should not be nil")
}
if out.NoteSource.SourceType != "meeting" || out.NoteSource.SourceEntityID != "6911188411934433028" {
t.Errorf("NoteSource = %+v", out.NoteSource)
}
}
func TestVCNoteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeNoteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeNoteGenerated)
}
type call struct {
method string
path string
body any
}
var calls []call
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
calls = append(calls, call{method: method, path: path, body: body})
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
},
}
cleanup, err := def.PreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("PreConsume error: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup must not be nil")
}
if len(calls) != 1 {
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
}
if calls[0].method != "POST" || calls[0].path != pathNoteSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, eventTypeNoteGenerated)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathNoteUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, eventTypeNoteGenerated)
}
func TestProcessVCNoteGenerated_DetailFailureFallsBackToBaseFields(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
called++
return nil, context.DeadlineExceeded
},
}
out := runNoteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_note_002",
"event_type": "vc.note.generated_v1",
"create_time": "1608725989001"
},
"event": {
"note_id": "6943848821689040999"
}
}`)
if called != 1 {
t.Fatalf("detail API called %d times, want 1", called)
}
if out.NoteID != "6943848821689040999" {
t.Errorf("NoteID = %q", out.NoteID)
}
if out.NoteToken != "" || out.VerbatimToken != "" {
t.Errorf("NoteToken/VerbatimToken = %q/%q, want empty", out.NoteToken, out.VerbatimToken)
}
if out.NoteSource != nil {
t.Errorf("NoteSource = %+v, want nil", out.NoteSource)
}
}
func TestProcessVCNoteGenerated_EmptyTokensRetriesAndSucceeds(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
called++
if called <= 1 {
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"note": {
"artifacts": [],
"note_source": {"source_type": "meeting", "source_entity_id": "123"}
}
}
}`), nil
}
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"note": {
"artifacts": [
{"artifact_type": 1, "doc_token": "delayed_note_token"},
{"artifact_type": 2, "doc_token": "delayed_verbatim_token"}
],
"note_source": {"source_type": "meeting", "source_entity_id": "123"}
}
}
}`), nil
},
}
out := runNoteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_note_empty_retry",
"event_type": "vc.note.generated_v1",
"create_time": "1608725989000"
},
"event": {
"note_id": "6943848821689040empty"
}
}`)
if called != 2 {
t.Fatalf("detail API called %d times, want 2 (1 initial + 1 retry)", called)
}
if out.NoteToken != "delayed_note_token" {
t.Errorf("NoteToken = %q, want delayed_note_token", out.NoteToken)
}
if out.VerbatimToken != "delayed_verbatim_token" {
t.Errorf("VerbatimToken = %q, want delayed_verbatim_token", out.VerbatimToken)
}
}
func TestProcessVCNoteGenerated_EmptyTokensExhaustsRetries(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
called++
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"note": {
"artifacts": [],
"note_source": {"source_type": "meeting", "source_entity_id": "123"}
}
}
}`), nil
},
}
out := runNoteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_note_empty_exhaust",
"event_type": "vc.note.generated_v1",
"create_time": "1608725989000"
},
"event": {
"note_id": "6943848821689040emptyex"
}
}`)
wantCalls := 1 + vcNoteDetailMaxRetries
if called != wantCalls {
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
}
if out.NoteToken != "" || out.VerbatimToken != "" {
t.Errorf("NoteToken/VerbatimToken = %q/%q, want empty after exhausted retries", out.NoteToken, out.VerbatimToken)
}
}
func TestProcessVCNoteGenerated_MalformedPayload(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
raw := &event.RawEvent{
EventType: eventTypeNoteGenerated,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processVCNoteGenerated(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 runNoteGenerated(t *testing.T, rt event.APIClient, payload string) VCNoteGeneratedOutput {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeNoteGenerated,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processVCNoteGenerated(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out VCNoteGeneratedOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid VCNoteGeneratedOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}

View File

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

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestMain(m *testing.M) {
for _, k := range Keys() {
event.RegisterKey(k)
}
os.Exit(m.Run())
}
func TestVCKeys_ProcessedMeetingEndedRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeMeetingEnded)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeMeetingEnded)
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for processed key")
}
if def.PreConsume == nil {
t.Error("PreConsume must not be nil for processed key")
}
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" {
t.Errorf("Scopes = %v", def.Scopes)
}
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v", def.AuthTypes)
}
}
func TestProcessVCParticipantMeetingEnded(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_end_001",
"event_type": "vc.meeting.participant_meeting_ended_v1",
"create_time": "1608725989000",
"app_id": "cli_test"
},
"event": {
"meeting": {
"id": "6911188411934433028",
"topic": "my meeting",
"meeting_no": "235812466",
"start_time": "1608883322",
"end_time": "1608883899",
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
}
}
}`
out := runMeetingEnded(t, payload)
if out.Type != eventTypeMeetingEnded {
t.Errorf("Type = %q", out.Type)
}
if out.EventID != "ev_vc_end_001" {
t.Errorf("EventID = %q", out.EventID)
}
if out.Timestamp != "1608725989000" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
if out.MeetingID != "6911188411934433028" {
t.Errorf("MeetingID = %q", out.MeetingID)
}
if out.Topic != "my meeting" || out.MeetingNo != "235812466" {
t.Errorf("Topic/MeetingNo = %q/%q", out.Topic, out.MeetingNo)
}
if out.CalendarEventID != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
t.Errorf("CalendarEventID = %q", out.CalendarEventID)
}
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out.StartTime != want {
t.Errorf("StartTime = %q, want %q", out.StartTime, want)
}
if want := time.Unix(1608883899, 0).Local().Format(time.RFC3339); out.EndTime != want {
t.Errorf("EndTime = %q, want %q", out.EndTime, want)
}
}
func TestProcessVCParticipantMeetingEnded_InvalidMeetingTimes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_end_002",
"event_type": "vc.meeting.participant_meeting_ended_v1",
"create_time": "1608725989001"
},
"event": {
"meeting": {
"id": "meeting_invalid_time",
"start_time": "bad",
"end_time": ""
}
}
}`
out := runMeetingEnded(t, payload)
if out.StartTime != "" || out.EndTime != "" {
t.Errorf("StartTime/EndTime = %q/%q, want empty strings", out.StartTime, out.EndTime)
}
}
func TestProcessVCParticipantMeetingEnded_MalformedPayload(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
raw := &event.RawEvent{
EventType: eventTypeMeetingEnded,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processVCParticipantMeetingEnded(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 TestVCParticipantMeetingEnded_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup("vc.meeting.participant_meeting_ended_v1")
if !ok {
t.Fatal("vc.meeting.participant_meeting_ended_v1 should be registered via Keys()")
}
type call struct {
method string
path string
body any
}
var calls []call
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
calls = append(calls, call{method: method, path: path, body: body})
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
},
}
cleanup, err := def.PreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("PreConsume error: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup must not be nil")
}
if len(calls) != 1 {
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
}
if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, eventTypeMeetingEnded)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, eventTypeMeetingEnded)
}
func runMeetingEnded(t *testing.T, payload string) VCParticipantMeetingEndedOutput {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeMeetingEnded,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out VCParticipantMeetingEndedOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid VCParticipantMeetingEndedOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}

33
events/vc/preconsume.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/internal/event"
)
const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
if rt == nil {
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
}
body := map[string]string{"event_type": eventType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, err
}
return func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
}, nil
}
}

61
events/vc/register.go Normal file
View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package vc registers VC-domain EventKeys.
package vc
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const (
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
eventTypeNoteGenerated = "vc.note.generated_v1"
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s"
)
// Keys returns all VC-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeMeetingEnded,
DisplayName: "Participant meeting ended",
Description: "Triggered when a meeting the current user participates in has ended",
EventType: eventTypeMeetingEnded,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingEndedOutput{})},
},
Process: processVCParticipantMeetingEnded,
PreConsume: subscriptionPreConsume(eventTypeMeetingEnded, pathMeetingSubscribe, pathMeetingUnsubscribe),
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeMeetingEnded},
},
{
Key: eventTypeNoteGenerated,
DisplayName: "Note generated",
Description: "Triggered when a note has been generated",
EventType: eventTypeNoteGenerated,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCNoteGeneratedOutput{})},
},
Process: processVCNoteGenerated,
PreConsume: subscriptionPreConsume(eventTypeNoteGenerated, pathNoteSubscribe, pathNoteUnsubscribe),
Scopes: []string{"vc:note:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeNoteGenerated},
},
}
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"reflect"
"testing"
)
type stubAPIClient struct {
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
}
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
if s.callFn == nil {
return nil, nil
}
return s.callFn(ctx, method, path, body)
}
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
t.Helper()
want := map[string]string{"event_type": wantEventType}
if !reflect.DeepEqual(gotBody, want) {
t.Fatalf("request body = %#v, want %#v", gotBody, want)
}
}

View File

@@ -205,14 +205,32 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := strings.TrimSpace(string(errBody))
if msg != "" {
return nil, output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg)
err := output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg)
attachStreamLogID(err, resp.Header)
return nil, err
}
return nil, output.ErrNetwork("HTTP %d", resp.StatusCode)
err := output.ErrNetwork("HTTP %d", resp.StatusCode)
attachStreamLogID(err, resp.Header)
return nil, err
}
return resp, nil
}
func attachStreamLogID(err *output.ExitError, header http.Header) {
if err == nil || err.Detail == nil {
return
}
logID := strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyLogId))
if logID == "" {
logID = strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyRequestId))
}
if logID == "" {
return
}
err.Detail.Detail = map[string]any{"log_id": logID}
}
type cancelOnCloseBody struct {
io.ReadCloser
cancel context.CancelFunc

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client_test
import (
"context"
"errors"
"net/http"
"testing"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
factory, _, _, reg := cmdutil.TestFactory(t, config)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/drive/v1/medias/file_token/download",
Status: http.StatusForbidden,
RawBody: []byte("forbidden"),
Headers: http.Header{
larkcore.HttpHeaderKeyLogId: []string{"202605270003"},
},
})
client, err := factory.NewAPIClientWithConfig(config)
if err != nil {
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
}
_, err = client.DoStream(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/drive/v1/medias/file_token/download",
}, core.AsBot)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured error, got %T %v", err, err)
}
detail, _ := exitErr.Detail.Detail.(map[string]any)
if detail["log_id"] != "202605270003" {
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
}
}

View File

@@ -34,7 +34,7 @@ func RequireConfirmation(action string) error {
Message: fmt.Sprintf("%s requires confirmation", action),
Hint: "add --yes to confirm",
Risk: &output.RiskDetail{
Level: "high-risk-write",
Level: RiskHighRiskWrite,
Action: action,
},
},

27
internal/cmdutil/lang.go Normal file
View File

@@ -0,0 +1,27 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"strings"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
// ParseLangFlag validates and canonicalizes a --lang value, shared by config
// and profile so every entry point honors one contract. Empty is unset (no-op);
// a non-empty value must resolve via i18n.Parse or it errors.
func ParseLangFlag(raw string) (i18n.Lang, error) {
if raw == "" {
return "", nil
}
lang, ok := i18n.Parse(raw)
if !ok {
return "", output.ErrValidation(
"invalid --lang %q; valid values: %s",
raw, strings.Join(i18n.Codes(), ", "))
}
return lang, nil
}

View File

@@ -7,11 +7,20 @@ import "github.com/spf13/cobra"
const riskLevelAnnotationKey = "risk_level"
// Risk level constants — the three-tier convention used across the CLI.
// Use these in place of string literals so the typo radius is one place,
// not every call site.
const (
RiskRead = "read"
RiskWrite = "write"
RiskHighRiskWrite = "high-risk-write"
)
// SetRisk stores a command's static risk level on cobra annotations so the
// help renderer (cmd/root.go) can surface a Risk: line without importing
// shortcuts/common. Levels follow the three-tier convention: "read" | "write"
// | "high-risk-write". Framework-level confirmation gating only acts on
// "high-risk-write".
// shortcuts/common. Levels follow the three-tier convention: RiskRead |
// RiskWrite | RiskHighRiskWrite. Framework-level confirmation gating only
// acts on RiskHighRiskWrite.
func SetRisk(cmd *cobra.Command, level string) {
if level == "" {
return

View File

@@ -11,6 +11,7 @@ import (
"strings"
"unicode/utf8"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
@@ -41,7 +42,7 @@ type AppConfig struct {
AppId string `json:"appId"`
AppSecret SecretInput `json:"appSecret"`
Brand LarkBrand `json:"brand"`
Lang string `json:"lang,omitempty"`
Lang i18n.Lang `json:"lang,omitempty"`
DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto
StrictMode *StrictMode `json:"strictMode,omitempty"`
Users []AppUser `json:"users"`
@@ -159,6 +160,7 @@ type CliConfig struct {
DefaultAs Identity // AsUser | AsBot | AsAuto | "" (from config file)
UserOpenId string
UserName string
Lang i18n.Lang
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
}
@@ -264,6 +266,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
AppSecret: secret,
Brand: app.Brand,
DefaultAs: app.DefaultAs,
Lang: app.Lang,
}
if len(app.Users) > 0 {
cfg.UserOpenId = app.Users[0].UserOpenId

View File

@@ -12,6 +12,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
)
@@ -115,3 +116,45 @@ func TestFullChain_ConfigStrictMode(t *testing.T) {
t.Errorf("expected SupportsBot (%d), got %d", extcred.SupportsBot, acct.SupportedIdentities)
}
}
// TestFullChain_LangSurvivesProductionPath exercises the exact data flow the
// production Factory uses (factory_default.go Phase 3): disk → multi config →
// DefaultAccountProvider.ResolveAccount → Account → ToCliConfig. If Lang gets
// dropped at the credential boundary (as it would when Account lacks the field),
// shortcuts/common/runner.go RuntimeContext.Lang() returns "" and downstream
// consumers (mail signature, etc.) silently fall back to defaults — defeating
// the whole point of persisting --lang.
func TestFullChain_LangSurvivesProductionPath(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: "cfg_app",
AppSecret: core.PlainSecret("cfg_secret"),
Brand: core.BrandFeishu,
Lang: i18n.LangJaJP,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "")
acct, err := defaultAcct.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount: %v", err)
}
if acct.Lang != i18n.LangJaJP {
t.Errorf("Account.Lang = %q, want %q (DefaultAccountProvider must propagate Lang from config)", acct.Lang, i18n.LangJaJP)
}
cfg := acct.ToCliConfig()
if cfg == nil {
t.Fatal("ToCliConfig() = nil")
}
if cfg.Lang != i18n.LangJaJP {
t.Errorf("CliConfig.Lang = %q, want %q (this is the value RuntimeContext.Lang() reads in production)", cfg.Lang, i18n.LangJaJP)
}
}

View File

@@ -10,6 +10,7 @@ import (
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
)
// Account is the credential-layer view of the active runtime account.
@@ -23,6 +24,7 @@ type Account struct {
DefaultAs core.Identity
UserOpenId string
UserName string
Lang i18n.Lang
SupportedIdentities uint8
}
@@ -65,6 +67,7 @@ func AccountFromCliConfig(cfg *core.CliConfig) *Account {
DefaultAs: cfg.DefaultAs,
UserOpenId: cfg.UserOpenId,
UserName: cfg.UserName,
Lang: cfg.Lang,
SupportedIdentities: cfg.SupportedIdentities,
}
}
@@ -82,6 +85,7 @@ func (a *Account) ToCliConfig() *core.CliConfig {
DefaultAs: a.DefaultAs,
UserOpenId: a.UserOpenId,
UserName: a.UserName,
Lang: a.Lang,
SupportedIdentities: a.SupportedIdentities,
}
}

View File

@@ -7,6 +7,7 @@ import (
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
)
func TestTokenTypeString(t *testing.T) {
@@ -53,6 +54,7 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) {
DefaultAs: "user",
UserOpenId: "ou_123",
UserName: "alice",
Lang: i18n.LangJaJP,
SupportedIdentities: 3,
}
@@ -63,6 +65,9 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) {
if acct.AppID != cfg.AppID || acct.ProfileName != cfg.ProfileName || acct.UserName != cfg.UserName {
t.Fatalf("AccountFromCliConfig() = %#v, want copied fields from %#v", acct, cfg)
}
if acct.Lang != cfg.Lang {
t.Fatalf("AccountFromCliConfig().Lang = %q, want %q", acct.Lang, cfg.Lang)
}
roundtrip := acct.ToCliConfig()
if roundtrip == nil {
@@ -71,6 +76,9 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) {
if roundtrip.AppID != cfg.AppID || roundtrip.ProfileName != cfg.ProfileName || roundtrip.UserName != cfg.UserName {
t.Fatalf("ToCliConfig() = %#v, want copied fields from %#v", roundtrip, cfg)
}
if roundtrip.Lang != cfg.Lang {
t.Fatalf("ToCliConfig().Lang = %q, want %q (production Factory path reads Lang via this conversion)", roundtrip.Lang, cfg.Lang)
}
roundtrip.AppID = "mutated-cli"
acct.AppID = "mutated-account"

76
internal/i18n/lang.go Normal file
View File

@@ -0,0 +1,76 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package i18n
// Lang is a Feishu locale (e.g. "zh_cn"); "" means unset.
type Lang string
const (
LangZhCN Lang = "zh_cn"
LangEnUS Lang = "en_us"
LangJaJP Lang = "ja_jp"
LangKoKR Lang = "ko_kr"
LangFrFR Lang = "fr_fr"
LangDeDE Lang = "de_de"
LangEsES Lang = "es_es"
LangItIT Lang = "it_it"
LangRuRU Lang = "ru_ru"
LangPtBR Lang = "pt_br"
LangThTH Lang = "th_th"
LangViVN Lang = "vi_vn"
LangIdID Lang = "id_id"
LangMsMY Lang = "ms_my"
)
type langEntry struct {
Code Lang // canonical Feishu locale
Short string // ISO 639-1 code, also accepted as input shorthand
}
// catalog is the single source of truth; order drives --help and error listing.
var catalog = []langEntry{
{LangZhCN, "zh"}, {LangEnUS, "en"}, {LangJaJP, "ja"}, {LangKoKR, "ko"},
{LangFrFR, "fr"}, {LangDeDE, "de"}, {LangEsES, "es"}, {LangItIT, "it"},
{LangRuRU, "ru"}, {LangPtBR, "pt"}, {LangThTH, "th"}, {LangViVN, "vi"},
{LangIdID, "id"}, {LangMsMY, "ms"},
}
// find matches a short code or Feishu locale against the catalog (case-sensitive).
func find(s string) (langEntry, bool) {
for _, e := range catalog {
if string(e.Code) == s || e.Short == s {
return e, true
}
}
return langEntry{}, false
}
// Parse resolves a short code or Feishu locale to its canonical Lang.
// "" and unrecognized values return ("", false).
func Parse(s string) (Lang, bool) {
e, ok := find(s)
return e.Code, ok
}
// IsEnglish reports whether l uses the English TUI bundle (robust to "en_us"
// and legacy "en").
func (l Lang) IsEnglish() bool {
e, _ := find(string(l))
return e.Code == LangEnUS
}
// Base returns the ISO 639-1 short code ("en_us" → "en"), or "" if unknown.
func (l Lang) Base() string {
e, _ := find(string(l))
return e.Short
}
// Codes lists the canonical locales, for --help and error messages.
func Codes() []string {
out := make([]string, len(catalog))
for i, e := range catalog {
out[i] = string(e.Code)
}
return out
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package i18n
import "testing"
func TestParse(t *testing.T) {
tests := []struct {
in string
want Lang
wantOK bool
}{
{"zh", LangZhCN, true}, // short code
{"zh_cn", LangZhCN, true}, // canonical locale
{"en", LangEnUS, true}, // short code
{"en_us", LangEnUS, true}, // canonical locale
{"ja", LangJaJP, true}, // short code
{"pt", LangPtBR, true}, // pt → pt_br, not pt_pt
{"ms", LangMsMY, true}, // ms → ms_my
{"", "", false}, // unset
{"ZH", "", false}, // case-sensitive
{"zh-CN", "", false}, // hyphen form not accepted
{"zh_CN", "", false}, // case-sensitive region
{"ar", "", false}, // not in the supported set
{"xx", "", false}, // unknown
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
got, ok := Parse(tt.in)
if got != tt.want || ok != tt.wantOK {
t.Errorf("Parse(%q) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.wantOK)
}
})
}
}
func TestIsEnglish(t *testing.T) {
tests := []struct {
lang Lang
want bool
}{
{LangEnUS, true},
{Lang("en"), true}, // legacy short value on disk stays robust
{LangZhCN, false},
{LangJaJP, false},
{Lang("zh"), false},
{Lang(""), false}, // unset → not English (zh bundle)
{Lang("garbage"), false},
}
for _, tt := range tests {
t.Run(string(tt.lang), func(t *testing.T) {
if got := tt.lang.IsEnglish(); got != tt.want {
t.Errorf("Lang(%q).IsEnglish() = %v, want %v", tt.lang, got, tt.want)
}
})
}
}
func TestBase(t *testing.T) {
tests := []struct {
lang Lang
want string
}{
{LangEnUS, "en"},
{LangZhCN, "zh"},
{LangJaJP, "ja"},
{Lang("en"), "en"}, // legacy short value
{Lang("zh"), "zh"},
{Lang(""), ""}, // unset
{Lang("garbage"), ""}, // unknown
}
for _, tt := range tests {
t.Run(string(tt.lang), func(t *testing.T) {
if got := tt.lang.Base(); got != tt.want {
t.Errorf("Lang(%q).Base() = %q, want %q", tt.lang, got, tt.want)
}
})
}
}
func TestCodes(t *testing.T) {
codes := Codes()
if len(codes) != 14 {
t.Fatalf("len(Codes()) = %d, want 14", len(codes))
}
if codes[0] != "zh_cn" {
t.Errorf("Codes()[0] = %q, want %q (catalog order)", codes[0], "zh_cn")
}
// Every code must round-trip through Parse to itself (canonical).
for _, c := range codes {
if got, ok := Parse(c); !ok || string(got) != c {
t.Errorf("Parse(%q) = (%q, %v), want (%q, true)", c, got, ok, c)
}
}
}

View File

@@ -41,6 +41,7 @@ func wrapError(op string, err error) error {
if errors.Is(err, errNotInitialized) {
hint = "The keychain master key may have been cleaned up or deleted. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain, you can try running this outside the sandbox. Otherwise, please reconfigure the CLI by running lark-cli config init."
}
hint += extraHint(err)
func() {
defer func() { recover() }()

View File

@@ -43,6 +43,12 @@ var keyringGet = keyring.Get
// keyringSet is overridden in tests to simulate system keychain writes.
var keyringSet = keyring.Set
// errKeychainBlocked is returned when the OS Keychain is reachable but
// denies access — sandbox restriction, user-denied prompt, or a 5-second
// timeout (typically caused by an ignored permission dialog). Distinct
// from errNotInitialized (master key entry genuinely absent).
var errKeychainBlocked = errors.New("keychain access blocked")
// StorageDir returns the storage directory for a given service name on macOS.
func StorageDir(service string) string {
home, err := vfs.UserHomeDir()
@@ -85,7 +91,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
return
} else if !errors.Is(err, keyring.ErrNotFound) {
// Not ErrNotFound, which means access was denied or blocked by the system
resCh <- result{key: nil, err: errors.New("keychain access blocked")}
resCh <- result{key: nil, err: errKeychainBlocked}
return
}
@@ -117,7 +123,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
return res.key, res.err
case <-ctx.Done():
// Timeout is usually caused by ignored/blocked permission prompts
return nil, errors.New("keychain access blocked")
return nil, errKeychainBlocked
}
}
@@ -265,11 +271,7 @@ func platformGet(service, account string) (string, error) {
if err != nil {
return "", err
}
plaintext, err := decryptData(data, key)
if err != nil {
return "", err
}
return plaintext, nil
return decryptData(data, key)
}
// platformSet stores a value in the macOS keychain.
@@ -316,3 +318,116 @@ func platformRemove(service, account string) error {
}
return nil
}
// DowngradeResult reports what DowngradeMasterKeyToFile did. The command
// never writes to or removes from the OS Keychain — it only reads from it
// and only writes to the local file fallback.
type DowngradeResult int
const (
// DowngradeAlreadyDone means master.key.file was already present and valid.
DowngradeAlreadyDone DowngradeResult = iota
// DowngradeUsedKeychainKey means the existing OS Keychain master key was
// copied verbatim into the local file fallback. Existing .enc credentials
// remain readable via the file path.
DowngradeUsedKeychainKey
// DowngradeCreatedNewKey means the OS Keychain held no master key, so a
// fresh random key was generated and written to the file fallback only.
// The OS Keychain was not touched.
DowngradeCreatedNewKey
)
// MasterKeyFilePath returns the absolute path of the file fallback master key
// for the given service.
func MasterKeyFilePath(service string) string {
return filepath.Join(StorageDir(service), fileMasterKeyName)
}
// DowngradeMasterKeyToFile materializes the OS Keychain master key into the
// local file fallback so that subsequent platformGet calls take the file-first
// path and bypass the OS Keychain entirely. The Keychain entry itself is kept
// as a cold backup; nothing is removed there.
//
// Idempotent: if master.key.file is already present and valid, returns
// DowngradeAlreadyDone without touching anything.
func DowngradeMasterKeyToFile(service string) (DowngradeResult, error) {
dir := StorageDir(service)
keyPath := filepath.Join(dir, fileMasterKeyName)
existing, statErr := vfs.ReadFile(keyPath)
if statErr == nil {
if len(existing) == masterKeyBytes {
return DowngradeAlreadyDone, nil
}
return 0, errors.New("keychain is corrupted")
}
if !errors.Is(statErr, os.ErrNotExist) {
return 0, statErr
}
result := DowngradeUsedKeychainKey
key, err := getMasterKey(service, false)
if err != nil {
if !errors.Is(err, errNotInitialized) {
return 0, err
}
// Keychain has no master key. Generate a fresh one *locally* — do
// NOT call getMasterKey(service, true), which would write the new
// key into the OS Keychain as a side effect. keychain-downgrade
// must never modify the OS Keychain; it only ever reads from it.
key = make([]byte, masterKeyBytes)
if _, err := rand.Read(key); err != nil {
return 0, err
}
result = DowngradeCreatedNewKey
}
if err := vfs.MkdirAll(dir, 0700); err != nil {
return 0, err
}
file, err := vfs.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
if errors.Is(err, os.ErrExist) {
concurrent, readErr := vfs.ReadFile(keyPath)
if readErr == nil && len(concurrent) == masterKeyBytes {
return DowngradeAlreadyDone, nil
}
if readErr != nil {
return 0, readErr
}
return 0, errors.New("keychain is corrupted")
}
return 0, err
}
writeFailed := true
defer func() {
if writeFailed {
_ = vfs.Remove(keyPath)
}
}()
if _, err := file.Write(key); err != nil {
_ = file.Close()
return 0, err
}
if err := file.Close(); err != nil {
return 0, err
}
writeFailed = false
return result, nil
}
// extraHint appends a darwin-specific suggestion to wrapError's hint message
// when the failure is one keychain-downgrade can recover from: either the
// master key is missing (errNotInitialized) or the OS Keychain is reachable
// but blocking access (errKeychainBlocked — sandbox, denied prompt, timeout).
// In both cases the user can run keychain-downgrade from an interactive
// Terminal session, after which the file fallback is readable from any
// context (sandbox, automation, CI, etc.). Corruption errors are
// deliberately excluded — downgrade would re-read the same bad bytes and
// fail; the right fix there is to delete the corrupt Keychain entry first.
func extraHint(err error) string {
if errors.Is(err, errNotInitialized) || errors.Is(err, errKeychainBlocked) {
return " On macOS, you can also open an interactive Terminal session (where the system Keychain is reachable) and run `lark-cli config keychain-downgrade` to materialize the master key into a local file; subsequent runs in this sandbox/automation context will then read from the file and succeed. Trade-off: after downgrade, any process running as your macOS user can read that file (file permissions replace the Keychain's per-app ACL)."
}
return ""
}

View File

@@ -10,8 +10,10 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/zalando/go-keyring"
)
@@ -111,6 +113,305 @@ func TestPlatformGetPrefersFileMasterKey(t *testing.T) {
}
}
// TestDowngradeAlreadyDoneIsIdempotent verifies that re-running downgrade
// when master.key.file already exists is a no-op and reports AlreadyDone
// without touching the system keychain.
func TestDowngradeAlreadyDoneIsIdempotent(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
origGet := keyringGet
origSet := keyringSet
keyringGet = func(service, user string) (string, error) {
t.Fatalf("keyringGet should not be called when master.key.file is already valid")
return "", nil
}
keyringSet = func(service, user, password string) error {
t.Fatalf("keyringSet should not be called when master.key.file is already valid")
return nil
}
t.Cleanup(func() {
keyringGet = origGet
keyringSet = origSet
})
service := "test-service"
dir := StorageDir(service)
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
preExisting := make([]byte, masterKeyBytes)
for i := range preExisting {
preExisting[i] = byte(i + 7)
}
keyPath := filepath.Join(dir, fileMasterKeyName)
if err := os.WriteFile(keyPath, preExisting, 0600); err != nil {
t.Fatalf("WriteFile(master key) error = %v", err)
}
result, err := DowngradeMasterKeyToFile(service)
if err != nil {
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
}
if result != DowngradeAlreadyDone {
t.Fatalf("result = %v, want DowngradeAlreadyDone", result)
}
after, err := os.ReadFile(keyPath)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if !bytesEqual(after, preExisting) {
t.Fatalf("master.key.file content changed; want preserved")
}
}
// TestDowngradeCopiesKeychainKeyToFile verifies the happy path: a keychain
// key exists, the file does not, and downgrade copies the bytes verbatim
// so that existing .enc files (encrypted with the keychain key) remain
// readable via the file fallback.
func TestDowngradeCopiesKeychainKeyToFile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
keychainKey := make([]byte, masterKeyBytes)
for i := range keychainKey {
keychainKey[i] = byte(i + 11)
}
origGet := keyringGet
origSet := keyringSet
keyringGet = func(service, user string) (string, error) {
return base64.StdEncoding.EncodeToString(keychainKey), nil
}
keyringSet = func(service, user, password string) error {
t.Fatalf("keyringSet should not be called when keychain already has a master key")
return nil
}
t.Cleanup(func() {
keyringGet = origGet
keyringSet = origSet
})
service := "test-service"
result, err := DowngradeMasterKeyToFile(service)
if err != nil {
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
}
if result != DowngradeUsedKeychainKey {
t.Fatalf("result = %v, want DowngradeUsedKeychainKey", result)
}
got, err := os.ReadFile(MasterKeyFilePath(service))
if err != nil {
t.Fatalf("ReadFile(master.key.file) error = %v", err)
}
if !bytesEqual(got, keychainKey) {
t.Fatalf("file key bytes do not match keychain key; existing .enc files would become unreadable")
}
}
// TestDowngradeCreatesNewKeyWhenStorageEmpty verifies the "fresh user"
// path: keychain is empty and no .enc files exist, so we generate a new
// random key and write it to the file fallback. The OS Keychain is NOT
// modified (regression guard for the side-effecting getMasterKey(_, true)
// call we used to make).
func TestDowngradeCreatesNewKeyWhenStorageEmpty(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
origGet := keyringGet
origSet := keyringSet
keyringGet = func(service, user string) (string, error) {
return "", keyring.ErrNotFound
}
keyringSet = func(service, user, password string) error {
t.Fatalf("keyringSet must not be called; keychain-downgrade never writes to the system Keychain")
return nil
}
t.Cleanup(func() {
keyringGet = origGet
keyringSet = origSet
})
service := "test-service"
result, err := DowngradeMasterKeyToFile(service)
if err != nil {
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
}
if result != DowngradeCreatedNewKey {
t.Fatalf("result = %v, want DowngradeCreatedNewKey", result)
}
fileKey, err := os.ReadFile(MasterKeyFilePath(service))
if err != nil {
t.Fatalf("ReadFile(master.key.file) error = %v", err)
}
if len(fileKey) != masterKeyBytes {
t.Fatalf("file key length = %d, want %d", len(fileKey), masterKeyBytes)
}
}
// TestDowngradeDoesNotClobberConcurrentlyWrittenKey is the regression guard
// for the TOCTOU between the initial existence check and the final write.
// Race trace the fix closes:
//
// T0 proc A: ReadFile(keyPath) → ErrNotExist (initial check passes)
// T1 proc B: platformSet → getFileMasterKey(_, true) creates keyPath with K_B
// then writes .enc encrypted with K_B
// T2 proc A: rand.Read → K_A; would overwrite K_B and orphan B's .enc
//
// We simulate proc B's interleaving by performing the concurrent file write
// inside the keyringGet hook — by the time DowngradeMasterKeyToFile gets back
// to the final OpenFile call, the file already exists, the O_EXCL branch
// fires, and the concurrent key is preserved verbatim.
func TestDowngradeDoesNotClobberConcurrentlyWrittenKey(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
service := "test-service"
dir := StorageDir(service)
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
concurrentKey := make([]byte, masterKeyBytes)
for i := range concurrentKey {
concurrentKey[i] = byte(i + 77)
}
origGet := keyringGet
origSet := keyringSet
keyringGet = func(svc, user string) (string, error) {
if err := os.WriteFile(filepath.Join(dir, fileMasterKeyName), concurrentKey, 0600); err != nil {
t.Fatalf("simulated concurrent write failed: %v", err)
}
return "", keyring.ErrNotFound
}
keyringSet = func(svc, user, password string) error {
t.Fatalf("keyringSet must not be called; keychain-downgrade never writes to the system Keychain")
return nil
}
t.Cleanup(func() {
keyringGet = origGet
keyringSet = origSet
})
result, err := DowngradeMasterKeyToFile(service)
if err != nil {
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
}
if result != DowngradeAlreadyDone {
t.Fatalf("result = %v, want DowngradeAlreadyDone (concurrent write must be preserved)", result)
}
got, err := os.ReadFile(filepath.Join(dir, fileMasterKeyName))
if err != nil {
t.Fatalf("ReadFile error = %v", err)
}
if !bytesEqual(got, concurrentKey) {
t.Fatalf("master.key.file was clobbered; concurrent platformSet's encrypted credentials would be orphaned")
}
}
// TestPlatformGetSurfacesKeychainBlocked verifies that "keychain access blocked"
// (the sandbox case) propagates as errKeychainBlocked through platformGet, so
// the wrapError hint chain can attach the keychain-downgrade suggestion.
func TestPlatformGetSurfacesKeychainBlocked(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
origGet := keyringGet
origSet := keyringSet
keyringGet = func(service, user string) (string, error) {
return "", errors.New("sandbox denied keychain access")
}
keyringSet = func(service, user, password string) error {
return nil
}
t.Cleanup(func() {
keyringGet = origGet
keyringSet = origSet
})
service := "test-service"
account := "test-account"
dir := StorageDir(service)
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
lostKey := make([]byte, masterKeyBytes)
for i := range lostKey {
lostKey[i] = byte(i + 55)
}
encrypted, err := encryptData("secret", lostKey)
if err != nil {
t.Fatalf("encryptData() error = %v", err)
}
if err := os.WriteFile(filepath.Join(dir, safeFileName(account)), encrypted, 0600); err != nil {
t.Fatalf("WriteFile(.enc) error = %v", err)
}
_, err = platformGet(service, account)
if !errors.Is(err, errKeychainBlocked) {
t.Fatalf("err = %v, want errKeychainBlocked", err)
}
}
// TestWrapErrorHintMentionsDowngradeForRecoverableCases is the regression
// guard for the bug where `lark-cli api ...` inside a sandbox surfaced
// "keychain access blocked" but the hint did NOT mention keychain-downgrade
// — the very command meant to recover from that exact situation. Root cause:
// the blocked path used an anonymous errors.New string, so the extraHint
// `errors.Is` check (only matched errNotInitialized) couldn't recognize it.
//
// Asserts the full wrapError → ExitError.Detail.Hint pipeline:
// - errKeychainBlocked + errNotInitialized → hint mentions keychain-downgrade
// - "keychain is corrupted" (downgrade would re-read the same bad bytes) → no mention
// - generic errors → no mention
//
// Add new cases here whenever extraHint's matcher widens, to keep the
// promise that the hint is suggested iff downgrade can actually help.
func TestWrapErrorHintMentionsDowngradeForRecoverableCases(t *testing.T) {
cases := []struct {
name string
err error
wantHint bool
}{
{"access blocked (sandbox / denied prompt / timeout)", errKeychainBlocked, true},
{"not initialized (missing master key)", errNotInitialized, true},
{"corrupted (downgrade would re-read the same bad bytes)", errors.New("keychain is corrupted"), false},
{"unrelated generic error", errors.New("something else entirely"), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := wrapError("Get", tc.err)
var ee *output.ExitError
if !errors.As(err, &ee) || ee.Detail == nil {
t.Fatalf("wrapError returned %#v; expected *output.ExitError with Detail", err)
}
got := strings.Contains(ee.Detail.Hint, "keychain-downgrade")
if got != tc.wantHint {
t.Fatalf("hint mentions keychain-downgrade = %v, want %v\n full hint: %q", got, tc.wantHint, ee.Detail.Hint)
}
})
}
}
func bytesEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// TestPlatformSetPrefersExistingFileMasterKey verifies writes stay on the file-based
// master key path once the fallback master key already exists.
func TestPlatformSetPrefersExistingFileMasterKey(t *testing.T) {

View File

@@ -0,0 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !darwin
package keychain
// extraHint is a no-op on non-darwin platforms. The keychain-downgrade
// command is macOS-only, so there is no extra suggestion to surface.
func extraHint(err error) string { return "" }

View File

@@ -66,6 +66,19 @@ const (
// IM resource ownership mismatch.
LarkErrOwnershipMismatch = 231205
// Mail send: account / mailbox-level failures returned by
// POST /open-apis/mail/v1/user_mailboxes/:user_mailbox_id/drafts/:draft_id/send.
// Mail v1 uses service-scoped 123xxxx codes; keep the full upstream code
// because ErrAPI preserves Detail.Code exactly as returned by the server.
// These codes indicate the entire batch will keep failing identically and
// are consumed by shortcuts/mail.isFatalSendErr to abort early.
LarkErrMailboxNotFound = 1234013 // mailbox not found or not active
LarkErrMailSendQuotaUser = 1236007 // user daily send count exceeded
LarkErrMailSendQuotaUserExt = 1236008 // user daily external recipient count exceeded
LarkErrMailSendQuotaTenantExt = 1236009 // tenant daily external recipient count exceeded
LarkErrMailQuota = 1236010 // mail quota limit
LarkErrTenantStorageLimit = 1236013 // tenant storage limit exceeded
)
// legacyHints supplies the per-code actionable hint string for the legacy

View File

@@ -91,6 +91,32 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
}
}
func TestMailSendErrorConstantsUseServiceScopedCodes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
got int
want int
}{
{name: "mailbox not found", got: LarkErrMailboxNotFound, want: 1234013},
{name: "user daily send quota", got: LarkErrMailSendQuotaUser, want: 1236007},
{name: "user external recipient quota", got: LarkErrMailSendQuotaUserExt, want: 1236008},
{name: "tenant external recipient quota", got: LarkErrMailSendQuotaTenantExt, want: 1236009},
{name: "mail quota", got: LarkErrMailQuota, want: 1236010},
{name: "tenant storage limit", got: LarkErrTenantStorageLimit, want: 1236013},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if tt.got != tt.want {
t.Fatalf("code=%d, want %d", tt.got, tt.want)
}
})
}
}
// TestClassifyLarkError_WikiLockContention verifies the wiki write-lock
// contention error (131009) maps to an actionable retry hint instead of
// a generic "api_error". Surfaces during concurrent wiki +node-create

View File

@@ -22,6 +22,64 @@ var registryFS embed.FS
// embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in.
var embeddedMetaJSON []byte
// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers
// that need to parse key order or other JSON-level structure not exposed by
// LoadFromMeta (which loses map insertion order).
func EmbeddedMetaJSON() []byte {
return embeddedMetaJSON
}
var (
embeddedServicesMap map[string]map[string]interface{} // service name -> spec
embeddedServiceNames []string // sorted
embeddedParseOnce sync.Once
)
// parseEmbeddedServices parses embeddedMetaJSON into a service name → spec map
// without touching mergedServices. Safe to call multiple times (sync.Once).
func parseEmbeddedServices() {
embeddedParseOnce.Do(func() {
embeddedServicesMap = make(map[string]map[string]interface{})
if len(embeddedMetaJSON) == 0 {
return
}
var wrapper struct {
Services []map[string]interface{} `json:"services"`
}
if err := json.Unmarshal(embeddedMetaJSON, &wrapper); err != nil {
return
}
for _, svc := range wrapper.Services {
name, _ := svc["name"].(string)
if name == "" {
continue
}
embeddedServicesMap[name] = svc
}
embeddedServiceNames = make([]string, 0, len(embeddedServicesMap))
for name := range embeddedServicesMap {
embeddedServiceNames = append(embeddedServiceNames, name)
}
sort.Strings(embeddedServiceNames)
})
}
// EmbeddedSpec returns the embedded spec for one service, or nil if unknown.
// Bypasses remote overlay — used for deterministic envelope output.
func EmbeddedSpec(serviceName string) map[string]interface{} {
parseEmbeddedServices()
return embeddedServicesMap[serviceName]
}
// EmbeddedServiceNames returns sorted embedded service names (no overlay).
// Returns a defensive copy — callers must not mutate the package-level slice.
func EmbeddedServiceNames() []string {
parseEmbeddedServices()
out := make([]string, len(embeddedServiceNames))
copy(out, embeddedServiceNames)
return out
}
var (
mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec
mergedProjectList []string // sorted project names

View File

@@ -0,0 +1,874 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"bytes"
"encoding/json"
"sort"
"strconv"
"sync"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/registry"
)
// MethodKeyOrder records the natural meta_data.json key order for one method's
// parameters / requestBody / responseBody. Nested object key orders are stored
// under NestedKeys, keyed by dotted path from the method root
// (e.g. "responseBody.items.properties").
type MethodKeyOrder struct {
Parameters []string
RequestBody []string
ResponseBody []string
NestedKeys map[string][]string
}
var (
keyOrderIndex map[string]*MethodKeyOrder // dottedPath -> order
keyOrderInitOnce sync.Once
)
// lookupKeyOrder returns the key-order record for service.resourcePath.method,
// or nil if the method is not in the embedded data (e.g. remote-cached).
func lookupKeyOrder(service string, resourcePath []string, method string) *MethodKeyOrder {
keyOrderInitOnce.Do(buildKeyOrderIndex)
if keyOrderIndex == nil {
return nil
}
dotted := dottedPath(service, resourcePath, method)
return keyOrderIndex[dotted]
}
func dottedPath(service string, resourcePath []string, method string) string {
var buf bytes.Buffer
buf.WriteString(service)
for _, r := range resourcePath {
buf.WriteByte('.')
buf.WriteString(r)
}
buf.WriteByte('.')
buf.WriteString(method)
return buf.String()
}
// buildKeyOrderIndex parses the embedded meta_data.json bytes once at init,
// walking services -> resources -> methods -> {parameters,requestBody,responseBody}
// and recording each map's key insertion order via json.Decoder.Token().
func buildKeyOrderIndex() {
raw := registry.EmbeddedMetaJSON()
if len(raw) == 0 {
return
}
keyOrderIndex = make(map[string]*MethodKeyOrder)
dec := json.NewDecoder(bytes.NewReader(raw))
// Top-level: { "services": [...], "version": "..." }
if !expectDelim(dec, '{') {
return
}
for dec.More() {
key, _ := readKey(dec)
if key != "services" {
skipValue(dec)
continue
}
if !expectDelim(dec, '[') {
return
}
for dec.More() {
parseService(dec)
}
// closing ]
_, _ = dec.Token()
}
}
// parseService consumes one service object inside services[].
// meta_data.json may emit "resources" before "name", so we first capture both
// raw fields, then walk resources with the resolved service name.
func parseService(dec *json.Decoder) {
if !expectDelim(dec, '{') {
return
}
var serviceName string
var resourcesRaw json.RawMessage
for dec.More() {
key, _ := readKey(dec)
switch key {
case "name":
tok, _ := dec.Token()
if s, ok := tok.(string); ok {
serviceName = s
}
case "resources":
if err := dec.Decode(&resourcesRaw); err != nil {
skipValue(dec)
}
default:
skipValue(dec)
}
}
_, _ = dec.Token() // closing }
if serviceName != "" && len(resourcesRaw) > 0 {
subDec := json.NewDecoder(bytes.NewReader(resourcesRaw))
parseResources(subDec, serviceName, nil)
}
}
// parseResources walks a resources map (resName -> resource object).
// resourcePath is the accumulated path of parent resources (for nested resources).
func parseResources(dec *json.Decoder, service string, resourcePath []string) {
if !expectDelim(dec, '{') {
return
}
for dec.More() {
resName, _ := readKey(dec)
parseResourceObj(dec, service, append(resourcePath, resName))
}
_, _ = dec.Token()
}
// parseResourceObj consumes one resource value: { methods: {...}, ... } and may
// recurse into nested resources via "resources" key if present.
func parseResourceObj(dec *json.Decoder, service string, resourcePath []string) {
if !expectDelim(dec, '{') {
return
}
for dec.More() {
key, _ := readKey(dec)
switch key {
case "methods":
parseMethods(dec, service, resourcePath)
case "resources":
parseResources(dec, service, resourcePath)
default:
skipValue(dec)
}
}
_, _ = dec.Token()
}
// parseMethods consumes the methods map (methodName -> method object).
func parseMethods(dec *json.Decoder, service string, resourcePath []string) {
if !expectDelim(dec, '{') {
return
}
for dec.More() {
methodName, _ := readKey(dec)
mko := parseMethod(dec)
dotted := dottedPath(service, resourcePath, methodName)
keyOrderIndex[dotted] = mko
}
_, _ = dec.Token()
}
// parseMethod consumes one method object and records key orders.
func parseMethod(dec *json.Decoder) *MethodKeyOrder {
mko := &MethodKeyOrder{NestedKeys: make(map[string][]string)}
if !expectDelim(dec, '{') {
return mko
}
for dec.More() {
key, _ := readKey(dec)
switch key {
case "parameters":
mko.Parameters = recordObjectKeysRecursive(dec, "parameters", mko.NestedKeys)
case "requestBody":
mko.RequestBody = recordObjectKeysRecursive(dec, "requestBody", mko.NestedKeys)
case "responseBody":
mko.ResponseBody = recordObjectKeysRecursive(dec, "responseBody", mko.NestedKeys)
default:
skipValue(dec)
}
}
_, _ = dec.Token()
return mko
}
// recordObjectKeysRecursive consumes an object and records the top-level key
// order. It also recurses into each child's "properties" submap, recording
// nested orders under prefix.subpath in nestedKeys. Returns the top-level keys
// in order.
func recordObjectKeysRecursive(dec *json.Decoder, prefix string, nestedKeys map[string][]string) []string {
if !expectDelim(dec, '{') {
return nil
}
var order []string
for dec.More() {
key, _ := readKey(dec)
order = append(order, key)
// Each child value is itself an object; we want its nested "properties" order if present.
consumeFieldRecursive(dec, prefix+"."+key, nestedKeys)
}
_, _ = dec.Token()
if prefix != "" && len(order) > 0 {
nestedKeys[prefix] = order
}
return order
}
// consumeFieldRecursive consumes a field object (e.g. one parameter spec) and,
// if it contains "properties": {...}, recursively records that submap's order.
func consumeFieldRecursive(dec *json.Decoder, path string, nestedKeys map[string][]string) {
tok, err := dec.Token()
if err != nil {
return
}
delim, ok := tok.(json.Delim)
if !ok || delim != '{' {
// Not an object — skip the rest of the value
skipValueAfterToken(dec, tok)
return
}
for dec.More() {
fieldKey, _ := readKey(dec)
if fieldKey == "properties" {
recordObjectKeysRecursive(dec, path+".properties", nestedKeys)
} else {
skipValue(dec)
}
}
_, _ = dec.Token()
}
// --- json.Decoder helpers ---
func expectDelim(dec *json.Decoder, want json.Delim) bool {
tok, err := dec.Token()
if err != nil {
return false
}
delim, ok := tok.(json.Delim)
return ok && delim == want
}
func readKey(dec *json.Decoder) (string, error) {
tok, err := dec.Token()
if err != nil {
return "", err
}
s, _ := tok.(string)
return s, nil
}
// skipValue consumes the next complete value (scalar, object, or array).
func skipValue(dec *json.Decoder) {
tok, err := dec.Token()
if err != nil {
return
}
skipValueAfterToken(dec, tok)
}
func skipValueAfterToken(dec *json.Decoder, tok json.Token) {
delim, ok := tok.(json.Delim)
if !ok {
return
}
// We started inside a container of type `delim` ({ or [) and must eat
// tokens until that container closes, tracking nested containers of any
// kind. depth counts how many open containers we are currently inside.
_ = delim
depth := 1
for depth > 0 {
t, err := dec.Token()
if err != nil {
return
}
if d, ok := t.(json.Delim); ok {
switch d {
case '{', '[':
depth++
case '}', ']':
depth--
}
}
}
}
// coerceLiteral converts a meta_data literal (default / enum / example) to
// the JSON Schema type declared by the field (integer/number/boolean/string).
// meta_data stores every literal as a string, so without coercion an
// `integer` field would emit string literals and fail any standard validator.
// Already-typed values pass through unchanged. Returns (value, true) on
// success, or (nil, false) when the literal cannot be coerced (caller should
// drop it).
func coerceLiteral(fieldType string, raw interface{}) (interface{}, bool) {
s, isStr := raw.(string)
if !isStr {
// Already typed (e.g. meta_data emitted a JSON number/bool directly).
return raw, true
}
switch fieldType {
case "integer":
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
return v, true
}
return nil, false
case "number":
if v, err := strconv.ParseFloat(s, 64); err == nil {
return v, true
}
return nil, false
case "boolean":
switch s {
case "true":
return true, true
case "false":
return false, true
}
return nil, false
default: // "string", "" (nested objects), or unknown
return s, true
}
}
// sortEnum sorts an enum slice in-place using a comparator appropriate for
// the declared JSON Schema type, so integer enums end up [1, 2, 10] rather
// than the lexicographic [1, 10, 2].
func sortEnum(fieldType string, vals []interface{}) {
sort.SliceStable(vals, func(i, j int) bool {
switch fieldType {
case "integer":
ai, _ := vals[i].(int64)
bi, _ := vals[j].(int64)
return ai < bi
case "number":
af, _ := vals[i].(float64)
bf, _ := vals[j].(float64)
return af < bf
case "boolean":
ab, _ := vals[i].(bool)
bb, _ := vals[j].(bool)
return !ab && bb // false < true
default:
as, _ := vals[i].(string)
bs, _ := vals[j].(string)
return as < bs
}
})
}
// convertProperty recursively converts one meta_data field map into a Property.
// nestedPath is the dotted lookup key into the current method's NestedKeys map
// (e.g. "responseBody.items.properties"). Empty path = top-level, no nested
// lookup needed.
func convertProperty(field map[string]interface{}, nestedPath string) Property {
var p Property
rawType, _ := field["type"].(string)
switch rawType {
case "file":
p.Type = "string"
p.Format = "binary"
case "list":
// meta_data uses non-standard "list" on a couple of fields;
// translate to JSON Schema "array" so validators accept it.
p.Type = "array"
default:
p.Type = rawType
}
if s, ok := field["description"].(string); ok {
p.Description = s
}
if v, ok := field["default"]; ok {
// Coerce default literal to match the declared JSON Schema type so
// validators do not reject e.g. {type:"integer", default:"500"}.
// When coercion fails (e.g. default:"" on an integer field, which
// meta_data uses to mean "no default"), omit the field entirely
// instead of emitting a type-mismatched default — the result is a
// missing `default` key rather than a contract violation.
if coerced, ok := coerceLiteral(p.Type, v); ok {
p.Default = coerced
}
}
if v, ok := field["example"]; ok {
// meta_data stores examples as strings even when the field is integer/
// boolean/number; coerce to the declared type so downstream validators
// accept the envelope. Drop on coerce failure (same policy as default).
if coerced, ok := coerceLiteral(p.Type, v); ok {
p.Example = coerced
}
}
// min / max are stored as strings in meta_data; parse on best-effort.
if minStr, ok := field["min"].(string); ok && minStr != "" {
if v, err := strconv.ParseFloat(minStr, 64); err == nil {
p.Minimum = &v
}
}
if maxStr, ok := field["max"].(string); ok && maxStr != "" {
if v, err := strconv.ParseFloat(maxStr, 64); err == nil {
p.Maximum = &v
}
}
// enum: prefer existing "enum" array; else extract from options[].value.
// Values are typed per p.Type so integer fields get integer enums, etc.
// (JSON Schema 2020-12 requires enum value types to match the declared
// type — meta_data stores everything as strings.)
if enumRaw, ok := field["enum"].([]interface{}); ok && len(enumRaw) > 0 {
for _, e := range enumRaw {
if v, ok := coerceLiteral(p.Type, e); ok {
p.Enum = append(p.Enum, v)
}
}
// Numeric/boolean enums get sorted (no inherent meaning in meta_data
// order); string enums keep meta_data order, which sometimes carries
// semantic priority (e.g. image_type ["message","avatar"]).
if p.Type != "string" && p.Type != "" {
sortEnum(p.Type, p.Enum)
}
} else if optsRaw, ok := field["options"].([]interface{}); ok && len(optsRaw) > 0 {
seen := make(map[string]bool)
for _, o := range optsRaw {
om, ok := o.(map[string]interface{})
if !ok {
continue
}
raw, ok := om["value"].(string)
if !ok || seen[raw] {
continue
}
seen[raw] = true
if v, ok := coerceLiteral(p.Type, raw); ok {
p.Enum = append(p.Enum, v)
}
}
// Same policy as the `enum` branch: numeric/boolean enums get sorted
// (no semantic meaning in source order); string enums keep meta_data
// order, which may carry semantic priority.
if p.Type != "string" && p.Type != "" {
sortEnum(p.Type, p.Enum)
}
}
// nested properties: recurse
if propsRaw, ok := field["properties"].(map[string]interface{}); ok && len(propsRaw) > 0 {
nested, nestedRequired := buildOrderedProps(propsRaw, nestedPath)
if p.Type == "array" {
// meta_data quirk: array element schema is wrapped in "properties".
// Unfold into Items: { type: "object", properties: <nested> }
p.Items = &Property{
Type: "object",
Properties: nested,
Required: nestedRequired,
}
// Property.Properties stays nil for arrays
} else {
if p.Type == "" {
p.Type = "object" // infer
}
p.Properties = nested
p.Required = nestedRequired
}
}
// array items fallback: emit `items: {}` (any schema) for every array that
// meta_data does not describe an element shape for — whether it arrived as
// "list" or natively as "array". Without this, typeless arrays (e.g. arrays
// of bare ID strings) violate the L1 lint rule and are not JSON Schema valid
// for consumers that require `items`.
if p.Type == "array" && p.Items == nil {
p.Items = &Property{}
}
return p
}
// buildOrderedProps converts a map[string]interface{} of field specs into an
// OrderedProps plus the alphabetized list of child keys marked `required:true`
// in meta_data. Callers attach that list to the enclosing object's `required`,
// so nested objects faithfully report their call contract (top-level required
// is handled separately by buildInputSchema).
func buildOrderedProps(raw map[string]interface{}, nestedPath string) (*OrderedProps, []string) {
op := &OrderedProps{Map: make(map[string]Property, len(raw))}
var required []string
keys := orderedKeys(raw, nestedPath)
for _, k := range keys {
fieldRaw, _ := raw[k].(map[string]interface{})
op.Order = append(op.Order, k)
op.Map[k] = convertProperty(fieldRaw, nestedPath+"."+k+".properties")
if req, _ := fieldRaw["required"].(bool); req {
required = append(required, k)
}
}
sort.Strings(required)
return op, required
}
// currentMethodOrder is the per-method key-order context used by orderedKeys.
// It is set inside AssembleEnvelope (under assembleMu) and reset on return.
var currentMethodOrder *MethodKeyOrder
// parseAffordance lifts the affordance overlay from a method's raw meta_data.json
// entry into a typed *Affordance. Returns nil when the field is absent, malformed,
// or carries no populated subfields.
//
// Affordance is authored in larksuite-cli-registry's registry-config.yaml under
// overrides.<resource>.<method>.affordance and flows through gen-registry.py's
// deep_merge into the embedded meta_data.json.
func parseAffordance(raw interface{}) *Affordance {
if raw == nil {
return nil
}
b, err := json.Marshal(raw)
if err != nil {
return nil
}
var a Affordance
if err := json.Unmarshal(b, &a); err != nil {
return nil
}
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
return nil
}
return &a
}
// convertAccessTokens translates from_meta accessTokens (uses "tenant") into
// CLI --as form (uses "bot"). The result is deduped and sorted alphabetically.
// Unknown tokens are dropped. Returns an empty slice for nil/empty input.
func convertAccessTokens(raw []interface{}) []string {
seen := make(map[string]bool)
for _, t := range raw {
s, ok := t.(string)
if !ok {
continue
}
switch s {
case "tenant":
seen["bot"] = true
case "user":
seen["user"] = true
}
}
out := make([]string, 0, len(seen))
for k := range seen {
out = append(out, k)
}
sort.Strings(out)
return out
}
// buildMeta produces the _meta extension namespace.
func buildMeta(method map[string]interface{}) *Meta {
m := &Meta{
EnvelopeVersion: "1.0",
RequiredScopes: []string{}, // never nil for stable JSON
}
if scopesRaw, ok := method["scopes"].([]interface{}); ok {
for _, s := range scopesRaw {
if str, ok := s.(string); ok {
m.Scopes = append(m.Scopes, str)
}
}
}
if rsRaw, ok := method["requiredScopes"].([]interface{}); ok {
for _, s := range rsRaw {
if str, ok := s.(string); ok {
m.RequiredScopes = append(m.RequiredScopes, str)
}
}
}
atRaw, _ := method["accessTokens"].([]interface{})
m.AccessTokens = convertAccessTokens(atRaw)
m.Danger, _ = method["danger"].(bool)
if risk, _ := method["risk"].(string); risk != "" {
m.Risk = risk
} else {
m.Risk = cmdutil.RiskRead
}
if docURL, _ := method["docUrl"].(string); docURL != "" {
m.DocURL = docURL
}
m.Affordance = parseAffordance(method["affordance"])
return m
}
// buildInputSchema produces the inputSchema for one API method.
//
// Top-level shape:
//
// { type: object,
// required: [<"params" if any param required>, <"data" if any body required>],
// properties: {
// params: { type: object, required: [...], properties: { ...path/query fields } }, // only if method has parameters
// data: { type: object, required: [...], properties: { ...body fields } }, // only if method has requestBody
// yes: { type: boolean, default: false, ... } // only when risk == "high-risk-write"
// } }
//
// The params / data wrapping mirrors the CLI's actual flag layout:
// path+query → --params JSON, body → --data JSON, file → --file. AI consumers
// can pluck inputSchema.properties.params and pass it verbatim to --params.
//
// Caller must set currentMethodOrder for property-order preservation.
func buildInputSchema(method map[string]interface{}) *InputSchema {
is := &InputSchema{
Type: "object",
Required: []string{}, // never nil — stable envelope shape
Properties: &OrderedProps{Map: make(map[string]Property)},
}
// Build the "params" sub-object from method.parameters (path + query).
paramsRaw, _ := method["parameters"].(map[string]interface{})
paramsProps := &OrderedProps{Map: make(map[string]Property)}
var paramsRequired []string
for _, k := range orderedKeys(paramsRaw, "parameters") {
field, _ := paramsRaw[k].(map[string]interface{})
prop := convertProperty(field, "parameters."+k+".properties")
paramsProps.Order = append(paramsProps.Order, k)
paramsProps.Map[k] = prop
if req, _ := field["required"].(bool); req {
paramsRequired = append(paramsRequired, k)
}
}
if len(paramsProps.Order) > 0 {
sort.Strings(paramsRequired)
is.Properties.Order = append(is.Properties.Order, "params")
is.Properties.Map["params"] = Property{
Type: "object",
Required: paramsRequired,
Properties: paramsProps,
}
if len(paramsRequired) > 0 {
is.Required = append(is.Required, "params")
}
}
// Split method.requestBody into two buckets:
// - data: non-file body fields → corresponds to CLI --data JSON
// - file: type:file body fields → corresponds to CLI --file <key>=<path>
// File fields are kept *out* of `data` so the schema mirrors the actual
// CLI flag dispatch: --file owns one wire format (multipart upload),
// --data owns the rest (JSON body).
bodyRaw, _ := method["requestBody"].(map[string]interface{})
dataProps := &OrderedProps{Map: make(map[string]Property)}
fileProps := &OrderedProps{Map: make(map[string]Property)}
var dataRequired []string
var fileRequired []string
for _, k := range orderedKeys(bodyRaw, "requestBody") {
field, _ := bodyRaw[k].(map[string]interface{})
prop := convertProperty(field, "requestBody."+k+".properties")
isFile := false
if t, _ := field["type"].(string); t == "file" {
isFile = true
}
if isFile {
fileProps.Order = append(fileProps.Order, k)
fileProps.Map[k] = prop
if req, _ := field["required"].(bool); req {
fileRequired = append(fileRequired, k)
}
} else {
dataProps.Order = append(dataProps.Order, k)
dataProps.Map[k] = prop
if req, _ := field["required"].(bool); req {
dataRequired = append(dataRequired, k)
}
}
}
if len(dataProps.Order) > 0 {
sort.Strings(dataRequired)
is.Properties.Order = append(is.Properties.Order, "data")
is.Properties.Map["data"] = Property{
Type: "object",
Required: dataRequired,
Properties: dataProps,
}
if len(dataRequired) > 0 {
is.Required = append(is.Required, "data")
}
}
if len(fileProps.Order) > 0 {
sort.Strings(fileRequired)
is.Properties.Order = append(is.Properties.Order, "file")
is.Properties.Map["file"] = Property{
Type: "object",
Description: "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.",
Required: fileRequired,
Properties: fileProps,
}
if len(fileRequired) > 0 {
is.Required = append(is.Required, "file")
}
}
// high-risk-write injects a top-level `yes` confirmation flag — sibling
// of params/data. It is a CLI gate (consumed by lark-cli, not sent to
// the backend), not an API field.
if risk, _ := method["risk"].(string); risk == cmdutil.RiskHighRiskWrite {
is.Properties.Order = append(is.Properties.Order, "yes")
falseVal := false
is.Properties.Map["yes"] = Property{
Type: "boolean",
Default: falseVal,
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.",
}
// yes is intentionally NOT added to top-level Required; the gate is
// enforced semantically (yes==true) by the CLI, not structurally.
}
sort.Strings(is.Required) // alphabetical
return is
}
// buildOutputSchema produces the outputSchema for one API method.
func buildOutputSchema(method map[string]interface{}) *OutputSchema {
os := &OutputSchema{
Type: "object",
Properties: &OrderedProps{Map: make(map[string]Property)},
}
respRaw, _ := method["responseBody"].(map[string]interface{})
for _, k := range orderedKeys(respRaw, "responseBody") {
field, _ := respRaw[k].(map[string]interface{})
os.Properties.Order = append(os.Properties.Order, k)
os.Properties.Map[k] = convertProperty(field, "responseBody."+k+".properties")
}
return os
}
// assembleMu serializes AssembleEnvelope calls so that the package-level
// currentMethodOrder pointer is safe for concurrent callers.
var assembleMu sync.Mutex
// AssembleEnvelope is the main entry point: takes a service / resource path /
// method name plus its meta_data spec, and produces a fully assembled MCP
// envelope. Output is fully determined by inputs (same arguments → same
// envelope), but assembly briefly publishes the per-method key-order context
// through the package-level currentMethodOrder so orderedKeys can reach it
// without threading it through every helper. assembleMu serializes that
// publish, which is why concurrent callers are still safe — they queue
// rather than run in parallel.
//
// If parallelism becomes a bottleneck, replace currentMethodOrder with an
// assembler struct or pass *MethodKeyOrder explicitly down the call chain.
func AssembleEnvelope(serviceName string, resourcePath []string, methodName string, method map[string]interface{}) Envelope {
assembleMu.Lock()
defer assembleMu.Unlock()
currentMethodOrder = lookupKeyOrder(serviceName, resourcePath, methodName)
defer func() { currentMethodOrder = nil }()
name := serviceName
for _, r := range resourcePath {
name += " " + r
}
name += " " + methodName
desc, _ := method["description"].(string)
return Envelope{
Name: name,
Description: desc,
InputSchema: buildInputSchema(method),
OutputSchema: buildOutputSchema(method),
Meta: buildMeta(method),
}
}
// MethodFilter is an optional predicate used by AssembleService and
// AssembleAll to filter methods (e.g. by access token for strict mode).
// Pass nil to include all methods.
type MethodFilter func(method map[string]interface{}) bool
// AssembleService assembles all methods under one service into a sorted
// envelope slice (sorted by Envelope.Name ascending).
func AssembleService(serviceName string, spec map[string]interface{}, filter MethodFilter) []Envelope {
if spec == nil {
return nil
}
resources, _ := spec["resources"].(map[string]interface{})
var out []Envelope
walkMethods(resources, nil, func(resourcePath []string, methodName string, method map[string]interface{}) {
if filter != nil && !filter(method) {
return
}
out = append(out, AssembleEnvelope(serviceName, resourcePath, methodName, method))
})
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// AssembleAll assembles every embedded service into one big sorted slice.
// Uses embedded data only (bypasses remote overlay) so envelope output is
// deterministic across machines (CI vs dev vs different user brands).
func AssembleAll(filter MethodFilter) []Envelope {
var out []Envelope
for _, svc := range registry.EmbeddedServiceNames() {
spec := registry.EmbeddedSpec(svc)
out = append(out, AssembleService(svc, spec, filter)...)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// walkMethods recursively walks resources -> methods, calling visit for each
// terminal method. It supports nested resources via the optional "resources"
// key inside a resource value (matches meta_data.json structure).
func walkMethods(resources map[string]interface{}, parentPath []string,
visit func(resourcePath []string, methodName string, method map[string]interface{})) {
for resName, resRaw := range resources {
resMap, ok := resRaw.(map[string]interface{})
if !ok {
continue
}
curPath := append(append([]string(nil), parentPath...), resName)
if methods, ok := resMap["methods"].(map[string]interface{}); ok {
for mName, mRaw := range methods {
if m, ok := mRaw.(map[string]interface{}); ok {
visit(curPath, mName, m)
}
}
}
if nested, ok := resMap["resources"].(map[string]interface{}); ok {
walkMethods(nested, curPath, visit)
}
}
}
// orderedKeys returns the keys of raw in their meta_data natural order if
// the current per-method key-order context has them recorded; otherwise
// alphabetical fallback.
func orderedKeys(raw map[string]interface{}, nestedPath string) []string {
if currentMethodOrder != nil && nestedPath != "" {
if order, ok := currentMethodOrder.NestedKeys[nestedPath]; ok {
// Filter to keys that actually exist in raw (defensive)
out := make([]string, 0, len(order))
seen := make(map[string]bool)
for _, k := range order {
if _, ok := raw[k]; ok {
out = append(out, k)
seen[k] = true
}
}
// Append any keys present in raw but missing from order (defensive),
// alphabetically for determinism.
var extra []string
for k := range raw {
if !seen[k] {
extra = append(extra, k)
}
}
sort.Strings(extra)
out = append(out, extra...)
return out
}
}
// Fallback: alphabetical
keys := make([]string, 0, len(raw))
for k := range raw {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View File

@@ -0,0 +1,782 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"encoding/json"
"os"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/registry"
)
// TestMain isolates registry-backed tests from any host ~/.lark-cli cache so
// the suite gives the same answer on every machine. Without this, a stale
// local remote_meta.json could surface methods that aren't in the embedded
// snapshot (or alter their data) depending on the contributor's environment.
//
// Note: os.Exit skips deferred functions, so cleanup is done explicitly
// after m.Run before exiting.
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "schema-test-cfg-*")
if err != nil {
// Surface the failure rather than silently running against the host
// cache — that defeats the whole purpose of this isolation.
println("schema test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
os.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
os.Setenv("LARKSUITE_CLI_REMOTE_META", "off") // never touch network
code := m.Run()
os.RemoveAll(dir)
os.Exit(code)
}
func TestKeyOrderIndex_ImReactionsList(t *testing.T) {
// We only assert key-set membership, not absolute order — the upstream
// meta_data API does not guarantee a stable JSON key sequence across
// fetches, so hard-coding the order makes CI flaky. Order preservation
// from input to output is tested separately in TestBuildInputSchema_*.
order := lookupKeyOrder("im", []string{"reactions"}, "list")
if order == nil {
t.Fatal("expected key order for im.reactions.list, got nil")
}
wantParams := map[string]bool{
"message_id": true, "reaction_type": true, "page_token": true,
"page_size": true, "user_id_type": true,
}
if got, want := len(order.Parameters), len(wantParams); got != want {
t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters)
}
for _, k := range order.Parameters {
if !wantParams[k] {
t.Errorf("unexpected parameter key %q", k)
}
}
// im.reactions.list 是 GET没有 requestBody
if len(order.RequestBody) != 0 {
t.Errorf("expected empty RequestBody, got %v", order.RequestBody)
}
}
func TestKeyOrderIndex_ImImagesCreate(t *testing.T) {
// Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList.
order := lookupKeyOrder("im", []string{"images"}, "create")
if order == nil {
t.Fatal("expected key order for im.images.create, got nil")
}
wantBody := map[string]bool{"image_type": true, "image": true}
if got, want := len(order.RequestBody), len(wantBody); got != want {
t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody)
}
for _, k := range order.RequestBody {
if !wantBody[k] {
t.Errorf("unexpected requestBody key %q", k)
}
}
}
func TestKeyOrderIndex_UnknownPath(t *testing.T) {
// 远端缓存的命令(不在 embedded 内)查不到 key order返回 nil 走字母序兜底
order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar")
if order != nil {
t.Errorf("expected nil for unknown path, got %+v", order)
}
}
func TestConvertProperty_BasicTypes(t *testing.T) {
tests := []struct {
name string
input map[string]interface{}
wantType string
}{
{"string", map[string]interface{}{"type": "string"}, "string"},
{"integer", map[string]interface{}{"type": "integer"}, "integer"},
{"boolean", map[string]interface{}{"type": "boolean"}, "boolean"},
{"number", map[string]interface{}{"type": "number"}, "number"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertProperty(tt.input, "")
if got.Type != tt.wantType {
t.Errorf("Type = %q, want %q", got.Type, tt.wantType)
}
})
}
}
func TestConvertProperty_FileBinary(t *testing.T) {
input := map[string]interface{}{"type": "file", "description": "upload"}
got := convertProperty(input, "")
if got.Type != "string" {
t.Errorf("Type = %q, want \"string\"", got.Type)
}
if got.Format != "binary" {
t.Errorf("Format = %q, want \"binary\"", got.Format)
}
}
func TestConvertProperty_OptionsToEnum(t *testing.T) {
input := map[string]interface{}{
"type": "string",
"options": []interface{}{
map[string]interface{}{"value": "banana"},
map[string]interface{}{"value": "apple"},
map[string]interface{}{"value": "banana"}, // duplicate
},
}
got := convertProperty(input, "")
// string enums preserve source order (deduped), matching the `enum`
// branch. Numeric/boolean enums would still be sorted by value.
want := []interface{}{"banana", "apple"}
if !reflect.DeepEqual(got.Enum, want) {
t.Errorf("Enum = %v, want %v", got.Enum, want)
}
}
func TestConvertProperty_EnumPassThrough(t *testing.T) {
input := map[string]interface{}{
"type": "string",
"enum": []interface{}{"x", "y"},
}
got := convertProperty(input, "")
want := []interface{}{"x", "y"} // pass through, no sort
if !reflect.DeepEqual(got.Enum, want) {
t.Errorf("Enum = %v, want %v", got.Enum, want)
}
}
func TestConvertProperty_EnumIntegerCoerce(t *testing.T) {
input := map[string]interface{}{
"type": "integer",
"options": []interface{}{
map[string]interface{}{"value": "10"},
map[string]interface{}{"value": "1"},
map[string]interface{}{"value": "2"},
},
}
got := convertProperty(input, "")
want := []interface{}{int64(1), int64(2), int64(10)} // typed + numerically sorted
if !reflect.DeepEqual(got.Enum, want) {
t.Errorf("Enum = %v, want %v", got.Enum, want)
}
}
func TestConvertProperty_ListTypeFallback(t *testing.T) {
input := map[string]interface{}{
"type": "list",
"description": "ids",
}
got := convertProperty(input, "")
if got.Type != "array" {
t.Errorf("Type = %q, want %q", got.Type, "array")
}
if got.Items == nil {
t.Fatalf("Items = nil, want non-nil (any-schema fallback)")
}
}
func TestConvertProperty_MinMaxParsing(t *testing.T) {
input := map[string]interface{}{"type": "integer", "min": "10", "max": "50"}
got := convertProperty(input, "")
if got.Minimum == nil || *got.Minimum != 10.0 {
t.Errorf("Minimum = %v, want 10", got.Minimum)
}
if got.Maximum == nil || *got.Maximum != 50.0 {
t.Errorf("Maximum = %v, want 50", got.Maximum)
}
}
func TestConvertProperty_MinMaxInvalid(t *testing.T) {
input := map[string]interface{}{"type": "integer", "min": "not_a_number"}
got := convertProperty(input, "")
if got.Minimum != nil {
t.Errorf("Minimum = %v, want nil for unparseable min", got.Minimum)
}
}
func TestConvertProperty_ArrayWithProperties(t *testing.T) {
// meta_data quirk: array element schema is in "properties" not "items"
input := map[string]interface{}{
"type": "array",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "string"},
"name": map[string]interface{}{"type": "string"},
},
}
got := convertProperty(input, "")
if got.Type != "array" {
t.Fatalf("Type = %q, want \"array\"", got.Type)
}
if got.Items == nil {
t.Fatal("Items is nil, want non-nil")
}
if got.Items.Type != "object" {
t.Errorf("Items.Type = %q, want \"object\"", got.Items.Type)
}
if got.Items.Properties == nil || len(got.Items.Properties.Map) != 2 {
t.Errorf("Items.Properties did not contain both id and name")
}
if got.Properties != nil {
t.Error("array Property must not have top-level Properties after unfold")
}
}
func TestConvertProperty_ObjectWithProperties(t *testing.T) {
input := map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"x": map[string]interface{}{"type": "string"},
},
}
got := convertProperty(input, "")
if got.Type != "object" {
t.Errorf("Type = %q, want \"object\"", got.Type)
}
if got.Properties == nil || got.Properties.Map["x"].Type != "string" {
t.Errorf("nested Properties not preserved")
}
}
func TestConvertProperty_InferObjectFromProperties(t *testing.T) {
input := map[string]interface{}{
"properties": map[string]interface{}{
"y": map[string]interface{}{"type": "string"},
},
}
got := convertProperty(input, "")
if got.Type != "object" {
t.Errorf("Type = %q, want \"object\" (inferred)", got.Type)
}
}
func TestConvertProperty_DropsRefAndAnnotations(t *testing.T) {
input := map[string]interface{}{
"type": "string",
"ref": "operator",
"annotations": []interface{}{"readOnly"},
"enumName": "FooEnum",
}
got := convertProperty(input, "")
// 这些字段直接被丢弃Property 结构里也没存这些字段,断言只有 type 设置即可
if got.Type != "string" {
t.Errorf("Type = %q", got.Type)
}
}
func TestConvertProperty_DescriptionDefaultExample(t *testing.T) {
input := map[string]interface{}{
"type": "string",
"description": "hello\nworld",
"default": "",
"example": "ex",
}
got := convertProperty(input, "")
if got.Description != "hello\nworld" {
t.Errorf("Description not preserved verbatim")
}
if got.Default != "" {
t.Errorf("Default = %v, want empty string (preserved)", got.Default)
}
if got.Example != "ex" {
t.Errorf("Example = %v, want \"ex\"", got.Example)
}
}
func TestBuildInputSchema_ReactionsList(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
if is.Type != "object" {
t.Errorf("Type = %q, want \"object\"", is.Type)
}
// top-level required: ["params"] because message_id is a required path param
if !reflect.DeepEqual(is.Required, []string{"params"}) {
t.Errorf("Required = %v, want [params]", is.Required)
}
// top-level properties only contains "params" (no body fields, no high-risk-write)
if !reflect.DeepEqual(is.Properties.Order, []string{"params"}) {
t.Errorf("top-level properties order = %v, want [params]", is.Properties.Order)
}
// params sub-object: required + property order
params := is.Properties.Map["params"]
if params.Type != "object" {
t.Errorf("params.Type = %q, want \"object\"", params.Type)
}
if !reflect.DeepEqual(params.Required, []string{"message_id"}) {
t.Errorf("params.Required = %v, want [message_id]", params.Required)
}
if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) {
t.Errorf("params.properties order = %v, want (from key index) %v",
params.Properties.Order, mko.Parameters)
}
}
func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"images"}, "create")
currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create")
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
// top-level required: ["data", "file"] — image_type body required + image file required
if !reflect.DeepEqual(is.Required, []string{"data", "file"}) {
t.Errorf("Required = %v, want [data, file]", is.Required)
}
// top-level properties: data (for non-file body) + file (for binary upload)
if !reflect.DeepEqual(is.Properties.Order, []string{"data", "file"}) {
t.Errorf("top-level properties order = %v, want [data, file]", is.Properties.Order)
}
// data sub-object carries only non-file body fields (image_type)
data := is.Properties.Map["data"]
if !reflect.DeepEqual(data.Required, []string{"image_type"}) {
t.Errorf("data.Required = %v, want [image_type]", data.Required)
}
if !reflect.DeepEqual(data.Properties.Order, []string{"image_type"}) {
t.Errorf("data.properties order = %v, want [image_type]", data.Properties.Order)
}
if it := data.Properties.Map["image_type"]; !reflect.DeepEqual(it.Enum, []interface{}{"message", "avatar"}) {
t.Errorf("image_type unexpected: %+v", it)
}
if _, isFile := data.Properties.Map["image"]; isFile {
t.Errorf("image (file field) should NOT appear in data sub-object")
}
// file sub-object carries the binary upload field
file := is.Properties.Map["file"]
if file.Type != "object" {
t.Errorf("file.Type = %q, want \"object\"", file.Type)
}
if !reflect.DeepEqual(file.Required, []string{"image"}) {
t.Errorf("file.Required = %v, want [image]", file.Required)
}
if !reflect.DeepEqual(file.Properties.Order, []string{"image"}) {
t.Errorf("file.properties order = %v, want [image]", file.Properties.Order)
}
img := file.Properties.Map["image"]
if img.Type != "string" {
t.Errorf("image.Type = %q, want \"string\"", img.Type)
}
if img.Format != "binary" {
t.Errorf("image.Format = %q, want \"binary\"", img.Format)
}
}
func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
// Synthesized method to avoid registry-overlay variance (remote cache may
// strip `risk` field); buildInputSchema only cares about the method map.
method := map[string]interface{}{
"risk": "high-risk-write",
"parameters": map[string]interface{}{
"message_id": map[string]interface{}{
"type": "string",
"location": "path",
"required": true,
},
},
}
currentMethodOrder = nil
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
// yes lives at inputSchema.properties.yes (sibling of params/data)
yes, ok := is.Properties.Map["yes"]
if !ok {
t.Fatal("expected top-level `yes` property in high-risk-write envelope, not found")
}
if yes.Type != "boolean" {
t.Errorf("yes.Type = %q, want \"boolean\"", yes.Type)
}
if v, _ := yes.Default.(bool); v != false {
t.Errorf("yes.Default = %v, want false", yes.Default)
}
// yes must NOT be in top-level required
for _, r := range is.Required {
if r == "yes" {
t.Errorf("`yes` should not appear in top-level required")
}
}
// yes is appended to properties.Order
last := is.Properties.Order[len(is.Properties.Order)-1]
if last != "yes" {
t.Errorf("`yes` should be last in properties.Order, got: %v", is.Properties.Order)
}
}
func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
if _, ok := is.Properties.Map["yes"]; ok {
t.Errorf("`yes` must not be injected for risk=read")
}
}
func TestBuildOutputSchema_ReactionsList(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
os := buildOutputSchema(method)
if os.Type != "object" {
t.Errorf("Type = %q, want \"object\"", os.Type)
}
// Top-level response: has_more, page_token, items
if _, ok := os.Properties.Map["items"]; !ok {
t.Fatal("items not found in outputSchema")
}
items := os.Properties.Map["items"]
if items.Type != "array" {
t.Errorf("items.Type = %q, want \"array\"", items.Type)
}
if items.Items == nil {
t.Fatal("items.Items is nil (array unfold failed)")
}
if items.Items.Type != "object" {
t.Errorf("items.Items.Type = %q, want \"object\"", items.Items.Type)
}
}
func TestConvertAccessTokens(t *testing.T) {
tests := []struct {
name string
input []interface{}
want []string
}{
{"tenant only", []interface{}{"tenant"}, []string{"bot"}},
{"user only", []interface{}{"user"}, []string{"user"}},
{"tenant then user", []interface{}{"tenant", "user"}, []string{"bot", "user"}},
{"user then tenant", []interface{}{"user", "tenant"}, []string{"bot", "user"}},
{"deduped", []interface{}{"tenant", "tenant", "user"}, []string{"bot", "user"}},
{"empty", []interface{}{}, []string{}},
{"nil", nil, []string{}},
{"unknown skipped", []interface{}{"user", "admin"}, []string{"user"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertAccessTokens(tt.input)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
func TestBuildMeta_FullFields(t *testing.T) {
// Synthesized method to avoid runtime variance from remote-cache overlay
// (which strips `risk` from merged services). All other field semantics
// match the real im.images.create entry in meta_data.json.
method := map[string]interface{}{
"risk": "write",
"danger": true,
"scopes": []interface{}{
"im:resource:upload",
"im:resource",
},
"accessTokens": []interface{}{"tenant"},
"docUrl": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create",
}
m := buildMeta(method)
if m.EnvelopeVersion != "1.0" {
t.Errorf("EnvelopeVersion = %q", m.EnvelopeVersion)
}
if m.Risk != "write" {
t.Errorf("Risk = %q, want \"write\"", m.Risk)
}
if !m.Danger {
t.Errorf("Danger = false, want true")
}
if !reflect.DeepEqual(m.AccessTokens, []string{"bot"}) {
t.Errorf("AccessTokens = %v, want [bot]", m.AccessTokens)
}
if m.DocURL == "" {
t.Errorf("DocURL should be present for im.images.create")
}
if !reflect.DeepEqual(m.Scopes, []string{"im:resource:upload", "im:resource"}) {
t.Errorf("Scopes = %v, want [im:resource:upload, im:resource] (meta_data natural order)", m.Scopes)
}
if m.RequiredScopes == nil {
t.Errorf("RequiredScopes should be empty slice, not nil")
}
if len(m.RequiredScopes) != 0 {
t.Errorf("RequiredScopes should be empty for this method, got %v", m.RequiredScopes)
}
if m.Affordance != nil {
t.Errorf("Affordance must be nil when method has no affordance field, got %+v", m.Affordance)
}
}
func TestBuildMeta_MissingRiskDefaultsToRead(t *testing.T) {
method := map[string]interface{}{
"scopes": []interface{}{"x"},
"accessTokens": []interface{}{"user"},
// no risk field
}
m := buildMeta(method)
if m.Risk != "read" {
t.Errorf("Risk = %q, want \"read\" (default for missing risk)", m.Risk)
}
}
func TestBuildMeta_RequiredScopesPresent(t *testing.T) {
method := loadMethodFromRegistry(t, "mail", []string{"user_mailbox", "messages"}, "get")
m := buildMeta(method)
if len(m.RequiredScopes) == 0 {
t.Errorf("RequiredScopes should be non-empty for mail.user_mailbox.messages.get")
}
}
func TestParseAffordance_NilOrEmpty(t *testing.T) {
cases := []struct {
name string
raw interface{}
}{
{"nil", nil},
{"empty object", map[string]interface{}{}},
{"all-five-empty-arrays", map[string]interface{}{
"use_when": []interface{}{},
"do_not_use_when": []interface{}{},
"prerequisites": []interface{}{},
"examples": []interface{}{},
"related": []interface{}{},
}},
{"malformed (string)", "not an object"},
{"malformed (number)", 42},
{"malformed (nested type mismatch)", map[string]interface{}{
"examples": "should be a list, not a string",
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := parseAffordance(c.raw); got != nil {
t.Errorf("parseAffordance(%v) = %+v, want nil", c.raw, got)
}
})
}
}
func TestParseAffordance_FullPopulated(t *testing.T) {
raw := map[string]interface{}{
"use_when": []interface{}{"需要拿到当前用户的主日历 ID"},
"do_not_use_when": []interface{}{"已知具体某一个非主日历的 calendar_id"},
"prerequisites": []interface{}{"user 身份登录"},
"examples": []interface{}{
map[string]interface{}{"description": "获取主日历", "command": "lark-cli calendar calendars primary"},
},
"related": []interface{}{"calendars.list"},
}
a := parseAffordance(raw)
if a == nil {
t.Fatal("parseAffordance returned nil, want populated")
}
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
t.Errorf("UseWhen = %v", a.UseWhen)
}
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" ||
a.Examples[0].Command != "lark-cli calendar calendars primary" {
t.Errorf("Examples = %+v", a.Examples)
}
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
t.Errorf("Related = %v", a.Related)
}
}
func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
method := map[string]interface{}{
"scopes": []interface{}{"x"},
"accessTokens": []interface{}{"user"},
"risk": "read",
"affordance": map[string]interface{}{
"use_when": []interface{}{"trigger"},
},
}
m := buildMeta(method)
if m.Affordance == nil {
t.Fatal("Affordance should be populated from method[\"affordance\"]")
}
if len(m.Affordance.UseWhen) != 1 || m.Affordance.UseWhen[0] != "trigger" {
t.Errorf("UseWhen = %v", m.Affordance.UseWhen)
}
}
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
method := map[string]interface{}{
"scopes": []interface{}{"x"},
"accessTokens": []interface{}{"user"},
"risk": "read",
// no docUrl
}
m := buildMeta(method)
if m.DocURL != "" {
t.Errorf("DocURL = %q, want empty (will be omitempty)", m.DocURL)
}
// Verify JSON serialization omits doc_url
b, _ := json.Marshal(m)
if strings.Contains(string(b), "doc_url") {
t.Errorf("doc_url should be omitted from JSON, got: %s", b)
}
}
func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) {
// 装配器对空 responseBody 应生成 properties = {} (不 nil
method := map[string]interface{}{}
currentMethodOrder = nil
os := buildOutputSchema(method)
if os.Type != "object" {
t.Errorf("Type = %q, want \"object\"", os.Type)
}
if os.Properties == nil {
t.Fatal("Properties is nil, want empty OrderedProps")
}
if len(os.Properties.Order) != 0 {
t.Errorf("Properties.Order should be empty, got %v", os.Properties.Order)
}
}
func TestAssembleEnvelope_ReactionsList_FullStructure(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
env := AssembleEnvelope("im", []string{"reactions"}, "list", method)
if env.Name != "im reactions list" {
t.Errorf("Name = %q, want \"im reactions list\"", env.Name)
}
if env.Description == "" {
t.Errorf("Description should not be empty for im.reactions.list")
}
if env.InputSchema == nil || env.OutputSchema == nil || env.Meta == nil {
t.Fatal("InputSchema/OutputSchema/Meta must all be non-nil")
}
if env.Meta.EnvelopeVersion != "1.0" {
t.Errorf("Meta.EnvelopeVersion = %q", env.Meta.EnvelopeVersion)
}
}
func TestAssembleEnvelope_NestedResource_NameJoinedWithSpaces(t *testing.T) {
// im.chat.members.create — resource path is one element "chat.members" with
// an internal dot. Substituted from plan's `bots` because remote-cache
// overlay strips `bots` from the loaded method map on this environment;
// the assertion is about name joining, not method specifics.
method := loadMethodFromRegistry(t, "im", []string{"chat.members"}, "create")
env := AssembleEnvelope("im", []string{"chat.members"}, "create", method)
// chat.members resourcePath stays as one element in the slice with a dot;
// name should split it to "im chat.members create" — we keep the dot as-is
// inside the resource segment to round-trip with completion logic.
if env.Name != "im chat.members create" {
t.Errorf("Name = %q, want \"im chat.members create\"", env.Name)
}
}
func TestAssembleEnvelope_JSONIsStable(t *testing.T) {
// Assemble twice; JSON output must be byte-identical (determinism).
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
a := AssembleEnvelope("im", []string{"reactions"}, "list", method)
b := AssembleEnvelope("im", []string{"reactions"}, "list", method)
ja, _ := json.MarshalIndent(a, "", " ")
jb, _ := json.MarshalIndent(b, "", " ")
if string(ja) != string(jb) {
t.Errorf("envelope assembly is non-deterministic:\nfirst:\n%s\nsecond:\n%s", ja, jb)
}
}
func TestAssembleService_Im(t *testing.T) {
spec := registry.LoadFromMeta("im")
envs := AssembleService("im", spec, nil)
if len(envs) == 0 {
t.Fatal("expected non-empty envelopes for service im")
}
// Every envelope.Name starts with "im "
for _, e := range envs {
if !strings.HasPrefix(e.Name, "im ") {
t.Errorf("envelope name %q does not start with \"im \"", e.Name)
}
}
// Sorted by name
for i := 1; i < len(envs); i++ {
if envs[i-1].Name > envs[i].Name {
t.Errorf("envelopes not sorted by name at idx %d: %q > %q", i, envs[i-1].Name, envs[i].Name)
}
}
}
func TestAssembleService_FilterByAccessToken(t *testing.T) {
spec := registry.LoadFromMeta("im")
// Filter to bot-only (--as bot, which corresponds to "tenant")
envs := AssembleService("im", spec, func(method map[string]interface{}) bool {
tokens, _ := method["accessTokens"].([]interface{})
for _, t := range tokens {
if s, _ := t.(string); s == "tenant" {
return true
}
}
return false
})
// Every envelope's _meta.access_tokens must contain "bot"
for _, e := range envs {
found := false
for _, t := range e.Meta.AccessTokens {
if t == "bot" {
found = true
break
}
}
if !found {
t.Errorf("envelope %q does not declare bot access", e.Name)
}
}
}
func TestAssembleAll_AtLeast193(t *testing.T) {
envs := AssembleAll(nil)
// Envelope assembly is overlay-independent (Task 17b): AssembleAll walks the
// embedded meta_data.json directly, so the count is stable across machines.
if len(envs) < 193 {
t.Errorf("AssembleAll returned %d envelopes, expected >= 193", len(envs))
}
// Spot check: im reactions list should be present
found := false
for _, e := range envs {
if e.Name == "im reactions list" {
found = true
break
}
}
if !found {
t.Errorf("im reactions list not found in AssembleAll output")
}
}
// loadMethodFromRegistry is a test helper that pulls one method's spec from the
// real embedded meta_data.json via the registry package.
func loadMethodFromRegistry(t *testing.T, service string, resourcePath []string, methodName string) map[string]interface{} {
t.Helper()
spec := registry.LoadFromMeta(service)
if spec == nil {
t.Fatalf("service %q not found in registry", service)
}
resources, _ := spec["resources"].(map[string]interface{})
resKey := strings.Join(resourcePath, ".")
res, ok := resources[resKey].(map[string]interface{})
if !ok {
t.Fatalf("resource %q.%s not found", service, resKey)
}
methods, _ := res["methods"].(map[string]interface{})
m, ok := methods[methodName].(map[string]interface{})
if !ok {
t.Fatalf("method %q.%s.%s not found", service, resKey, methodName)
}
return m
}

233
internal/schema/lint.go Normal file
View File

@@ -0,0 +1,233 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"errors"
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
)
var validJSONSchemaTypes = map[string]bool{
"string": true,
"integer": true,
"number": true,
"boolean": true,
"array": true,
"object": true,
}
var validAccessTokens = map[string]bool{
"user": true,
"bot": true,
}
// lintEnvelope runs L1-L3 checks and returns a list of errors. Empty slice
// means the envelope is compliant.
func lintEnvelope(env Envelope) []error {
var errs []error
// ---- L1: structural ----
if env.Name == "" {
errs = append(errs, errors.New("L1: name must not be empty"))
}
if env.InputSchema == nil {
errs = append(errs, errors.New("L1: inputSchema must not be nil"))
} else {
if env.InputSchema.Type != "object" {
errs = append(errs, fmt.Errorf("L1: inputSchema.type = %q, want \"object\"", env.InputSchema.Type))
}
if env.InputSchema.Properties == nil {
errs = append(errs, errors.New("L1: inputSchema.properties must not be nil"))
}
}
if env.OutputSchema == nil {
errs = append(errs, errors.New("L1: outputSchema must not be nil"))
} else {
if env.OutputSchema.Type != "object" {
errs = append(errs, fmt.Errorf("L1: outputSchema.type = %q, want \"object\"", env.OutputSchema.Type))
}
}
if env.Meta == nil {
errs = append(errs, errors.New("L1: _meta must not be nil"))
// Cannot continue meta-dependent checks
return errs
}
if env.Meta.EnvelopeVersion != "1.0" {
errs = append(errs, fmt.Errorf("L1: _meta.envelope_version = %q, want \"1.0\"", env.Meta.EnvelopeVersion))
}
// L1: validate every Property type recursively
if env.InputSchema != nil && env.InputSchema.Properties != nil {
validatePropertyTypes(env.InputSchema.Properties, &errs)
}
if env.OutputSchema != nil && env.OutputSchema.Properties != nil {
validatePropertyTypes(env.OutputSchema.Properties, &errs)
}
// ---- L2: type-level consistency ----
if env.InputSchema != nil && env.InputSchema.Properties != nil {
// Walk the whole property tree so format/min-max checks reach leaf
// fields nested under the params/data wrapper.
walkForL2(env.InputSchema.Properties, &errs)
// Top-level required keys must exist in top-level properties.
for _, r := range env.InputSchema.Required {
if _, ok := env.InputSchema.Properties.Map[r]; !ok {
errs = append(errs, fmt.Errorf("L2: required key %q not found in properties", r))
}
}
}
// ---- L3: cross-field self-consistency ----
dangerExpected := env.Meta.Risk == cmdutil.RiskWrite || env.Meta.Risk == cmdutil.RiskHighRiskWrite
if env.Meta.Danger != dangerExpected {
errs = append(errs, fmt.Errorf("L3: _meta.danger=%v inconsistent with risk=%q", env.Meta.Danger, env.Meta.Risk))
}
// `yes` lives at inputSchema.properties.yes (sibling of params/data),
// injected only for risk == RiskHighRiskWrite.
hasYes := false
if env.InputSchema != nil && env.InputSchema.Properties != nil {
_, hasYes = env.InputSchema.Properties.Map["yes"]
}
wantYes := env.Meta.Risk == cmdutil.RiskHighRiskWrite
if hasYes != wantYes {
errs = append(errs, fmt.Errorf("L3: inputSchema `yes` property=%v inconsistent with risk=%q", hasYes, env.Meta.Risk))
}
if len(env.Meta.AccessTokens) == 0 {
errs = append(errs, errors.New("L3: _meta.access_tokens must not be empty"))
}
for _, t := range env.Meta.AccessTokens {
if !validAccessTokens[t] {
errs = append(errs, fmt.Errorf("L3: _meta.access_tokens contains invalid value %q (allowed: user, bot)", t))
}
}
return errs
}
// walkForL2 recursively applies per-field L2 checks (format:binary on
// non-string; minimum>=maximum) plus the sub-object required-exists invariant.
// Required only matters on object-typed Properties (e.g. the params / data
// wrappers); leaf scalars ignore it.
func walkForL2(props *OrderedProps, errs *[]error) {
if props == nil {
return
}
for _, k := range props.Order {
p := props.Map[k]
if p.Format == "binary" && p.Type != "string" {
*errs = append(*errs, fmt.Errorf("L2: field %q has format: binary but type = %q (want string)", k, p.Type))
}
if p.Minimum != nil && p.Maximum != nil && *p.Minimum >= *p.Maximum {
*errs = append(*errs, fmt.Errorf("L2: field %q minimum (%v) >= maximum (%v)", k, *p.Minimum, *p.Maximum))
}
if len(p.Required) > 0 && p.Properties != nil {
for _, r := range p.Required {
if _, ok := p.Properties.Map[r]; !ok {
*errs = append(*errs, fmt.Errorf("L2: required key %q in %q not found in its properties", r, k))
}
}
}
if p.Properties != nil {
walkForL2(p.Properties, errs)
}
}
}
// validatePropertyTypes walks an OrderedProps tree and asserts:
// - every Property.Type is in validJSONSchemaTypes (or empty for nested objects with only properties)
// - array Properties have Items
//
// Errors are appended to *errs.
func validatePropertyTypes(props *OrderedProps, errs *[]error) {
if props == nil {
return
}
for _, k := range props.Order {
p := props.Map[k]
if p.Type != "" && !validJSONSchemaTypes[p.Type] {
*errs = append(*errs, fmt.Errorf("L1: property %q has invalid type %q", k, p.Type))
}
if p.Type == "array" && p.Items == nil {
*errs = append(*errs, fmt.Errorf("L1: array property %q missing items", k))
}
if p.Properties != nil {
validatePropertyTypes(p.Properties, errs)
}
// Validate the array-element schema itself, not only its child
// properties — a primitive element with an invalid type (e.g.
// `items.type = "list"`) would otherwise slip past lint.
if p.Items != nil {
validateItemSchema(k, p.Items, errs)
}
}
}
// validateItemSchema checks a single array element schema for invalid types,
// then recurses into any further nested properties/items.
func validateItemSchema(parentKey string, item *Property, errs *[]error) {
if item.Type != "" && !validJSONSchemaTypes[item.Type] {
*errs = append(*errs, fmt.Errorf("L1: array property %q items has invalid type %q", parentKey, item.Type))
}
if item.Type == "array" && item.Items == nil {
*errs = append(*errs, fmt.Errorf("L1: array property %q items (nested array) missing items", parentKey))
}
if item.Properties != nil {
validatePropertyTypes(item.Properties, errs)
}
if item.Items != nil {
validateItemSchema(parentKey, item.Items, errs)
}
}
// coverageBaseline is the per-metric warn threshold for L4 coverage checks.
// If the measured rate drops below the baseline, t.Logf emits a warning but
// does NOT fail the test. Adjust these constants upward as meta_data quality
// improves over time.
var coverageBaseline = map[string]float64{
"description": 0.99,
"scopes": 1.00,
"doc_url": 0.98,
"risk": 0.96,
}
// measureCoverage returns the non-empty rate for each tracked metric.
func measureCoverage(envs []Envelope) map[string]float64 {
if len(envs) == 0 {
return map[string]float64{
"description": 0,
"scopes": 0,
"doc_url": 0,
"risk": 0,
}
}
total := float64(len(envs))
var descNonEmpty, scopesNonEmpty, docURLNonEmpty, riskNonEmpty float64
for _, e := range envs {
if e.Description != "" {
descNonEmpty++
}
if e.Meta == nil {
continue
}
if len(e.Meta.Scopes) > 0 {
scopesNonEmpty++
}
if e.Meta.DocURL != "" {
docURLNonEmpty++
}
if e.Meta.Risk != "" {
riskNonEmpty++
}
}
return map[string]float64{
"description": descNonEmpty / total,
"scopes": scopesNonEmpty / total,
"doc_url": docURLNonEmpty / total,
"risk": riskNonEmpty / total,
}
}

View File

@@ -0,0 +1,379 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/registry"
)
// validEnvelope builds a baseline valid envelope used as a starting point in
// negative tests below.
func validEnvelope() Envelope {
props := &OrderedProps{Map: map[string]Property{}}
return Envelope{
Name: "x y z",
Description: "ok",
InputSchema: &InputSchema{
Type: "object",
Properties: props,
},
OutputSchema: &OutputSchema{
Type: "object",
Properties: &OrderedProps{Map: map[string]Property{}},
},
Meta: &Meta{
EnvelopeVersion: "1.0",
AccessTokens: []string{"user"},
Risk: "read",
Danger: false,
},
}
}
func TestLintEnvelope_Valid(t *testing.T) {
env := validEnvelope()
errs := lintEnvelope(env)
if len(errs) != 0 {
t.Errorf("expected no errors, got: %v", errs)
}
}
func TestLintEnvelope_L1_StructuralChecks(t *testing.T) {
tests := []struct {
name string
mutate func(*Envelope)
wantSub string
}{
{
name: "empty name",
mutate: func(e *Envelope) { e.Name = "" },
wantSub: "name",
},
{
name: "nil InputSchema",
mutate: func(e *Envelope) { e.InputSchema = nil },
wantSub: "inputSchema",
},
{
name: "inputSchema type not object",
mutate: func(e *Envelope) { e.InputSchema.Type = "string" },
wantSub: "inputSchema.type",
},
{
name: "nil OutputSchema",
mutate: func(e *Envelope) { e.OutputSchema = nil },
wantSub: "outputSchema",
},
{
name: "nil Meta",
mutate: func(e *Envelope) { e.Meta = nil },
wantSub: "_meta",
},
{
name: "wrong envelope version",
mutate: func(e *Envelope) { e.Meta.EnvelopeVersion = "0.9" },
wantSub: "envelope_version",
},
{
name: "invalid property type",
mutate: func(e *Envelope) {
e.InputSchema.Properties.Order = []string{"x"}
e.InputSchema.Properties.Map["x"] = Property{Type: "unknown_type"}
},
wantSub: "invalid type",
},
{
name: "array missing items",
mutate: func(e *Envelope) {
e.InputSchema.Properties.Order = []string{"x"}
e.InputSchema.Properties.Map["x"] = Property{Type: "array"} // no Items
},
wantSub: "items",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := validEnvelope()
tt.mutate(&env)
errs := lintEnvelope(env)
if len(errs) == 0 {
t.Fatalf("expected lint error, got none")
}
found := false
for _, e := range errs {
if strings.Contains(e.Error(), tt.wantSub) {
found = true
break
}
}
if !found {
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
}
})
}
}
func TestLintEnvelope_L2_TypeChecks(t *testing.T) {
tests := []struct {
name string
mutate func(*Envelope)
wantSub string
}{
{
name: "format binary on non-string",
mutate: func(e *Envelope) {
e.InputSchema.Properties.Order = []string{"f"}
e.InputSchema.Properties.Map["f"] = Property{Type: "integer", Format: "binary"}
},
wantSub: "format: binary",
},
{
name: "required key not in properties",
mutate: func(e *Envelope) {
e.InputSchema.Required = []string{"nonexistent"}
},
wantSub: "required",
},
{
name: "minimum >= maximum",
mutate: func(e *Envelope) {
min, max := 50.0, 10.0
e.InputSchema.Properties.Order = []string{"n"}
e.InputSchema.Properties.Map["n"] = Property{Type: "integer", Minimum: &min, Maximum: &max}
},
wantSub: "minimum",
},
{
// Regression guard: walkForL2 must recurse into the params/data
// sub-objects introduced by the 4-bucket inputSchema, not only the
// top-level Properties map.
name: "format binary on non-string inside params sub-object",
mutate: func(e *Envelope) {
e.InputSchema.Properties.Order = []string{"params"}
e.InputSchema.Properties.Map["params"] = Property{
Type: "object",
Properties: &OrderedProps{
Order: []string{"id"},
Map: map[string]Property{
"id": {Type: "integer", Format: "binary"}, // wrong: binary on integer
},
},
}
},
wantSub: "format: binary",
},
{
name: "sub-object required references missing property",
mutate: func(e *Envelope) {
e.InputSchema.Properties.Order = []string{"data"}
e.InputSchema.Properties.Map["data"] = Property{
Type: "object",
Required: []string{"ghost"}, // not in properties below
Properties: &OrderedProps{
Order: []string{"real"},
Map: map[string]Property{"real": {Type: "string"}},
},
}
},
wantSub: "ghost",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := validEnvelope()
tt.mutate(&env)
errs := lintEnvelope(env)
if len(errs) == 0 {
t.Fatalf("expected lint error, got none")
}
found := false
for _, e := range errs {
if strings.Contains(e.Error(), tt.wantSub) {
found = true
break
}
}
if !found {
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
}
})
}
}
func TestLintEnvelope_L3_CrossFieldChecks(t *testing.T) {
tests := []struct {
name string
mutate func(*Envelope)
wantSub string
}{
{
name: "danger true but risk read",
mutate: func(e *Envelope) {
e.Meta.Danger = true
e.Meta.Risk = "read"
},
wantSub: "danger",
},
{
name: "high-risk-write without yes",
mutate: func(e *Envelope) {
e.Meta.Risk = "high-risk-write"
e.Meta.Danger = true
// no yes injection
},
wantSub: "yes",
},
{
name: "yes injected but risk not high-risk-write",
mutate: func(e *Envelope) {
e.InputSchema.Properties.Order = []string{"yes"}
e.InputSchema.Properties.Map["yes"] = Property{Type: "boolean"}
},
wantSub: "yes",
},
{
name: "empty access_tokens",
mutate: func(e *Envelope) {
e.Meta.AccessTokens = []string{}
},
wantSub: "access_tokens",
},
{
name: "invalid access_token value",
mutate: func(e *Envelope) {
e.Meta.AccessTokens = []string{"admin"}
},
wantSub: "access_tokens",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := validEnvelope()
tt.mutate(&env)
errs := lintEnvelope(env)
if len(errs) == 0 {
t.Fatalf("expected lint error, got none")
}
found := false
for _, e := range errs {
if strings.Contains(e.Error(), tt.wantSub) {
found = true
break
}
}
if !found {
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
}
})
}
}
func TestMeasureCoverage_Counts(t *testing.T) {
envs := []Envelope{
{Description: "ok", Meta: &Meta{Scopes: []string{"s"}, Risk: "read", DocURL: "http://x"}},
{Description: "", Meta: &Meta{Scopes: []string{}, Risk: "", DocURL: ""}},
{Description: "ok2", Meta: &Meta{Scopes: []string{"s"}, Risk: "write", DocURL: "http://y"}},
}
c := measureCoverage(envs)
// 2/3 have non-empty description = ~0.667
if c["description"] < 0.66 || c["description"] > 0.67 {
t.Errorf("description coverage = %v, want ~0.667", c["description"])
}
// 2/3 have non-empty scopes
if c["scopes"] < 0.66 || c["scopes"] > 0.67 {
t.Errorf("scopes coverage = %v, want ~0.667", c["scopes"])
}
// 2/3 have doc_url
if c["doc_url"] < 0.66 || c["doc_url"] > 0.67 {
t.Errorf("doc_url coverage = %v, want ~0.667", c["doc_url"])
}
// 2/3 have non-empty risk (but our builder always fills risk with "read" default — this test uses raw envs)
if c["risk"] < 0.66 || c["risk"] > 0.67 {
t.Errorf("risk coverage = %v, want ~0.667", c["risk"])
}
}
// isKnownDataInconsistency returns true for lint errors that originate from
// real meta_data quality issues we still have to ship around in PR-1. With
// Task 17b the assembler walks embedded data only, so overlay-induced
// inconsistencies (risk-stripping) no longer appear; only the true embedded
// meta_data data-quality patterns remain.
//
// As meta_data quality improves this filter should be tightened/removed so
// TestAllEnvelopesPass becomes a hard gate again.
func isKnownDataInconsistency(msg string) bool {
switch {
case strings.Contains(msg, `L3: _meta.danger=false inconsistent with risk="write"`):
// Embedded meta_data has ~7 envelopes (e.g. attendance.user_tasks.query,
// drive.user.subscription, mail.user_mailbox.event.subscribe) where
// `risk="write"` but `danger` is missing (defaults to false). Needs a
// meta_data fix to set danger=true on these write methods.
return true
case strings.Contains(msg, `L3: _meta.danger=true inconsistent with risk="read"`):
// Embedded meta_data has ~9 envelopes (e.g. calendar.events.search_event,
// drive.metas.batch_query, mail.user_mailbox.templates.create) where
// `danger=true` but `risk` is missing (defaults to "read"). Needs a
// meta_data fix to set the proper risk level on these methods.
return true
case strings.Contains(msg, "L2: field") && strings.Contains(msg, "minimum") && strings.Contains(msg, "maximum"):
// meta_data sets min == max on some fields (e.g.
// mail.user_mailbox.event.subscribe.event_type), which the lint reads
// as min >= max. Real fix is in meta_data.
return true
}
return false
}
func TestAllEnvelopesPass(t *testing.T) {
failCount := 0
knownWarnings := 0
knownEnvelopes := map[string]bool{}
// Use embedded data only so the gate is deterministic across machines
// (matches Task 17b: envelope assembly is overlay-independent).
for _, svc := range registry.EmbeddedServiceNames() {
spec := registry.EmbeddedSpec(svc)
envs := AssembleService(svc, spec, nil)
for _, env := range envs {
errs := lintEnvelope(env)
if len(errs) == 0 {
continue
}
var realErrs []error
for _, e := range errs {
if isKnownDataInconsistency(e.Error()) {
t.Logf("env %s skipped: known data-level inconsistency: %v", env.Name, e)
knownWarnings++
knownEnvelopes[env.Name] = true
continue
}
realErrs = append(realErrs, e)
}
if len(realErrs) > 0 {
for _, e := range realErrs {
t.Errorf("%s: %v", env.Name, e)
}
failCount++
}
}
}
t.Logf("L1-L3 known data-level inconsistencies: %d warnings across %d envelopes (danger/risk mismatch + min==max)", knownWarnings, len(knownEnvelopes))
if failCount > 0 {
t.Fatalf("%d envelopes failed L1-L3 lint with non-data-level errors", failCount)
}
// L4 coverage report (warn-only via t.Logf)
all := AssembleAll(nil)
c := measureCoverage(all)
for metric, rate := range c {
baseline := coverageBaseline[metric]
if rate < baseline {
t.Logf("L4 coverage warn: %s = %.1f%% (baseline: %.1f%%)", metric, rate*100, baseline*100)
} else {
t.Logf("L4 coverage ok: %s = %.1f%% (baseline: %.1f%%)", metric, rate*100, baseline*100)
}
}
}

30
internal/schema/path.go Normal file
View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import "strings"
// ParsePath normalizes the positional arguments of `lark-cli schema` into a
// slice of path segments. It accepts two equivalent forms:
//
// lark-cli schema im.messages.reply -> single arg, split on "."
// lark-cli schema im messages reply -> multiple args, used as-is
// lark-cli schema "im chat.members bots" is NOT a supported form; quote
// arguments individually if your shell needs it. Nested resources keep their
// internal dots (e.g. "chat.members").
//
// Returns nil for zero args (bare invocation).
func ParsePath(args []string) []string {
switch len(args) {
case 0:
return nil
case 1:
if strings.Contains(args[0], ".") {
return strings.Split(args[0], ".")
}
return []string{args[0]}
default:
return args
}
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"reflect"
"testing"
)
func TestParsePath(t *testing.T) {
tests := []struct {
name string
args []string
want []string
}{
{"empty args -> nil", nil, nil},
{"empty slice -> nil", []string{}, nil},
{"single dotted", []string{"im.messages.reply"}, []string{"im", "messages", "reply"}},
{"single no-dot", []string{"im"}, []string{"im"}},
{"multi args", []string{"im", "messages", "reply"}, []string{"im", "messages", "reply"}},
{"two args", []string{"im", "messages"}, []string{"im", "messages"}},
{"nested resource dotted", []string{"im.chat.members.bots"}, []string{"im", "chat", "members", "bots"}},
{"nested resource space form", []string{"im", "chat.members", "bots"}, []string{"im", "chat.members", "bots"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParsePath(tt.args)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParsePath(%v) = %v, want %v", tt.args, got, tt.want)
}
})
}
}

164
internal/schema/types.go Normal file
View File

@@ -0,0 +1,164 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"bytes"
"encoding/json"
"fmt"
"sort"
)
// Envelope is the MCP Tool spec contract for a single API method command.
type Envelope struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema *InputSchema `json:"inputSchema"`
OutputSchema *OutputSchema `json:"outputSchema"`
Meta *Meta `json:"_meta"`
}
// InputSchema is JSON Schema Draft 2020-12 flattened.
//
// Required is intentionally rendered (no omitempty) so the envelope shape
// stays stable for AI consumers — an empty []string means "no required
// fields" rather than "schema is missing the field".
type InputSchema struct {
Type string `json:"type"`
Required []string `json:"required"`
Properties *OrderedProps `json:"properties"`
}
// OutputSchema wraps responseBody into a JSON Schema object.
type OutputSchema struct {
Type string `json:"type"`
Properties *OrderedProps `json:"properties"`
}
// Property is one field's JSON Schema shape, recursive.
//
// Required is used when Property describes a nested object (e.g. the
// "params" / "data" sub-objects inside inputSchema): it lists which keys
// inside that object's Properties are mandatory. Leaf fields ignore it.
type Property struct {
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
Enum []interface{} `json:"enum,omitempty"`
Default interface{} `json:"default,omitempty"`
Example interface{} `json:"example,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Format string `json:"format,omitempty"`
Required []string `json:"required,omitempty"`
Properties *OrderedProps `json:"properties,omitempty"`
Items *Property `json:"items,omitempty"`
}
// Meta is the Lark-specific extension namespace.
type Meta struct {
EnvelopeVersion string `json:"envelope_version"`
Scopes []string `json:"scopes"`
RequiredScopes []string `json:"required_scopes"`
AccessTokens []string `json:"access_tokens"`
Danger bool `json:"danger"`
Risk string `json:"risk"`
DocURL string `json:"doc_url,omitempty"`
Affordance *Affordance `json:"affordance,omitempty"`
}
// Affordance is the hand-written overlay (PR-1 only defines the type, no YAML loaded).
type Affordance struct {
UseWhen []string `json:"use_when,omitempty"`
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
Prerequisites []string `json:"prerequisites,omitempty"`
Examples []AffordanceCase `json:"examples,omitempty"`
Related []string `json:"related,omitempty"`
}
// AffordanceCase is one example entry: a one-line description plus a
// ready-to-run lark-cli command string.
type AffordanceCase struct {
Description string `json:"description"`
Command string `json:"command"`
}
// OrderedProps is map[string]Property with preserved key order on MarshalJSON.
// It is used wherever JSON output must reflect meta_data.json's natural field
// order rather than Go's default alphabetical map encoding.
type OrderedProps struct {
Order []string
Map map[string]Property
}
// MarshalJSON emits keys in Order, not alphabetical. If Order is empty but
// Map has entries, fall back to alphabetical key order over Map so callers
// that only populated Map (no explicit ordering) still see their fields.
func (o *OrderedProps) MarshalJSON() ([]byte, error) {
if o == nil || (len(o.Order) == 0 && len(o.Map) == 0) {
return []byte("{}"), nil
}
keys := o.Order
if len(keys) == 0 {
keys = make([]string, 0, len(o.Map))
for k := range o.Map {
keys = append(keys, k)
}
sort.Strings(keys)
}
var buf bytes.Buffer
buf.WriteByte('{')
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
}
keyJSON, err := json.Marshal(k)
if err != nil {
return nil, fmt.Errorf("marshal key %q: %w", k, err)
}
buf.Write(keyJSON)
buf.WriteByte(':')
valJSON, err := json.Marshal(o.Map[k])
if err != nil {
return nil, fmt.Errorf("marshal value for %q: %w", k, err)
}
buf.Write(valJSON)
}
buf.WriteByte('}')
return buf.Bytes(), nil
}
// UnmarshalJSON parses an object preserving key order via json.Decoder.Token().
// Used for round-tripping in tests (and future golden update flows).
func (o *OrderedProps) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
tok, err := dec.Token()
if err != nil {
return err
}
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
return fmt.Errorf("expected object, got %v", tok)
}
o.Order = nil
o.Map = make(map[string]Property)
for dec.More() {
keyTok, err := dec.Token()
if err != nil {
return err
}
key, ok := keyTok.(string)
if !ok {
return fmt.Errorf("expected string key, got %v", keyTok)
}
var prop Property
if err := dec.Decode(&prop); err != nil {
return err
}
o.Order = append(o.Order, key)
o.Map[key] = prop
}
if _, err := dec.Token(); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"encoding/json"
"testing"
)
// OrderedProps 在测试里验证MarshalJSON 按 Order 切片顺序输出 key跳过 Go map 默认字母序。
func TestOrderedProps_MarshalJSON_PreservesOrder(t *testing.T) {
op := &OrderedProps{
Order: []string{"z_first", "a_second", "m_third"},
Map: map[string]Property{
"z_first": {Type: "string"},
"a_second": {Type: "integer"},
"m_third": {Type: "boolean"},
},
}
b, err := json.Marshal(op)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
got := string(b)
want := `{"z_first":{"type":"string"},"a_second":{"type":"integer"},"m_third":{"type":"boolean"}}`
if got != want {
t.Errorf("OrderedProps key order not preserved:\ngot: %s\nwant: %s", got, want)
}
}
func TestOrderedProps_MarshalJSON_Empty(t *testing.T) {
op := &OrderedProps{Order: nil, Map: nil}
b, err := json.Marshal(op)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(b) != "{}" {
t.Errorf("empty OrderedProps should marshal to {}, got: %s", b)
}
}
func TestOrderedProps_UnmarshalJSON_RoundTrip(t *testing.T) {
in := []byte(`{"first":{"type":"string"},"second":{"type":"integer"}}`)
var op OrderedProps
if err := json.Unmarshal(in, &op); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if len(op.Order) != 2 {
t.Fatalf("expected 2 keys, got %d", len(op.Order))
}
if op.Order[0] != "first" || op.Order[1] != "second" {
t.Errorf("unmarshal lost order: got %v", op.Order)
}
if op.Map["first"].Type != "string" {
t.Errorf("first.type mismatch")
}
}

View File

@@ -78,12 +78,12 @@ func (r *NpmResult) CombinedOutput() string {
// Platform-specific methods (PrepareSelfReplace, CleanupStaleFiles)
// are in updater_unix.go and updater_windows.go.
//
// Override DetectOverride / NpmInstallOverride / SkillsUpdateOverride / VerifyOverride
// Override DetectOverride / NpmInstallOverride / SkillsCommandOverride / VerifyOverride
// / RestoreAvailableOverride for testing.
type Updater struct {
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
SkillsUpdateOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error
RestoreAvailableOverride func() bool
@@ -153,12 +153,27 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r
}
// RunSkillsUpdate installs skills, trying the .well-known source first and
// falling back to the GitHub repo on failure or timeout.
func (u *Updater) RunSkillsUpdate() *NpmResult {
if u.SkillsUpdateOverride != nil {
return u.SkillsUpdateOverride()
func (u *Updater) ListOfficialSkills() *NpmResult {
r := u.runSkillsListOfficial("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsListOfficial("larksuite/cli")
}
return r
}
func (u *Updater) ListGlobalSkills() *NpmResult {
return u.runSkillsListGlobal()
}
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
if r.Err != nil {
r = u.runSkillsInstall("larksuite/cli", nameList)
}
return r
}
func (u *Updater) InstallAllSkills() *NpmResult {
r := u.runSkillsAdd("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsAdd("larksuite/cli")
@@ -167,6 +182,28 @@ func (u *Updater) RunSkillsUpdate() *NpmResult {
}
func (u *Updater) runSkillsAdd(source string) *NpmResult {
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
}
func (u *Updater) runSkillsListOfficial(source string) *NpmResult {
return u.runSkillsCommand("-y", "skills", "add", source, "--list")
}
func (u *Updater) runSkillsListGlobal() *NpmResult {
return u.runSkillsCommand("-y", "skills", "ls", "-g")
}
func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult {
args := []string{"-y", "skills", "add", source, "-s"}
args = append(args, nameList...)
args = append(args, "-g", "-y")
return u.runSkillsCommand(args...)
}
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
if u.SkillsCommandOverride != nil {
return u.SkillsCommandOverride(args...)
}
r := &NpmResult{}
npxPath, err := exec.LookPath("npx")
if err != nil {
@@ -175,7 +212,7 @@ func (u *Updater) runSkillsAdd(source string) *NpmResult {
}
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y")
cmd := exec.CommandContext(ctx, npxPath, args...)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()

View File

@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs"
@@ -166,3 +167,87 @@ func TestVerifyBinaryEmptyOutput(t *testing.T) {
t.Fatal("VerifyBinary(empty output) expected error, got nil")
}
}
func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
tests := []struct {
name string
run func(*Updater) *NpmResult
want string
}{
{
name: "list official primary",
run: func(u *Updater) *NpmResult {
return u.runSkillsListOfficial("https://open.feishu.cn")
},
want: "-y skills add https://open.feishu.cn --list",
},
{
name: "list global",
run: func(u *Updater) *NpmResult {
return u.runSkillsListGlobal()
},
want: "-y skills ls -g",
},
{
name: "install skill primary",
run: func(u *Updater) *NpmResult {
return u.runSkillsInstall("https://open.feishu.cn", []string{"lark-mail"})
},
want: "-y skills add https://open.feishu.cn -s lark-mail -g -y",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell script")
}
dir := t.TempDir()
script := filepath.Join(dir, "npx")
logPath := filepath.Join(dir, "npx.log")
if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \""+logPath+"\"\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
result := tt.run(New())
if result.Err != nil {
t.Fatalf("command err = %v, want nil", result.Err)
}
raw, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(string(raw)) != tt.want {
t.Fatalf("args = %q, want %q", strings.TrimSpace(string(raw)), tt.want)
}
})
}
}
func TestListOfficialSkillsFallsBack(t *testing.T) {
called := []string{}
updater := &Updater{
SkillsCommandOverride: func(args ...string) *NpmResult {
called = append(called, strings.Join(args, " "))
r := &NpmResult{}
if strings.Contains(strings.Join(args, " "), "https://open.feishu.cn") {
r.Err = fmt.Errorf("primary failed")
return r
}
r.Stdout.WriteString("lark-calendar\n")
return r
},
}
result := updater.ListOfficialSkills()
if result.Err != nil {
t.Fatalf("ListOfficialSkills() err = %v, want nil", result.Err)
}
if len(called) != 2 {
t.Fatalf("called %d commands, want 2: %#v", len(called), called)
}
if !strings.Contains(called[1], "larksuite/cli --list") {
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
}
}

View File

@@ -3,46 +3,29 @@
package skillscheck
// Init runs the synchronous skills version check. Stores a StaleNotice
// when the local stamp records a version that does not match
// currentVersion. Safe to call from cmd/root.go before rootCmd.Execute();
// zero network, zero subprocess — only a local stamp file read.
import "strings"
// Init runs the synchronous skills version check. Stores a StaleNotice when
// the local skills state records a version that does not match currentVersion.
// Safe to call from cmd/root.go before rootCmd.Execute(); zero network, zero
// subprocess — only a local state file read.
//
// Skip rules: see shouldSkip (CI envs, DEV builds, non-release semver,
// LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out).
//
// Failure modes (all → no notice, no nag):
// - shouldSkip rule met
// - ReadStamp returns an I/O error other than ENOENT
// - Stamp matches currentVersion (in-sync)
// - Stamp is missing (cold start) — only users who ran `lark-cli update`
// opt into drift tracking; npx-only installs are intentionally silent.
func Init(currentVersion string) {
// Clear any stale notice from a prior call so early returns below
// (skip rules / read errors / cold start / in-sync) leave pending == nil
// instead of preserving a stale value from a previous Init invocation.
SetPending(nil)
if shouldSkip(currentVersion) {
return
}
stamp, err := ReadStamp()
if err != nil {
// Fail closed — don't nag for a transient FS problem.
version, ok := ReadSyncedVersion()
if !ok {
return
}
if stamp == "" {
// Cold start: the stamp is written exclusively by `lark-cli update`
// (runSkillsAndStamp). Users who installed skills via
// `npx skills add larksuite/cli -g` have no stamp yet — they must
// not be nagged with "skills not installed", since the on-disk
// skills directory may already be fully populated.
return
}
if stamp == currentVersion {
if strings.TrimPrefix(strings.TrimPrefix(version, "v"), "V") == strings.TrimPrefix(strings.TrimPrefix(currentVersion, "v"), "V") {
return
}
SetPending(&StaleNotice{
Current: stamp, // guaranteed non-empty under the new contract
Current: version,
Target: currentVersion,
})
}

View File

@@ -18,9 +18,8 @@ func resetPending(t *testing.T) {
func TestInit_InSync_NoNotice(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.21"); err != nil {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
Init("1.0.21")
@@ -39,12 +38,24 @@ func TestInit_ColdStart_NoNotice(t *testing.T) {
}
}
func TestInit_Drift_NoticeWithStampVersion(t *testing.T) {
func TestInit_NormalizedVersion_NoNotice(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.20"); err != nil {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
Init("v1.0.21")
if got := GetPending(); got != nil {
t.Errorf("GetPending() = %+v, want nil (normalized versions are in-sync)", got)
}
}
func TestInit_Drift_NoticeWithStateVersion(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := WriteState(SkillsState{Version: "1.0.20"}); err != nil {
t.Fatal(err)
}
Init("1.0.21")
@@ -61,22 +72,18 @@ func TestInit_Skipped_NoNotice(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
// Even with an empty config dir (no stamp), DEV version should skip
// the check entirely and never emit a notice.
Init("DEV")
if got := GetPending(); got != nil {
t.Errorf("GetPending() = %+v, want nil (skip rules met)", got)
}
}
func TestInit_ReadStampError_FailsClosed(t *testing.T) {
func TestInit_ReadStateError_FailsClosed(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Make the stamp path a directory so vfs.ReadFile returns a
// non-ENOENT I/O error.
if err := os.MkdirAll(filepath.Join(dir, "skills.stamp"), 0o755); err != nil {
if err := os.MkdirAll(filepath.Join(dir, "skills-state.json"), 0o755); err != nil {
t.Fatal(err)
}
Init("1.0.21")

View File

@@ -3,9 +3,8 @@
// Package skillscheck verifies that the locally installed lark-cli
// skills are in sync with the running binary version, by comparing
// the current binary version against a stamp file written when skills
// are last synced (by `lark-cli update`). On mismatch it stores a
// notice for injection into JSON envelopes via output.PendingNotice.
// the current binary version against skills-state.json. On mismatch it
// stores a notice for injection into JSON envelopes via output.PendingNotice.
package skillscheck
import (
@@ -26,8 +25,7 @@ type StaleNotice struct {
// Message returns a single-line, AI-agent-parseable description of the
// drift plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message
// in style ("..., run: lark-cli update" suffix). Current is guaranteed
// non-empty because Init only emits a StaleNotice for the drift case
// (stamp present and != binary version).
// non-empty because Init only emits a StaleNotice for the drift case.
func (s *StaleNotice) Message() string {
return fmt.Sprintf(
"lark-cli skills %s out of sync with binary %s, run: lark-cli update",

View File

@@ -1,49 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"errors"
"io/fs"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
const stampFile = "skills.stamp"
// stampPath returns ~/.lark-cli/skills.stamp.
// Uses the BASE config dir (not workspace-aware) because skills install
// globally via `npx -g`; per-workspace tracking would produce false
// drift signals when switching workspaces.
func stampPath() string {
return filepath.Join(core.GetBaseConfigDir(), stampFile)
}
// ReadStamp returns the version recorded in the stamp file. Returns
// ("", nil) when the file does not exist (interpreted as "never synced").
// Other I/O errors are returned as-is so callers can fail closed.
func ReadStamp() (string, error) {
data, err := vfs.ReadFile(stampPath())
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return "", nil
}
return "", err
}
return strings.TrimSpace(string(data)), nil
}
// WriteStamp records `version` as the last successfully synced skills
// version. Atomic via tmp + rename (validate.AtomicWrite). Creates
// the base config directory if it does not exist.
func WriteStamp(version string) error {
if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil {
return err
}
return validate.AtomicWrite(stampPath(), []byte(version), 0o644)
}

View File

@@ -1,113 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"os"
"path/filepath"
"testing"
)
func TestReadStamp_Missing(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got, err := ReadStamp()
if err != nil {
t.Fatalf("ReadStamp() err = %v, want nil for ENOENT", err)
}
if got != "" {
t.Errorf("ReadStamp() = %q, want \"\" for missing file", got)
}
}
func TestReadStamp_Normal(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadStamp()
if err != nil || got != "1.0.21" {
t.Errorf("ReadStamp() = (%q, %v), want (\"1.0.21\", nil)", got, err)
}
}
func TestReadStamp_TrailingNewlineTolerated(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21\n"), 0o644); err != nil {
t.Fatal(err)
}
got, _ := ReadStamp()
if got != "1.0.21" {
t.Errorf("ReadStamp() = %q, want \"1.0.21\" (newline trimmed)", got)
}
}
func TestReadStamp_EmptyFile(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte(""), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadStamp()
if err != nil || got != "" {
t.Errorf("ReadStamp() = (%q, %v), want (\"\", nil)", got, err)
}
}
func TestWriteStamp_CreatesDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.21"); err != nil {
t.Fatalf("WriteStamp() = %v, want nil", err)
}
got, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
if string(got) != "1.0.21" {
t.Errorf("file content = %q, want \"1.0.21\"", string(got))
}
}
func TestWriteStamp_OverwritesExisting(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}
if err := WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}
got, _ := ReadStamp()
if got != "1.0.21" {
t.Errorf("ReadStamp() after overwrite = %q, want \"1.0.21\"", got)
}
}
func TestWriteStamp_NoTrailingNewline(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
if string(raw) != "1.0.21" {
t.Errorf("raw file = %q, want exactly \"1.0.21\" (no newline)", string(raw))
}
}
// TestWriteStamp_MkdirAllFailure verifies WriteStamp returns the mkdir error
// when the base config dir cannot be created (parent path is a regular file).
func TestWriteStamp_MkdirAllFailure(t *testing.T) {
tmp := t.TempDir()
blocker := filepath.Join(tmp, "blocker")
// Create a regular file where MkdirAll wants to create a directory.
if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil {
t.Fatal(err)
}
// Point the config dir at a path UNDER the regular file — MkdirAll must fail.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(blocker, "child"))
if err := WriteStamp("1.0.21"); err == nil {
t.Fatal("WriteStamp() = nil, want non-nil error from MkdirAll failure")
}
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"path/filepath"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
const (
stateFile = "skills-state.json"
)
var ErrUnreadableState = errors.New("skills state is unreadable")
type SkillsState struct {
Version string `json:"version"`
OfficialSkills []string `json:"official_skills"`
UpdatedSkills []string `json:"updated_skills"`
AddedOfficialSkills []string `json:"added_official_skills"`
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
UpdatedAt string `json:"updated_at"`
}
func statePath() string {
return filepath.Join(core.GetBaseConfigDir(), stateFile)
}
func ReadState() (*SkillsState, bool, error) {
data, err := vfs.ReadFile(statePath())
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, false, nil
}
return nil, false, err
}
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, false, fmt.Errorf("%w: %v", ErrUnreadableState, err)
}
var state SkillsState
if err := json.Unmarshal(data, &state); err != nil {
return nil, false, fmt.Errorf("%w: %v", ErrUnreadableState, err)
}
return &state, true, nil
}
func WriteState(state SkillsState) error {
state.ensureNonNilSlices()
if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return validate.AtomicWrite(statePath(), append(data, '\n'), 0o644)
}
func ReadSyncedVersion() (string, bool) {
state, ok, err := ReadState()
if err != nil || !ok || state.Version == "" {
return "", false
}
return state.Version, true
}
func (s *SkillsState) ensureNonNilSlices() {
if s.OfficialSkills == nil {
s.OfficialSkills = []string{}
}
if s.UpdatedSkills == nil {
s.UpdatedSkills = []string{}
}
if s.AddedOfficialSkills == nil {
s.AddedOfficialSkills = []string{}
}
if s.SkippedDeletedSkills == nil {
s.SkippedDeletedSkills = []string{}
}
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
)
func TestReadState_Missing(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
state, ok, err := ReadState()
if err != nil {
t.Fatalf("ReadState() err = %v, want nil for missing file", err)
}
if ok {
t.Fatal("ReadState() ok = true, want false for missing file")
}
if state != nil {
t.Fatalf("ReadState() state = %#v, want nil for missing file", state)
}
}
func TestReadState_Valid(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
want := SkillsState{
Version: "1.2.3",
OfficialSkills: []string{"lark-doc", "lark-im"},
UpdatedSkills: []string{"lark-doc"},
AddedOfficialSkills: []string{"lark-task"},
SkippedDeletedSkills: []string{"custom-skill"},
UpdatedAt: "2026-05-18T10:00:00Z",
}
data, err := json.Marshal(want)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, stateFile), data, 0o644); err != nil {
t.Fatal(err)
}
got, ok, err := ReadState()
if err != nil {
t.Fatalf("ReadState() err = %v, want nil", err)
}
if !ok {
t.Fatal("ReadState() ok = false, want true")
}
if got == nil {
t.Fatal("ReadState() state = nil, want state")
}
if !reflect.DeepEqual(*got, want) {
t.Fatalf("ReadState() state = %#v, want %#v", *got, want)
}
}
func TestReadState_CorruptStateUnreadable(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, stateFile), []byte(`{"version":`), 0o644); err != nil {
t.Fatal(err)
}
state, ok, err := ReadState()
if !errors.Is(err, ErrUnreadableState) {
t.Fatalf("ReadState() err = %v, want ErrUnreadableState", err)
}
if ok {
t.Fatal("ReadState() ok = true, want false")
}
if state != nil {
t.Fatalf("ReadState() state = %#v, want nil", state)
}
}
func TestWriteState_CreatesDirAndWritesState(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
state := SkillsState{
Version: "1.2.3",
UpdatedAt: "2026-05-18T10:00:00Z",
}
if err := WriteState(state); err != nil {
t.Fatalf("WriteState() err = %v, want nil", err)
}
raw, err := os.ReadFile(filepath.Join(dir, stateFile))
if err != nil {
t.Fatal(err)
}
var got SkillsState
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("written state is invalid JSON: %v", err)
}
if got.Version != state.Version {
t.Fatalf("version = %q, want %q", got.Version, state.Version)
}
if got.OfficialSkills == nil {
t.Fatal("official_skills decoded as nil, want empty slice")
}
if got.UpdatedSkills == nil {
t.Fatal("updated_skills decoded as nil, want empty slice")
}
if got.AddedOfficialSkills == nil {
t.Fatal("added_skills decoded as nil, want empty slice")
}
if got.SkippedDeletedSkills == nil {
t.Fatal("skipped_deleted_skills decoded as nil, want empty slice")
}
}
func TestReadSyncedVersionFromState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if got, ok := ReadSyncedVersion(); ok || got != "" {
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for missing state", got, ok)
}
if err := WriteState(SkillsState{Version: "1.2.3"}); err != nil {
t.Fatal(err)
}
if got, ok := ReadSyncedVersion(); !ok || got != "1.2.3" {
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"1.2.3\", true)", got, ok)
}
if err := WriteState(SkillsState{}); err != nil {
t.Fatal(err)
}
if got, ok := ReadSyncedVersion(); ok || got != "" {
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for empty version", got, ok)
}
}

View File

@@ -0,0 +1,399 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"fmt"
"regexp"
"sort"
"strings"
"time"
"github.com/larksuite/cli/internal/selfupdate"
)
var (
skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`)
ansiPattern = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
)
type SyncInput struct {
Version string
OfficialSkills []string
LocalSkills []string
PreviousState *SkillsState
StateReadable bool
Force bool
}
type SyncPlan struct {
Version string
OfficialSkills []string
ToUpdate []string
Added []string
SkippedDeleted []string
}
func stripANSI(s string) string {
return ansiPattern.ReplaceAllString(s, "")
}
func ParseSkillsList(text string) []string {
text = stripANSI(text)
lines := strings.Split(text, "\n")
// Detect format type
hasGlobalSkills := strings.Contains(text, "Global Skills")
hasAvailableSkills := strings.Contains(text, "Available Skills")
if hasGlobalSkills {
// Format 1: locally installed skills list from "npx -y skills ls -g"
return parseGlobalSkillsList(lines)
} else if hasAvailableSkills {
// Format 2: official skills list from "npx -y skills add ... --list"
return parseOfficialSkillsList(lines)
}
return nil
}
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
func parseGlobalSkillsList(lines []string) []string {
seen := map[string]bool{}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Skip header
if strings.HasPrefix(trimmed, "Global Skills") {
continue
}
// Skip empty lines
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "Tip:") {
continue
}
// Skip indented lines (Agents: ...)
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
continue
}
// Extract skill name, format is typically "skill-name /path/to/skill"
parts := strings.Fields(trimmed)
if len(parts) == 0 {
continue
}
candidate := parts[0]
// Validate and add
if candidate == "" || strings.Contains(candidate, " ") || strings.HasSuffix(candidate, ":") {
continue
}
if !skillNamePattern.MatchString(candidate) {
continue
}
if at := strings.Index(candidate, "@"); at > 0 {
candidate = candidate[:at]
}
seen[candidate] = true
}
return sortedKeys(seen)
}
// parseOfficialSkillsList parses the output of "npx -y skills add ... --list"
func parseOfficialSkillsList(lines []string) []string {
seen := map[string]bool{}
inAvailableSection := false
for _, line := range lines {
// Check if we've reached the "Available Skills" section
if strings.Contains(line, "Available Skills") {
inAvailableSection = true
continue
}
if !inAvailableSection {
continue
}
// Process lines containing "│", e.g. " │ lark-approval "
if strings.Contains(line, "│") {
// Remove all "│" characters and spaces, extract the first valid token in order
parts := strings.FieldsFunc(line, func(r rune) bool {
return r == '│' || r == ' '
})
if len(parts) > 0 {
candidate := parts[0]
// Check if it's a valid official skill name
if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) {
seen[candidate] = true
}
}
}
}
return sortedKeys(seen)
}
func PlanSync(input SyncInput) SyncPlan {
official := uniqueSorted(input.OfficialSkills)
if input.Force {
return SyncPlan{
Version: input.Version,
OfficialSkills: official,
ToUpdate: official,
Added: []string{},
SkippedDeleted: []string{},
}
}
officialSet := toSet(official)
installedOfficial := intersection(input.LocalSkills, officialSet)
previousOfficial := []string{}
if input.StateReadable && input.PreviousState != nil {
previousOfficial = input.PreviousState.OfficialSkills
}
previousSet := toSet(previousOfficial)
newAddedOfficial := []string{}
for _, skill := range official {
if !previousSet[skill] {
newAddedOfficial = append(newAddedOfficial, skill)
}
}
updateSet := toSet(installedOfficial)
for _, skill := range newAddedOfficial {
updateSet[skill] = true
}
toUpdate := sortedKeys(updateSet)
updateSet = toSet(toUpdate)
skipped := []string{}
for _, skill := range official {
if !updateSet[skill] {
skipped = append(skipped, skill)
}
}
return SyncPlan{
Version: input.Version,
OfficialSkills: official,
ToUpdate: toUpdate,
Added: uniqueSorted(newAddedOfficial),
SkippedDeleted: skipped,
}
}
type SkillsRunner interface {
ListOfficialSkills() *selfupdate.NpmResult
ListGlobalSkills() *selfupdate.NpmResult
InstallSkill(nameList []string) *selfupdate.NpmResult
InstallAllSkills() *selfupdate.NpmResult
}
type SyncOptions struct {
Version string
Force bool
Runner SkillsRunner
Now func() time.Time
}
type SyncResult struct {
Action string
Official []string
Updated []string
Added []string
SkippedDeleted []string
Failed []string
Err error
Detail string
Force bool
}
func SyncSkills(opts SyncOptions) *SyncResult {
if opts.Now == nil {
opts.Now = time.Now
}
if opts.Runner == nil {
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
}
// --- Step 1: List official skills ---
officialResult := opts.Runner.ListOfficialSkills()
if officialResult == nil || officialResult.Err != nil {
return fallbackFullInstall(opts, resultDetail(officialResult), nil)
}
official := ParseSkillsList(officialResult.Stdout.String())
if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" {
return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil)
}
// --- Step 2: List local (installed) skills ---
local := []string{}
localResult := opts.Runner.ListGlobalSkills()
if localResult != nil && localResult.Err == nil {
local = ParseSkillsList(localResult.Stdout.String())
}
// --- Step 3: Read previous state ---
previous, readable, err := ReadState()
if err != nil {
readable = false
previous = nil
}
plan := PlanSync(SyncInput{
Version: opts.Version,
OfficialSkills: official,
LocalSkills: local,
PreviousState: previous,
StateReadable: readable,
Force: opts.Force,
})
result := &SyncResult{
Action: "synced",
Official: plan.OfficialSkills,
Updated: plan.ToUpdate,
Added: plan.Added,
SkippedDeleted: plan.SkippedDeleted,
Force: opts.Force,
}
if len(plan.ToUpdate) > 0 {
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
if installResult == nil || installResult.Err != nil {
return fallbackFullInstall(opts, resultDetail(installResult), official)
}
}
state := SkillsState{
Version: opts.Version,
OfficialSkills: plan.OfficialSkills,
UpdatedSkills: plan.ToUpdate,
AddedOfficialSkills: plan.Added,
SkippedDeletedSkills: plan.SkippedDeleted,
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
}
if err := WriteState(state); err != nil {
result.Action = "failed"
result.Err = fmt.Errorf("skills synced but state not written: %w", err)
return result
}
return result
}
// fallbackFullInstall performs a full skills install (npx -y skills add <source> -g -y)
// when incremental sync is not possible. On success it writes a state file so that
// subsequent syncs can use incremental mode. When official is non-nil the state
// records the full official list; otherwise a minimal state (version only) is
// written to break the fallback loop.
func fallbackFullInstall(opts SyncOptions, reason string, official []string) *SyncResult {
installResult := opts.Runner.InstallAllSkills()
if installResult == nil {
return &SyncResult{
Action: "fallback_failed",
Err: fmt.Errorf("full skills install failed: empty result (reason: %s)", reason),
Detail: reason,
Force: opts.Force,
}
}
if installResult.Err != nil {
return &SyncResult{
Action: "fallback_failed",
Err: fmt.Errorf("full skills install failed: %w (reason: %s)", installResult.Err, reason),
Detail: reason + "\n" + resultDetail(installResult),
Force: opts.Force,
}
}
state := SkillsState{
Version: opts.Version,
OfficialSkills: official,
UpdatedSkills: official,
AddedOfficialSkills: official,
SkippedDeletedSkills: []string{},
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
}
if writeErr := WriteState(state); writeErr != nil {
return &SyncResult{
Action: "fallback_synced",
Official: official,
Updated: official,
Added: official,
SkippedDeleted: []string{},
Detail: reason + "\nstate write failed: " + writeErr.Error(),
Force: opts.Force,
}
}
return &SyncResult{
Action: "fallback_synced",
Official: official,
Updated: official,
Added: official,
SkippedDeleted: []string{},
Detail: reason,
Force: opts.Force,
}
}
func resultDetail(result *selfupdate.NpmResult) string {
if result == nil {
return ""
}
parts := []string{}
if output := strings.TrimSpace(result.CombinedOutput()); output != "" {
parts = append(parts, output)
}
if result.Err != nil {
parts = append(parts, result.Err.Error())
}
return strings.Join(parts, "\n")
}
func uniqueSorted(values []string) []string {
return sortedKeys(toSet(values))
}
func toSet(values []string) map[string]bool {
out := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
out[value] = true
}
}
return out
}
// result = { x | x ∈ values ∧ x ∈ allowed }
func intersection(values []string, allowed map[string]bool) []string {
out := map[string]bool{}
for _, value := range values {
if allowed[value] {
out[value] = true
}
}
return sortedKeys(out)
}
func sortedKeys(values map[string]bool) []string {
out := make([]string, 0, len(values))
for value := range values {
out = append(out, value)
}
sort.Strings(out)
return out
}

View File

@@ -0,0 +1,517 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/selfupdate"
)
func TestParseSkillsListIgnoresUnsupportedFormat(t *testing.T) {
input := `Installed skills:
- lark-calendar
- lark-mail
lark-im
custom-skill
lark-base@1.0.0
lark-cli-harness:dev@0.1.0
`
got := ParseSkillsList(input)
if len(got) != 0 {
t.Fatalf("ParseSkillsList() = %#v, want empty result for unsupported format", got)
}
}
func TestParseGlobalSkillsList(t *testing.T) {
input := `Global Skills
lark-approval ~/.agents/skills/lark-approval
Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more
lark-attendance ~/.agents/skills/lark-attendance
Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more
lark-base ~/.agents/skills/lark-base
Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more
lark-calendar ~/.agents/skills/lark-calendar
Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more
dogfood ~/.hermes/skills/dogfood
Agents: Hermes Agent
yuanbao ~/.hermes/skills/yuanbao
Agents: Hermes Agent
`
got := ParseSkillsList(input)
want := []string{"dogfood", "lark-approval", "lark-attendance", "lark-base", "lark-calendar", "yuanbao"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() (Global Skills) = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsListWithANSI(t *testing.T) {
input := "\x1b[1mGlobal Skills\x1b[0m\n\n" +
"\x1b[36mlark-calendar\x1b[0m \x1b[38;5;102m~/.agents/skills/lark-calendar\x1b[0m\n" +
" \x1b[38;5;102mAgents:\x1b[0m TRAE CN, TRAE +3 more\n" +
"\x1b[36mdogfood\x1b[0m \x1b[38;5;102m~/.hermes/skills/dogfood\x1b[0m\n" +
" \x1b[38;5;102mAgents:\x1b[0m Hermes Agent\n" +
"\nTip: Use the -y flag to run in non-interactive mode (for CI and AI agents).\n"
got := ParseSkillsList(input)
want := []string{"dogfood", "lark-calendar"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() (ANSI Global Skills) = %#v, want %#v", got, want)
}
}
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
got := PlanSync(SyncInput{
Version: "1.0.33",
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
LocalSkills: []string{"lark-calendar", "lark-custom"},
PreviousState: previous,
StateReadable: true,
Force: false,
})
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-new"})
assertStrings(t, got.Added, []string{"lark-new"})
assertStrings(t, got.SkippedDeleted, []string{"lark-mail"})
}
func TestPlanNormal_MissingStateInstallsAllOfficial(t *testing.T) {
got := PlanSync(SyncInput{
Version: "1.0.33",
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
LocalSkills: []string{"lark-calendar"},
StateReadable: false,
Force: false,
})
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, got.Added, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, got.SkippedDeleted, []string{})
}
func TestPlanForceRestoresAllOfficial(t *testing.T) {
got := PlanSync(SyncInput{
Version: "1.0.33",
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
LocalSkills: []string{"lark-calendar"},
PreviousState: &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}},
StateReadable: true,
Force: true,
})
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, got.Added, []string{})
assertStrings(t, got.SkippedDeleted, []string{})
}
type fakeSkillsRunner struct {
officialOut string
globalOut string
officialErr error
globalErr error
installErr error
installAllErr error
installed [][]string
installedAll int
}
func officialSkillsOutput(names ...string) string {
var b strings.Builder
b.WriteString("Available Skills\n")
for _, name := range names {
b.WriteString("│ ")
b.WriteString(name)
b.WriteString("\n")
}
return b.String()
}
func globalSkillsOutput(names ...string) string {
var b strings.Builder
b.WriteString("Global Skills\n\n")
for _, name := range names {
b.WriteString(name)
b.WriteString(" ~/.agents/skills/")
b.WriteString(name)
b.WriteString("\n Agents: Claude Code\n")
}
return b.String()
}
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialOut)
r.Err = f.officialErr
return r
}
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalOut)
r.Err = f.globalErr
return r
}
func (f *fakeSkillsRunner) InstallSkill(nameList []string) *selfupdate.NpmResult {
f.installed = append(f.installed, nameList)
r := &selfupdate.NpmResult{}
r.Err = f.installErr
return r
}
func (f *fakeSkillsRunner) InstallAllSkills() *selfupdate.NpmResult {
f.installedAll++
r := &selfupdate.NpmResult{}
r.Err = f.installAllErr
return r
}
func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteState(SkillsState{
Version: "1.0.30",
OfficialSkills: []string{"lark-calendar", "lark-mail"},
UpdatedAt: "2026-05-18T00:00:00Z",
}); err != nil {
t.Fatal(err)
}
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalOut: globalSkillsOutput("lark-calendar", "lark-custom"),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Runner: runner,
Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) },
})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"})
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-new"})
assertStrings(t, state.AddedOfficialSkills, []string{"lark-new"})
assertStrings(t, state.SkippedDeletedSkills, []string{"lark-mail"})
if _, err := os.Stat(filepath.Join(dir, "skills.stamp")); !os.IsNotExist(err) {
t.Fatalf("skills.stamp exists or stat failed with unexpected err: %v", err)
}
}
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.33" {
t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33")
}
assertStrings(t, state.OfficialSkills, []string{})
}
func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: fmt.Errorf("full install failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_failed" {
t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action)
}
if result.Err == nil {
t.Fatalf("SyncSkills() err = nil, want error")
}
if !strings.Contains(result.Err.Error(), "full skills install failed") {
t.Fatalf("SyncSkills() err = %v, want full install failure", result.Err)
}
}
func TestSyncSkills_GlobalListFailureDegradesToColdStart(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalErr: fmt.Errorf("global list failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
}
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
}
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
}
func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: "Some unrecognized output format\n",
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
}
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
}
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll)
}
if len(runner.installed) != 1 {
t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed))
}
}
func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if len(runner.installed) != 1 {
t.Fatalf("installed = %d calls, want 1", len(runner.installed))
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1 (fallback triggered)", runner.installedAll)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.33" {
t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33")
}
assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail"})
}
func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_failed" {
t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action)
}
if result.Err == nil {
t.Fatalf("SyncSkills() err = nil, want error")
}
if !strings.Contains(result.Detail, "incremental boom") {
t.Fatalf("SyncSkills() detail = %q, want incremental error text", result.Detail)
}
if !strings.Contains(result.Err.Error(), "full skills install failed") {
t.Fatalf("SyncSkills() err = %v, want full install failure", result.Err)
}
}
func TestSyncSkills_NilRunnerFails(t *testing.T) {
result := SyncSkills(SyncOptions{Version: "1.0.33", Now: time.Now})
if result.Err == nil || !strings.Contains(result.Err.Error(), "skills runner is nil") {
t.Fatalf("SyncSkills() err = %v, want nil runner failure", result.Err)
}
}
func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
}
func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: fmt.Errorf("full install failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_failed" {
t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action)
}
if result.Err == nil {
t.Fatalf("SyncSkills() err = nil, want error")
}
}
func assertStrings(t *testing.T, got, want []string) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}
func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.33" {
t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33")
}
assertStrings(t, state.OfficialSkills, []string{})
assertStrings(t, state.UpdatedSkills, []string{})
assertStrings(t, state.AddedOfficialSkills, []string{})
}
func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail"})
assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-mail"})
assertStrings(t, state.AddedOfficialSkills, []string{"lark-calendar", "lark-mail"})
}
func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.Added, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
if !strings.Contains(result.Detail, "incremental boom") {
t.Fatalf("SyncSkills() detail = %q, want incremental error text", result.Detail)
}
}
func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result1.Action != "fallback_synced" {
t.Fatalf("first sync: action = %q, want fallback_synced", result1.Action)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() after first sync = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.33" {
t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33")
}
runner2 := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
}
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
if result2.Action != "synced" {
t.Fatalf("second sync: action = %q, want synced (no fallback loop)", result2.Action)
}
if runner2.installedAll != 0 {
t.Fatalf("second sync: installedAll = %d, want 0 (incremental, not fallback)", runner2.installedAll)
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.40",
"version": "1.0.43",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -21,7 +21,7 @@ var AppsAccessScopeGet = common.Shortcut{
Command: "+access-scope-get",
Description: "Get Miaoda app access scope configuration",
Risk: "read",
Scopes: []string{"spark:app.access_scope:read"},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{

View File

@@ -27,7 +27,7 @@ var AppsAccessScopeSet = common.Shortcut{
Command: "+access-scope-set",
Description: "Set Miaoda app access scope (specific / public / tenant)",
Risk: "write",
Scopes: []string{"spark:app.access_scope:write"},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{

View File

@@ -21,7 +21,7 @@ var AppsHTMLPublish = common.Shortcut{
Command: "+html-publish",
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
Risk: "write",
Scopes: []string{"spark:app:publish"},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{

View File

@@ -11,6 +11,7 @@ import (
"image"
"image/color"
"image/png"
"net/http"
"net/url"
"os"
"path/filepath"
@@ -2200,7 +2201,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("download reports progress when later attachment fails", func(t *testing.T) {
t.Run("download reports progress and log_id when later attachment fails", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -2228,8 +2229,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_b/download",
Status: 500,
Status: 403,
RawBody: []byte("server error"),
Headers: http.Header{"X-Tt-Logid": []string{"202605270001"}},
})
tmpDir := t.TempDir()
@@ -2258,6 +2260,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
}
if detail["log_id"] != "202605270001" {
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
}
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
t.Fatalf("expected first file to remain: %v", err)
}

View File

@@ -787,7 +787,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
QueryParams: query,
})
if err != nil {
return nil, output.ErrNetwork("download failed: %v", err)
return nil, err
}
defer resp.Body.Close()
@@ -835,6 +835,13 @@ func attachmentDownloadProgressError(err error, downloaded []map[string]interfac
msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err)
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
detail := map[string]interface{}{
"downloaded": downloaded,
"failed": failed,
}
if logID := baseAttachmentDownloadLogID(err); logID != "" {
detail["log_id"] = logID
}
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
@@ -842,10 +849,7 @@ func attachmentDownloadProgressError(err error, downloaded []map[string]interfac
Code: exitErr.Detail.Code,
Message: msg,
Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.",
Detail: map[string]interface{}{
"downloaded": downloaded,
"failed": failed,
},
Detail: detail,
},
Err: err,
}
@@ -865,6 +869,19 @@ func attachmentDownloadProgressError(err error, downloaded []map[string]interfac
}
}
func baseAttachmentDownloadLogID(err error) string {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return ""
}
detail, ok := exitErr.Detail.Detail.(map[string]interface{})
if !ok {
return ""
}
logID, _ := detail["log_id"].(string)
return strings.TrimSpace(logID)
}
func outputPathLooksDirectory(runtime *common.RuntimeContext, outputPath string) bool {
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, string(filepath.Separator)) {
return true

View File

@@ -10,6 +10,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -170,13 +171,34 @@ func ParseDriveMediaUploadResponse(apiResp *larkcore.ApiResp, action string) (ma
if larkCode := int(GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), result["error"])
return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), driveMediaUploadErrorDetail(apiResp, result["error"]))
}
data, _ := result["data"].(map[string]interface{})
return data, nil
}
func driveMediaUploadErrorDetail(apiResp *larkcore.ApiResp, detail interface{}) interface{} {
logID := ""
if apiResp != nil {
logID = strings.TrimSpace(apiResp.LogId())
}
if logID == "" {
return detail
}
detailMap, ok := detail.(map[string]interface{})
if !ok {
if detail == nil {
return map[string]interface{}{"log_id": logID}
}
return map[string]interface{}{"error": detail, "log_id": logID}
}
if _, exists := detailMap["log_id"]; !exists {
detailMap["log_id"] = logID
}
return detailMap
}
func ExtractDriveMediaUploadFileToken(data map[string]interface{}, action string) (string, error) {
fileToken := GetString(data, "file_token")
if fileToken == "" {

View File

@@ -7,10 +7,12 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"os"
"strings"
"sync/atomic"
@@ -21,6 +23,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
var commonDriveMediaUploadTestSeq atomic.Int64
@@ -459,6 +462,24 @@ func TestParseDriveMediaUploadResponseErrors(t *testing.T) {
t.Fatalf("expected API error, got %v", err)
}
})
t.Run("api code error includes log_id", func(t *testing.T) {
t.Parallel()
resp := &larkcore.ApiResp{
RawBody: []byte(`{"code":999,"msg":"boom","error":{"detail":"x"}}`),
Header: http.Header{"X-Tt-Logid": []string{"202605270002"}},
}
_, err := ParseDriveMediaUploadResponse(resp, "upload media failed")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured error, got %T %v", err, err)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["log_id"] != "202605270002" {
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
}
})
}
func TestExtractDriveMediaUploadFileTokenRequiresToken(t *testing.T) {

View File

@@ -73,6 +73,9 @@ var urlPathToType = []struct {
Type string
}{
{"/drive/folder/", "folder"},
{"/drive/file/", "file"},
{"/drive/shr/", "folder"},
{"/chat/drive/", "folder"},
{"/docx/", "docx"},
{"/doc/", "doc"},
{"/sheets/", "sheet"},

View File

@@ -28,6 +28,9 @@ func TestParseResourceURL(t *testing.T) {
{"wiki", "https://xxx.feishu.cn/wiki/wikcnABC", "wiki", "wikcnABC", true},
{"file", "https://xxx.feishu.cn/file/boxcnABC", "file", "boxcnABC", true},
{"folder", "https://xxx.feishu.cn/drive/folder/fldcnABC", "folder", "fldcnABC", true},
{"file via /drive/file/", "https://feishu.doubao.com/drive/file/boxcnABC", "file", "boxcnABC", true},
{"folder via /chat/drive/", "https://feishu.doubao.com/chat/drive/fldcnABC", "folder", "fldcnABC", true},
{"folder via /drive/shr/", "https://feishu.doubao.com/drive/shr/fldcnABC", "folder", "fldcnABC", true},
{"mindnote", "https://xxx.feishu.cn/mindnote/mncnABC", "mindnote", "mncnABC", true},
{"slides", "https://xxx.feishu.cn/slides/slkcnABC", "slides", "slkcnABC", true},

View File

@@ -25,6 +25,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -72,6 +73,13 @@ func (ctx *RuntimeContext) IsBot() bool {
// UserOpenId returns the current user's open_id from config.
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
// Lang returns the user's preference as a canonical locale, or "" if unset or
// unrecognized; callers choose their own fallback.
func (ctx *RuntimeContext) Lang() i18n.Lang {
lang, _ := i18n.Parse(string(ctx.Config.Lang))
return lang
}
// BotInfo holds bot identity metadata fetched lazily from /bot/v3/info.
type BotInfo struct {
OpenID string

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
)
func TestRuntimeContext_Lang(t *testing.T) {
tests := []struct {
name string
stored i18n.Lang
want i18n.Lang
}{
{"canonical locale", i18n.LangJaJP, i18n.LangJaJP},
{"legacy short value normalizes", "ja", i18n.LangJaJP},
{"legacy short zh normalizes", "zh", i18n.LangZhCN},
{"unset stays empty", "", ""},
{"unrecognized stays empty", "klingon", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := &RuntimeContext{Config: &core.CliConfig{Lang: tt.stored}}
if got := ctx.Lang(); got != tt.want {
t.Errorf("Lang() with stored %q = %q, want %q", tt.stored, got, tt.want)
}
})
}
}

View File

@@ -16,7 +16,7 @@ import (
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
return []common.Flag{
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown", "text"}},
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
{Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
@@ -142,7 +142,7 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
return ro
}
// validateFetchDetail 非 xml 格式markdown/text)不承载 block_id 与样式属性,拒绝 with-ids/full。
// validateFetchDetail 非 xml 格式markdown不承载 block_id 与样式属性,拒绝 with-ids/full。
func validateFetchDetail(runtime *common.RuntimeContext) error {
format := strings.TrimSpace(runtime.Str("doc-format"))
detail := strings.TrimSpace(runtime.Str("detail"))

View File

@@ -109,6 +109,45 @@ func TestDriveInspectValidate_ValidWikiURL(t *testing.T) {
}
}
func TestDriveInspectValidate_ValidDoubaoDriveFileURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/file/boxcnABC")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestDriveInspectValidate_ValidDoubaoChatDriveFolderURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://feishu.doubao.com/chat/drive/fldcnABC")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestDriveInspectValidate_ValidDoubaoDriveShareFolderURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/shr/fldcnABC")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
// --- DryRun tests ---
func TestDriveInspectDryRun_DocxURL(t *testing.T) {
@@ -235,10 +274,85 @@ func TestDriveInspectDryRun_BareTokenWithType(t *testing.T) {
}
}
func TestDriveInspectDryRun_DoubaoDriveFileURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/file/boxcnABC")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
dry := DriveInspect.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
reqDocs, ok := got.API[0].Body["request_docs"].([]interface{})
if !ok || len(reqDocs) != 1 {
t.Fatalf("expected request_docs with 1 entry, got %v", got.API[0].Body["request_docs"])
}
doc, _ := reqDocs[0].(map[string]interface{})
if doc["doc_token"] != "boxcnABC" {
t.Errorf("doc_token = %v, want boxcnABC", doc["doc_token"])
}
if doc["doc_type"] != "file" {
t.Errorf("doc_type = %v, want file", doc["doc_type"])
}
}
func TestDriveInspectDryRun_DoubaoDriveShareFolderURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/shr/fldcnABC")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
dry := DriveInspect.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
reqDocs, ok := got.API[0].Body["request_docs"].([]interface{})
if !ok || len(reqDocs) != 1 {
t.Fatalf("expected request_docs with 1 entry, got %v", got.API[0].Body["request_docs"])
}
doc, _ := reqDocs[0].(map[string]interface{})
if doc["doc_token"] != "fldcnABC" {
t.Errorf("doc_token = %v, want fldcnABC", doc["doc_token"])
}
if doc["doc_type"] != "folder" {
t.Errorf("doc_type = %v, want folder", doc["doc_type"])
}
}
// --- Execute tests ---
func TestDriveInspectExecute_DocxURL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -280,7 +394,6 @@ func TestDriveInspectExecute_DocxURL(t *testing.T) {
}
func TestDriveInspectExecute_WikiURL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -343,7 +456,6 @@ func TestDriveInspectExecute_WikiURL(t *testing.T) {
}
func TestDriveInspectExecute_WikiGetNodeIncompleteData(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -372,7 +484,6 @@ func TestDriveInspectExecute_WikiGetNodeIncompleteData(t *testing.T) {
}
func TestDriveInspectExecute_BareTokenWithType(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -409,7 +520,6 @@ func TestDriveInspectExecute_BareTokenWithType(t *testing.T) {
}
func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -433,7 +543,6 @@ func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
}
func TestDriveInspectExecute_PrettyFormat(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
secureLabelReadScope = "drive:file.meta.sec_label.read_only"
secureLabelUpdateScope = "docs:secure_label:write_only"
)
var secureLabelTypes = permApplyTypes
// DriveSecureLabelList lists secure labels available to the current user.
var DriveSecureLabelList = common.Shortcut{
Service: "drive",
Command: "+secure-label-list",
Description: "List secure labels available to the current user",
Risk: "read",
Scopes: []string{secureLabelReadScope},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: "10", Desc: "page size, 1-10"},
{Name: "page-token", Desc: "pagination token from previous response"},
{Name: "lang", Desc: "label language", Enum: []string{"zh", "en", "ja"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
pageSize := runtime.Int("page-size")
if pageSize < 1 || pageSize > 10 {
return output.ErrValidation("--page-size must be between 1 and 10")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("List secure labels available to the current user").
GET("/open-apis/drive/v2/my_secure_labels").
Params(buildSecureLabelListParams(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
data, err := runtime.CallAPI("GET",
"/open-apis/drive/v2/my_secure_labels",
buildSecureLabelListParams(runtime),
nil,
)
if err != nil {
return err
}
runtime.OutFormat(data, nil, nil)
return nil
},
}
// DriveSecureLabelUpdate updates the secure label on a Drive file/document.
var DriveSecureLabelUpdate = common.Shortcut{
Service: "drive",
Command: "+secure-label-update",
Description: "Update the secure label on a Drive file or document",
Risk: "write",
Scopes: []string{secureLabelUpdateScope},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "token", Desc: "target file token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: secureLabelTypes},
{Name: "label-id", Desc: "secure label ID to set", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, docType, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("Update Drive secure label").
PATCH("/open-apis/drive/v2/files/:file_token/secure_label").
Params(map[string]interface{}{"type": docType}).
Body(map[string]interface{}{"id": runtime.Str("label-id")}).
Set("file_token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, docType, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return err
}
body := map[string]interface{}{"id": runtime.Str("label-id")}
data, err := runtime.CallAPI("PATCH",
fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)),
map[string]interface{}{"type": docType},
body,
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
if pageToken := runtime.Str("page-token"); pageToken != "" {
params["page_token"] = pageToken
}
if lang := runtime.Str("lang"); lang != "" {
params["lang"] = lang
}
return params
}
func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) {
return resolvePermApplyTarget(raw, explicitType)
}

View File

@@ -0,0 +1,164 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveSecureLabelList_DryRun(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
"+secure-label-list",
"--page-size", "5",
"--page-token", "page_1",
"--lang", "zh",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"/open-apis/drive/v2/my_secure_labels",
`"GET"`,
`"page_size": 5`,
`"page_token": "page_1"`,
`"lang": "zh"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q:\n%s", want, out)
}
}
}
func TestDriveSecureLabelList_ValidatePageSize(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
"+secure-label-list",
"--page-size", "11",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "page-size") {
t.Fatalf("expected page-size validation error, got: %v", err)
}
}
func TestDriveSecureLabelList_ExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/my_secure_labels?page_size=10",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "7217780879644737540", "name": "L1"},
},
},
},
})
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
"+secure-label-list",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"L1"`) {
t.Fatalf("stdout missing label:\n%s", stdout.String())
}
}
func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "https://example.feishu.cn/docx/doxTok123?from=share",
"--label-id", "7217780879644737539",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"/open-apis/drive/v2/files/doxTok123/secure_label",
`"PATCH"`,
`"docx"`,
`"id": "7217780879644737539"`,
`"file_token": "doxTok123"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q:\n%s", want, out)
}
}
}
func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v2/files/doxTok123/secure_label?type=docx",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "doxTok123",
"--type", "docx",
"--label-id", "7217780879644737539",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
if body["id"] != "7217780879644737539" {
t.Fatalf("id = %v, want label id", body["id"])
}
}
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
Status: 403,
Body: map[string]interface{}{
"code": 1063013, "msg": "Security label downgrade requires approval",
},
})
targetURL := "https://example.feishu.cn/docx/doxTok123"
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", targetURL,
"--label-id", "7217780879644737539",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected 1063013 error")
}
if !strings.Contains(err.Error(), "Security label downgrade requires approval") {
t.Fatalf("expected raw API error message, got: %v", err)
}
}

View File

@@ -28,6 +28,8 @@ func Shortcuts() []common.Shortcut {
DriveSync,
DriveTaskResult,
DriveApplyPermission,
DriveSecureLabelList,
DriveSecureLabelUpdate,
DriveSearch,
DriveInspect,
}

View File

@@ -31,6 +31,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+sync",
"+task_result",
"+apply-permission",
"+secure-label-list",
"+secure-label-update",
"+search",
"+inspect",
}

View File

@@ -729,6 +729,18 @@ func TestShortcutDryRunShapes(t *testing.T) {
}
})
t.Run("ImMessagesSend dry run warns chat membership is not verified", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"text": "hello",
}, nil)
got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime))
if !strings.Contains(got, "Bot/user membership in the target chat is not verified") ||
!strings.Contains(got, "Bot/User can NOT be out of the chat") {
t.Fatalf("ImMessagesSend.DryRun() missing membership warning: %s", got)
}
})
t.Run("ImMessagesSend dry run uses placeholder media key for url input", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
@@ -742,6 +754,19 @@ func TestShortcutDryRunShapes(t *testing.T) {
}
})
t.Run("ImMessagesSend dry run preserves media and membership descriptions", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"image": "https://example.com/a.png",
}, nil)
mediaDesc := `"description":"dry-run uses placeholder media keys for --image URL input; execution uploads it before sending"`
membershipDesc := `"desc":"NOTE: dry-run validates request shape only. Bot/user membership in the target chat is not verified; the real send may fail with ` + "`Bot/User can NOT be out of the chat`" + `."`
got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime))
if !strings.Contains(got, mediaDesc) || !strings.Contains(got, membershipDesc) {
t.Fatalf("ImMessagesSend.DryRun() should preserve both descriptions: %s", got)
}
})
t.Run("ImMessagesMGet dry run expands message ids", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-ids": "om_1,om_2",

View File

@@ -32,6 +32,14 @@ type ConvertContext struct {
// SenderNames is a shared cache of open_id -> display name, accumulated across messages
// to avoid redundant contact API calls. May be nil.
SenderNames map[string]string
// MergeForwardSubItems is an optional pre-fetched cache of merge_forward
// sub-message lists, keyed by merge_forward message_id. When set, the
// merge_forward converter uses the cached entry instead of issuing its
// own GET; populated by callers via PrefetchMergeForwardSubItems before
// the FormatMessageItem loop. nil means "no prefetch — fall back to the
// per-message inline GET", which keeps non-shortcut callers (events,
// ad-hoc tests) working unchanged.
MergeForwardSubItems map[string][]map[string]interface{}
}
// converters maps message types to their ContentConverter implementations.
@@ -119,6 +127,20 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
if len(senderNames) > 0 {
nameCache = senderNames[0]
}
return formatMessageItem(m, runtime, nameCache, nil)
}
// FormatMessageItemWithMergePrefetch is like FormatMessageItem but threads a
// pre-fetched merge_forward sub-message map (typically built via
// PrefetchMergeForwardSubItems) through to the merge_forward converter so it
// can skip its own per-message GET. Shortcuts that iterate a page of raw
// items should pre-fetch once and call this variant in the loop to avoid the
// N × ~1s serial-merge_forward stall in the original code path.
func FormatMessageItemWithMergePrefetch(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}) map[string]interface{} {
return formatMessageItem(m, runtime, nameCache, mergePrefetch)
}
func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}) map[string]interface{} {
msgType, _ := m["msg_type"].(string)
messageId, _ := m["message_id"].(string)
mentions, _ := m["mentions"].([]interface{})
@@ -129,11 +151,12 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
if body, ok := m["body"].(map[string]interface{}); ok {
rawContent, _ := body["content"].(string)
content = ConvertBodyContent(msgType, &ConvertContext{
RawContent: rawContent,
MentionMap: BuildMentionKeyMap(mentions),
MessageID: messageId,
Runtime: runtime,
SenderNames: nameCache,
RawContent: rawContent,
MentionMap: BuildMentionKeyMap(mentions),
MessageID: messageId,
Runtime: runtime,
SenderNames: nameCache,
MergeForwardSubItems: mergePrefetch,
})
}
@@ -155,6 +178,20 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
}
// Preserve API-provided fields (even if this formatter doesn't otherwise use them).
// update_time is only meaningful when the message was actually edited;
// the server echoes update_time == create_time for unedited messages, which
// would otherwise make every output look "updated" to downstream consumers.
if updated {
if v, ok := m["update_time"]; ok && v != nil {
if s, isStr := v.(string); isStr {
if strings.TrimSpace(s) != "" {
msg["update_time"] = common.FormatTime(s)
}
} else {
msg["update_time"] = common.FormatTime(v)
}
}
}
if v, ok := m["chat_id"]; ok {
msg["chat_id"] = v
}

View File

@@ -95,6 +95,61 @@ func TestFormatMessageItem(t *testing.T) {
}
}
func TestFormatMessageItem_UpdateTime_Present(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_edit",
"updated": true,
"create_time": "1710500000",
"update_time": "1710600000",
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
"body": map[string]interface{}{"content": `{"text":"edited"}`},
}
got := FormatMessageItem(raw, nil)
want := common.FormatTime("1710600000")
if got["update_time"] != want {
t.Fatalf("FormatMessageItem() update_time = %#v, want %#v", got["update_time"], want)
}
}
func TestFormatMessageItem_UpdateTime_Absent(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_no_edit",
"updated": false,
"create_time": "1710500000",
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
got := FormatMessageItem(raw, nil)
if _, ok := got["update_time"]; ok {
t.Fatalf("FormatMessageItem() should not include update_time when absent, got = %#v", got["update_time"])
}
}
// TestFormatMessageItem_UpdateTime_UnchangedMessage: real API behavior — even
// for unedited messages, server returns update_time == create_time. We must
// NOT echo it through, otherwise every message looks "edited" to consumers.
// Gate the output on updated==true.
func TestFormatMessageItem_UpdateTime_UnchangedMessage(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_unchanged",
"updated": false,
"create_time": "1710500000",
"update_time": "1710500000", // server echoes create_time
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
got := FormatMessageItem(raw, nil)
if v, ok := got["update_time"]; ok {
t.Fatalf("FormatMessageItem() must skip update_time for unedited message, got = %#v", v)
}
}
func TestResolveAppLinkDomain(t *testing.T) {
if got := resolveAppLinkDomain(core.BrandFeishu); got != "applink.feishu.cn" {
t.Fatalf("resolveAppLinkDomain(feishu) = %q", got)

View File

@@ -4,11 +4,11 @@
package convertlib
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/larksuite/cli/internal/validate"
@@ -16,28 +16,53 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// mergeForwardPrefetchConcurrency caps in-flight merge_forward sub-message
// fetches when a shortcut pre-scans a page for merge_forward messages and
// prefetches their children concurrently. Each call is one ~700ms-1s
// GET /open-apis/im/v1/messages/{id} per merge_forward — strictly serial in
// FormatMessageItem before this change, which turned page-size 50 + 5
// merge_forward messages into ~8.5s of stall (measured on a real chat).
// GET /open-apis/im/v1/messages/{id} has no published per-app rate-limit at
// these levels, so we set this higher than the reactions batch_query cap
// (which sits at 4 to stay well under the gateway-layer 50/s + 1000/min
// explicit ceiling on the reactions endpoint).
const mergeForwardPrefetchConcurrency = 8
type mergeForwardConverter struct{}
// Convert expands merge_forward sub-messages into a tree when runtime is available,
// otherwise falls back to a summary string.
// Convert expands merge_forward sub-messages into a tree when runtime is
// available (or a pre-fetched cache was supplied), otherwise falls back to a
// summary string.
//
// When ctx.MergeForwardSubItems is non-nil (set by callers that pre-fetched
// the page's merge_forward children concurrently via
// PrefetchMergeForwardSubItems), Convert uses the cached items and skips the
// HTTP fetch entirely — this is how the shortcut layer turns N serial
// per-merge_forward GETs into one bounded-concurrency fan-out before the
// FormatMessageItem loop runs.
func (mergeForwardConverter) Convert(ctx *ConvertContext) string {
// When runtime is available, fetch sub-messages via API and expand into a tree.
// merge_forward body.content is typically a plain-text placeholder (e.g. "Merged and Forwarded Message"),
// not JSON with create_message_ids, so we must rely on the API to get actual sub-messages.
// Fast path: caller pre-fetched this merge_forward's sub-tree.
if ctx.MergeForwardSubItems != nil && ctx.MessageID != "" {
if cached, ok := ctx.MergeForwardSubItems[ctx.MessageID]; ok {
return renderMergeForwardTree(ctx, cached)
}
}
// Slow path: no pre-fetch; fall back to a per-merge_forward GET. Kept so
// callers that don't pre-fetch (e.g. event subscribers, ad-hoc Convert
// invocations in tests) still produce correct output, just serially.
// merge_forward body.content is typically a plain-text placeholder, not
// JSON with create_message_ids, so we must rely on the API to get actual
// sub-messages.
if ctx.Runtime != nil && ctx.MessageID != "" {
subItems, err := fetchMergeForwardSubMessages(ctx.MessageID, ctx.Runtime)
if err != nil {
return fmt.Sprintf("[Merged forward: fetch failed: %s]", err)
}
if len(subItems) > 0 {
// Resolve sender names using shared cache to avoid redundant API calls across merge_forward messages
nameMap := ResolveSenderNames(ctx.Runtime, subItems, ctx.SenderNames)
AttachSenderNames(subItems, nameMap)
childrenMap := BuildMergeForwardChildrenMap(subItems, ctx.MessageID)
return FormatMergeForwardSubTree(ctx.MessageID, childrenMap)
return renderMergeForwardTree(ctx, subItems)
}
}
// Fallback: try to extract message IDs from content (some older formats include them)
// Final fallback: try to extract message IDs from content (some older formats include them)
ids := ParseMergeForwardIDs(ctx.RawContent)
if len(ids) > 0 {
return fmt.Sprintf("[Merged forward: %d messages]", len(ids))
@@ -45,31 +70,158 @@ func (mergeForwardConverter) Convert(ctx *ConvertContext) string {
return "[Merged forward]"
}
// fetchMergeForwardSubMessages fetches all sub-messages in a merge_forward container
// via a single API call. Returns a flat list of raw message items with upper_message_id
// for tree reconstruction.
// renderMergeForwardTree resolves sender names for the supplied sub-items and
// produces the formatted forwarded-messages tree. Shared by the prefetch fast
// path and the inline fetch fallback so both produce identical output.
func renderMergeForwardTree(ctx *ConvertContext, subItems []map[string]interface{}) string {
nameMap := ResolveSenderNames(ctx.Runtime, subItems, ctx.SenderNames)
AttachSenderNames(subItems, nameMap)
childrenMap := BuildMergeForwardChildrenMap(subItems, ctx.MessageID)
return FormatMergeForwardSubTree(ctx.MessageID, childrenMap)
}
// PrefetchMergeForwardSubItems scans rawItems for merge_forward messages,
// concurrently fetches each one's flat sub-message list, and returns a map
// keyed by the merge_forward message_id. Callers thread the returned map
// through FormatMessageItemWithMergePrefetch (or directly into a
// ConvertContext.MergeForwardSubItems) so the per-item conversion loop can
// reuse cached sub-trees instead of issuing its own serial GET.
//
// Each fetch is independent (different message_id, different sub-tree), so
// concurrent goroutines never contend on shared mutable state — the result
// map is written under a mutex purely to make the map safe for concurrent
// inserts.
//
// On fetch failure: emit a stderr warning and intentionally do NOT insert
// the failed id into the result map. The downstream
// mergeForwardConverter.Convert path keys off "is this id present in the
// prefetch?" — by leaving the key absent on failure, Convert falls through
// to its inline-fetch slow path, which (a) gets a second attempt at the
// GET, and (b) if that ALSO fails, surfaces the real "[Merged forward:
// fetch failed: ...]" string the user used to see in stdout. Inserting nil
// would have silently produced an empty <forwarded_messages> tree instead,
// dropping the failure signal from the user-visible output.
//
// When nameCache is non-nil, this function also runs one batched
// ResolveSenderNames across every sub-item it fetched, populating the cache
// before returning. Without this step, each per-merge_forward render in the
// caller's loop would issue its own contact API request for any uncached
// sender, re-introducing an N × ~400ms serial stall (measured at 5
// merge_forwards × ~400ms = ~2s in production traces). Pre-populating the
// cache makes those per-render ResolveSenderNames calls effective no-ops.
func PrefetchMergeForwardSubItems(runtime *common.RuntimeContext, rawItems []interface{}, nameCache map[string]string) map[string][]map[string]interface{} {
if runtime == nil || len(rawItems) == 0 {
return nil
}
var ids []string
for _, item := range rawItems {
m, _ := item.(map[string]interface{})
if m == nil {
continue
}
if mt, _ := m["msg_type"].(string); mt != "merge_forward" {
continue
}
id, _ := m["message_id"].(string)
if id == "" {
continue
}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil
}
result := make(map[string][]map[string]interface{}, len(ids))
if len(ids) == 1 {
// Single-message fast path: no goroutine overhead. Matches the
// pre-existing serial behavior bit-for-bit when only one
// merge_forward is present.
items, err := fetchMergeForwardSubMessages(ids[0], runtime)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: merge_forward_prefetch_failed: %s: %v\n", ids[0], err)
// Leave the key absent so Convert falls back to its inline GET
// path and surfaces "[Merged forward: fetch failed: ...]" if
// the retry also fails. See function godoc.
} else {
result[ids[0]] = items
}
batchResolveMergeForwardSenders(runtime, result, nameCache)
return result
}
var mu sync.Mutex
sem := make(chan struct{}, mergeForwardPrefetchConcurrency)
var wg sync.WaitGroup
for _, id := range ids {
// Add before the semaphore acquire — sync.WaitGroup godoc
// recommends Add precede the goroutine-spawning event.
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
items, err := fetchMergeForwardSubMessages(id, runtime)
mu.Lock()
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: merge_forward_prefetch_failed: %s: %v\n", id, err)
// Leave the key absent — see fast-path comment above.
} else {
result[id] = items
}
mu.Unlock()
}()
}
wg.Wait()
batchResolveMergeForwardSenders(runtime, result, nameCache)
return result
}
// batchResolveMergeForwardSenders gathers every sub-item across every
// prefetched merge_forward and runs a single ResolveSenderNames call against
// nameCache. No-op when nameCache is nil (callers that pre-fetched without
// caring about sender resolution, e.g. event subscribers that render on the
// fly) or when nothing was fetched.
func batchResolveMergeForwardSenders(runtime *common.RuntimeContext, prefetch map[string][]map[string]interface{}, nameCache map[string]string) {
if nameCache == nil || len(prefetch) == 0 {
return
}
var allSubItems []map[string]interface{}
for _, items := range prefetch {
allSubItems = append(allSubItems, items...)
}
if len(allSubItems) == 0 {
return
}
ResolveSenderNames(runtime, allSubItems, nameCache)
}
// fetchMergeForwardSubMessages fetches all sub-messages in a merge_forward
// container via a single API call. Returns a flat list of raw message items
// with upper_message_id for tree reconstruction.
//
// Uses DoAPIJSON so the response envelope's code/msg are checked and surfaced
// — earlier this used the low-level DoAPI and reported every non-zero code
// as a generic "empty data" error, hiding the real failure (e.g. a server
// "code: 2200 Internal Error" with its log_id would show up as just "empty
// data" in the output).
func fetchMergeForwardSubMessages(messageID string, runtime *common.RuntimeContext) ([]map[string]interface{}, error) {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: mergeForwardMessagesPath(messageID),
QueryParams: larkcore.QueryParams{
"user_id_type": []string{"open_id"},
"card_msg_content_type": []string{"raw_card_content"},
},
})
data, err := runtime.DoAPIJSON(http.MethodGet, mergeForwardMessagesPath(messageID), larkcore.QueryParams{
"user_id_type": []string{"open_id"},
"card_msg_content_type": []string{"raw_card_content"},
}, nil)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return nil, fmt.Errorf("invalid response: %w", err)
}
data, _ := result["data"].(map[string]interface{})
// DoAPIJSON returns the envelope's `data` field; when the server's JSON
// has `code: 0` but omits `data` entirely, that field comes back as nil.
// Reading from a nil map in Go is safe (returns the zero value, never
// panics), but guarding explicitly makes the "successful empty
// response" path obvious and keeps a future signature change from
// silently introducing nil-deref hazards.
if data == nil {
return nil, fmt.Errorf("empty data")
return []map[string]interface{}{}, nil
}
rawItems, _ := data["items"].([]interface{})
items := make([]map[string]interface{}, 0, len(rawItems))
for _, raw := range rawItems {

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"testing"
"time"
)
@@ -86,7 +87,14 @@ func TestFetchMergeForwardSubMessages(t *testing.T) {
}
})
t.Run("empty data", func(t *testing.T) {
t.Run("empty data treated as no children", func(t *testing.T) {
// `code: 0` with no data field is a successful "no children" response
// after the switch to DoAPIJSON (which checks the response envelope's
// code/msg directly). Previously this was reported as a generic
// "empty data" error — which also masked real failures like a
// non-zero code with data: null — so a successful empty payload now
// returns (nil, nil) and lets Convert fall through to its summary
// fallback string.
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_bad"):
@@ -96,11 +104,193 @@ func TestFetchMergeForwardSubMessages(t *testing.T) {
}
}))
_, err := fetchMergeForwardSubMessages("om_bad", runtime)
if err == nil || !strings.Contains(err.Error(), "empty data") {
t.Fatalf("fetchMergeForwardSubMessages() error = %v", err)
items, err := fetchMergeForwardSubMessages("om_bad", runtime)
if err != nil {
t.Fatalf("fetchMergeForwardSubMessages(success-but-empty) err = %v, want nil", err)
}
if len(items) != 0 {
t.Fatalf("fetchMergeForwardSubMessages(success-but-empty) items = %#v, want empty", items)
}
})
t.Run("non-zero code surfaces real error", func(t *testing.T) {
// Regression coverage for the bug that motivated the DoAPIJSON
// switch: a server response with code != 0 (here: 2200 Internal
// Error, observed in production for some merge_forward IDs) used to
// be silently reported as the generic "empty data" string, hiding
// the real code/msg/log_id. With DoAPIJSON the envelope's code is
// checked and surfaced as an ErrAPI containing the real message.
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return convertlibJSONResponse(200, map[string]interface{}{
"code": 2200,
"msg": "Internal Error",
}), nil
}))
_, err := fetchMergeForwardSubMessages("om_err", runtime)
if err == nil {
t.Fatal("fetchMergeForwardSubMessages(code=2200) err = nil, want non-nil")
}
if !strings.Contains(err.Error(), "Internal Error") {
t.Fatalf("fetchMergeForwardSubMessages(code=2200) err = %q, want it to contain the real msg", err)
}
})
}
// TestPrefetchMergeForwardSubItems exercises the bounded-concurrency prefetch
// path: each merge_forward in the input gets its own GET fetched in
// parallel, and the returned map keys items by their merge_forward
// message_id. A goroutine cross-contamination bug would manifest as
// mis-keyed entries.
func TestPrefetchMergeForwardSubItems(t *testing.T) {
var (
mu sync.Mutex
callCount int
)
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
// Each merge_forward's path ends with its message_id; key the
// returned child off that so the test can detect mis-attachment.
path := req.URL.Path
// The path looks like /open-apis/im/v1/messages/<encoded-id>; take
// the last segment.
lastSlash := strings.LastIndex(path, "/")
if lastSlash < 0 {
return nil, fmt.Errorf("unexpected path: %s", path)
}
hostID := path[lastSlash+1:]
mu.Lock()
callCount++
mu.Unlock()
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"message_id": "om_child_of_" + hostID,
"create_time": "1710500000000",
},
},
},
}), nil
}))
// Mix of merge_forward and non-merge_forward messages — only the former
// should be fetched. 5 merge_forwards is enough to exercise the
// bounded fan-out (cap = 4) rather than fall into a single-message fast
// path.
rawItems := []interface{}{
map[string]interface{}{"message_id": "om_mf_1", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_text_a", "msg_type": "text"},
map[string]interface{}{"message_id": "om_mf_2", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_mf_3", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_image", "msg_type": "image"},
map[string]interface{}{"message_id": "om_mf_4", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_mf_5", "msg_type": "merge_forward"},
}
got := PrefetchMergeForwardSubItems(runtime, rawItems, nil)
if callCount != 5 {
t.Fatalf("expected 5 merge_forward fetches, got %d", callCount)
}
wantIDs := []string{"om_mf_1", "om_mf_2", "om_mf_3", "om_mf_4", "om_mf_5"}
for _, id := range wantIDs {
children, ok := got[id]
if !ok {
t.Fatalf("prefetch map missing key %q (cross-thread contamination?)", id)
}
if len(children) != 1 {
t.Fatalf("prefetch[%s] children len = %d, want 1", id, len(children))
}
want := "om_child_of_" + id
if children[0]["message_id"] != want {
t.Fatalf("prefetch[%s] child id = %v, want %q — mis-attributed result", id, children[0]["message_id"], want)
}
}
for _, missing := range []string{"om_text_a", "om_image"} {
if _, ok := got[missing]; ok {
t.Fatalf("prefetch map should not contain non-merge_forward key %q", missing)
}
}
}
// TestPrefetchMergeForwardSubItemsHTTPError covers the transport-level
// failure path: server replies with a non-2xx status (e.g. 503). DoAPIJSON
// surfaces this as a network error, the prefetch goroutine emits a stderr
// warning, and — critically — does NOT insert the failed id into the
// result map, so Convert falls back to inline retry (same contract as
// envelope-level errors, exercised by
// TestPrefetchMergeForwardSubItemsFailureFallsThroughToInlineFetch).
func TestPrefetchMergeForwardSubItemsHTTPError(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
// 503 Service Unavailable with no body — purely a transport-layer
// error. DoAPIJSON's `resp.StatusCode >= 400` branch handles this
// before it ever tries to parse an envelope, which is the path the
// envelope-error test doesn't reach.
return convertlibJSONResponse(503, map[string]interface{}{}), nil
}))
rawItems := []interface{}{
map[string]interface{}{"message_id": "om_mf_a", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_mf_b", "msg_type": "merge_forward"},
}
got := PrefetchMergeForwardSubItems(runtime, rawItems, nil)
for _, id := range []string{"om_mf_a", "om_mf_b"} {
if _, ok := got[id]; ok {
t.Fatalf("prefetch map contains transport-error id %q — Convert would render an empty tree instead of falling back to the inline retry path", id)
}
}
}
// TestPrefetchMergeForwardSubItemsFailureFallsThroughToInlineFetch is a
// regression test for the silent-empty-tree bug: when a prefetch fails, the
// failed id MUST be absent from the returned map (not present-with-nil).
// Otherwise Convert's "if cached, ok := m[id]; ok { renderTree(cached) }"
// path hits `ok=true, cached=nil`, renders an empty <forwarded_messages>
// tree, and the user-visible "[Merged forward: fetch failed: ...]" string
// that the inline path produced disappears.
func TestPrefetchMergeForwardSubItemsFailureFallsThroughToInlineFetch(t *testing.T) {
// Mock: every fetch returns an API error.
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return convertlibJSONResponse(200, map[string]interface{}{
"code": 2200,
"msg": "Internal Error",
}), nil
}))
// Multiple ids so we hit the concurrent path (the single-id fast path
// has its own dedicated branch; covering the concurrent branch is more
// stringent since the bug originally hid inside its mu.Lock section).
rawItems := []interface{}{
map[string]interface{}{"message_id": "om_mf_1", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_mf_2", "msg_type": "merge_forward"},
}
got := PrefetchMergeForwardSubItems(runtime, rawItems, nil)
// Every failed id MUST be absent from the map (not present-with-nil).
for _, id := range []string{"om_mf_1", "om_mf_2"} {
if _, ok := got[id]; ok {
t.Fatalf("prefetch map contains failed id %q — this would cause Convert to render an empty <forwarded_messages> tree instead of falling back to the inline-fetch error path", id)
}
}
// And as the downstream effect: invoking the converter on the failed id
// with the (now-cleanly-absent-key) prefetch map must produce the
// inline-path error string, not an empty tree. The mocked inline fetch
// also errors with the same 2200 / Internal Error, so the rendered
// content should contain "Merged forward: fetch failed".
out := (mergeForwardConverter{}).Convert(&ConvertContext{
MessageID: "om_mf_1",
Runtime: runtime,
SenderNames: map[string]string{},
MergeForwardSubItems: got,
})
if !strings.Contains(out, "Merged forward: fetch failed") {
t.Fatalf("Convert output after prefetch failure = %q, want it to contain \"Merged forward: fetch failed\" — failure signal lost", out)
}
}
func TestMergeForwardConverterWithRuntime(t *testing.T) {

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