Compare commits

..

72 Commits

Author SHA1 Message Date
liangshuo-1
1dd0758091 chore: release v1.0.60 (#1657) 2026-06-29 22:34:36 +08:00
yballul-bytedance
4a5a669b1a fix(auth): remove 'claude settings' (#1654) 2026-06-29 21:58:22 +08:00
liangshuo-1
ebb0b6fe73 feat(affordance): per-command usage guidance system (markdown source) (#1565) 2026-06-29 19:33:27 +08:00
liangshuo-1
5c0a36b2a6 feat(transport): add LARK_CLI_NO_PROXY_WARN to silence proxy warning (#1647) 2026-06-29 19:31:48 +08:00
mazhe-nerd
21905b0ba1 fix(install): load @clack/prompts via dynamic import to avoid ERR_REQUIRE_ESM (#1636) (#1652) 2026-06-29 19:16:37 +08:00
yballul-bytedance
602c788fd9 feat(authorization): expand lark-shared auth guidance and assert clean logout JSON (#1598)
- skills/lark-shared/SKILL.md: broaden skill description to cover auth login/status/logout, --domain business-domain scopes, missing scopes and authorization revocation; add an auth task quick-reference table mapping user intents to lark-cli commands; document LARKSUITE_CLI_NO_UPDATE_NOTIFIER / LARKSUITE_CLI_NO_SKILLS_NOTIFIER env vars for stable JSON; soften _notice.update handling so it no longer interrupts the current task.
- cmd/auth/logout_test.go: in TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly, additionally assert that the success JSON payload has no 'message' field, matching the contract that logout success only carries loggedOut=true.
2026-06-29 16:28:57 +08:00
HanShaoshuai-k
30b28cf17f fix: reduce public content false positives 2026-06-29 14:02:43 +08:00
calendar-assistant
297776ea66 feat(event): support VC meeting lifecycle events (#1632) 2026-06-29 11:11:23 +08:00
Max Coplan
5b0c3137e3 test(doc): derive fetch test flag defaults from v2FetchFlags (#1428)
Replace hardcoded flag defaults in the fetch test helpers with
fetchDefault() / fetchDefaultInt() helpers that read the declared
defaults from v2FetchFlags(). This prevents future drift between
production flag defaults and test setup, and panics loudly if a
flag name is misspelled rather than silently returning "".

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

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

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

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

* feat: add slides xml get shortcut

* fix: stop advertising slides screenshot scope

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

* docs: fix approval skill reference links

* docs: restore approval reference links

* docs: align approval skill with review guidelines

* docs: clarify approval skill boundaries

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

Change-Id: I6fd840fe813e5e664ea9ec680765fd41375cdebf

* docs: refine docs title guidance

Change-Id: I2f986a4606729bc791a1bff6c03aaa198b0798dc

* docs: keep lark doc skill create example

Change-Id: Ic7005e015c9e71a4582c1f4a8ac8222d552426d4

* test: allow docs create title flag in help

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

* refactor: tune docs im-markdown conversion

* test: expand docs im-markdown conversion coverage

* refactor: simplify docs im-markdown handlers

* test: cover docs im-markdown edge cases

* fix: expand doc im markdown tag downgrades

* fix: preserve blockquote paragraph breaks

* fix: handle im markdown nested tables and urls

* docs: document im markdown skill usage

* test: cover doc im markdown fetch

* test: strengthen doc fetch error coverage

* fix: fetch doc skill typo
2026-06-25 11:49:58 +08:00
cl900811
644c3c77dd doc(whiteboard):support export whiteboard as SVG and update whiteboard via SVG (#1559)
* feat(lark-whiteboard): update shortcut, support query or update whiteboard by svg

* feat(whiteboard): pin whiteboard-cli to v0.2.12 in lark-whiteboard skill

* fix(whiteboard): whiteboard shortcuts unit test

* fix(whiteboard): add whiteboard query shortcut unit test
2026-06-25 11:08:16 +08:00
xiongyuanwen-byted
bd898a1d74 feat(sheets): typed table I/O & error contract, workbook import/export, skill refresh (#1355)
* feat(sheets): add +sheet-show-gridline / +sheet-hide-gridline shortcuts

* docs(sheets): strengthen lark-sheets references for common editing pitfalls

Add targeted guidance to six lark-sheets references to reduce frequent
mistakes when editing spreadsheets through the CLI:

- write-cells: sanity-check units / dimension conversion / quantity factors
  before formula writes (formulas can run clean yet be off by a factor);
  keep derived output off original data columns to avoid clobbering source
- core-operations: prefer live formulas for derived values even when "live
  update" is not explicitly requested; scope rewrite/transform precisely so
  rows/columns that should stay unchanged are kept 1:1; treat header-stated
  format rules as checklist items; confirm the artifact file actually exists
  before finishing; write back bare values from local scripts
- visual-standards: apply border/header formatting on explicit request and
  identify the real header row; keep font size consistent with the source
- range-operations: keep total column width within A4 for printing
- read-data: dedup/compare long numbers via raw values, not csv formatted
  display (scientific notation collapses distinct numbers and causes false
  duplicates)
- chart: format date/number axes via source-cell number_format; place charts
  outside the data area so they do not cover existing data

* feat(sheets): implement table-put/table-get and sync skill specs

- Add lark_sheet_table_io.go with +table-put / +table-get and tests
- Refactor read-data; extend workbook; register new shortcuts
- Sync generated flag defs/schemas (go:embed) from sheet-skill-spec
- Sync skill references (write-cells numeric-column guidance, plus
  read-data / workbook / chart updates)

* docs(sheets): surface typed-write path at the write-decision point

Quick-ref table (SKILL.md, the first decision point) had no +table-put and
gated typed writes on "DataFrame", so a model holding a Counter/list/dict
would fall back to +csv-put and silently lose number/date fidelity.

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

Synced from sheet-skill-spec canonical (generate:cli + sync:cli).

* docs(sheets): sync SKILL.md (drop "not for local Excel" caveat)

Mirror the upstream sheet-skill-spec change removing the "not applicable to local Excel files" tail from the sheets skill and reference descriptions.

* docs(sheets): sync SKILL.md (drop "Feishu sheets only" caveat)

Mirror the upstream sheet-skill-spec change removing the "applies to Feishu sheets only" tail from the 14 sheet reference descriptions.

* feat(sheets): add +workbook-import wrapping the drive import core

Import a local xlsx/xls/csv as a new spreadsheet by delegating to the shared drive import flow with the target type pinned to sheet. Refactor drive +import to expose ImportParams / ValidateImport / PlanImportDryRun / RunImport (behavior unchanged, existing drive tests still cover it); sheets reuses them. Regenerate flag_defs_gen.go and sync the spec mirror.

* refactor(sheets): reuse the drive export core in +workbook-export

Replace +workbook-export's parallel export-task implementation with the shared drive ExportParams/RunExport core (pinned to type=sheet). Drops ~90 lines of duplicated poll/download code; +workbook-export now inherits drive's ctx cancellation, resume-on-timeout, filename sanitize/overwrite, and the full set of export status labels. The output contract aligns with drive's (adds ready/downloaded/doc_type; saved_path preserved). Also normalize an empty drive --output-dir to "." so drive +export behavior is unchanged, and fix the sheets export e2e to call +workbook-export instead of a nonexistent +export.

* docs(sheets): keep original column widths; align chart axis with requested metric

- range-operations: only widen new / overflowing columns; never recompute or
  shrink the widths of existing columns (any blanket resize, even by 1px,
  breaks the original visual format)
- chart: when the user asks for a share / percentage, the value axis should be
  a percentage (pie, or stack.percentage on bar/column) rather than raw counts

* docs(sheets): reword guidance to avoid eval-specific phrasing

Replace scoring-framework wording in the examples with plain functional
consequences (e.g. "not delivered", "goes stale when the source changes",
"breaks the original visual format"), so the references stay agent-facing.

* docs: add lark sheets financial modeling guidance

* docs(sheets): align write-cells reference with the generated output

Bring the hand-applied write-cells example in line with the spec-generated
reference so the CLI mirror is byte-identical to the canonical source.

* docs(sheets): align +csv-put help with formula support

Sync the formula-support wording from sheet-skill-spec (flag-defs, skill
references) and update the hand-authored cobra Description and comment for
+csv-put. +csv-put evaluates a leading-= cell as a formula via
set_range_from_csv; descriptions only, no behavior change.

* docs(sheets): fix invalid +dim-insert example in chart reference

The chart reference's placement example used non-existent flags
--dimension/--start/--end for +dim-insert. The real signature is
--position (required) + --count (required); copying the example
fails Validate with "--position is required". Replace it with
+dim-insert --position V --count 6 (insert 6 columns before V,
i.e. after U), aligning with the sheet-structure reference.

* docs(sheets): chart coordinate base / quoting + filter condition enums

Sync three reference-doc corrections from the spec source:

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

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

3. filter / filter-view: add the full conditions[].type x compare_type
   enum table (text / number / multiValue / color and their respective
   compare_type values and values shape), and call out the
   equals/notEquals (with s) vs equal/notEqual (no s) gotcha. The docs
   previously only showed two values via examples.

* docs(sheets): label +sheet-create --index as 0-based

The base flag description for +sheet-create's --index omitted the
coordinate base, while its siblings +sheet-move ("Target position
(0-based)") and +sheet-copy already state 0-based. Align the description
so the index base is unambiguous. Synced from the spec source
(flag-defs.json + workbook reference).

* fix(sheets): regenerate flag defs and fix asasalint in table io

* feat(sheets): add counta to chart aggregateType enum

Add `counta` (count non-empty cells, incl. text) to manage_chart_object
dim2.series[].aggregateType in the chart flag schema. `count` only counts
numeric cells, so counting occurrences of a text/category column renders an
empty chart; `counta` enables category frequency counts. Synced from the
sheet-skill-spec canonical schema.

* feat(sheets): make --target-position and --range mutually exclusive on +pivot-create

Both flags map to the same wire field (properties.range), so passing
non-default values for both is ambiguous. Mirror the
--target-sheet-id / --target-sheet-name mutex pattern: --target-position
takes priority over --range, and supplying both with non-default values
is rejected up front with a typed FlagErrorf. --target-position=A1 is
the documented default and is treated as "not set".

Add a symmetric validateCreateInput hook on objectCRUDSpec (alongside
the existing validateUpdateInput), wire it into objectCreateInput, and
inject the pivot-specific check on pivotSpec.

* feat(sheets): rework +workbook-create flags and --styles

- --values builds a type-less typed payload, writing through --sheets' batched set_cell_range path (raw passthrough preserves auto-detect; large tables batch; big ints via json.Number)
- drop --headers (subsumed by --values first row) and --header-style (typed header no longer auto-bold; use --styles instead)
- styles: deep-merge overlapping cell_styles/border_styles fields (was wholesale-replace which dropped fields); add manual border_styles validation (style/weight enums + sides) since --styles is on parseJSONFlagSkip and bypasses the schema validator
- regenerate flag-defs/flag-schemas/skills mirror from sheet-skill-spec (--styles flag + full per-side border schema)

* fix(sheets): add mention_type enum to set_cell_range cells schema

Constrain rich_text mention_type to the proto MENTION_FILE_TYPE set so a
file @mention with an out-of-enum value (e.g. 6 = cloud shared folder) is
rejected by the schema validator before it reaches the server and fails
pb serialization ("mentionFileInfo.fileType: enum value expected").

- data/flag-schemas.json: mention_type gains enum + per-value description
- lark_sheet_write_cells_test.go: cover reject (6) + allow (0 / 2 / 22)

* feat(sheets): implement pandas-split --sheets protocol for +table-put/+table-get/+workbook-create

Synced from sheet-skill-spec canonical (cli:table_put schema +
references). +table-put/+workbook-create accept the new shape via a
tableSheetIn -> tableSheetSpec normalize step (dtype string -> internal
type/format mapping). +table-get emits the same shape so the writer's
df_to_sheet and the reader's sheet_to_df round-trip cleanly.

isoDateToSerial now accepts the full ISO datetime form
(2024-01-15T00:00:00.000, including timezone suffixes) emitted by
df.to_json(date_format="iso"), not just yyyy-mm-dd. End-to-end verified
by the spec repo's contracts/python_helper_roundtrip script against a
real Lark spreadsheet on pandas 2.2 and 3.0.

* feat(sheets): add --dataframe Arrow IPC input for +table-put/+table-get/+workbook-create

Introduce a binary-typed twin of --sheets: --dataframe accepts an Arrow IPC
(Feather v2) payload that pandas' df.to_feather() writes, deriving dtypes and
per-column number formats from the Arrow schema. The two producers are mutually
exclusive and funnel through a shared resolver so +table-put and
+workbook-create stay in lockstep; +table-get gains --dataframe-out for
single-sheet reads. Also auto-grow a sub-sheet's row/column count before
writing so blocks past the backend's default 200x20 bounds no longer fail with
range-exceeds-sheet-bounds.

* docs(lark-sheets): remove financial modeling standards reference

Drop the lark-sheets-financial-modeling-standards.md reference doc and all
pointers to it from SKILL.md, core-operations, and visual-standards. Bump
skill version to 3.0.0.

* docs(lark-sheets): clarify cell-image vs float-image routing and fix reference self-references

Synced from sheet-skill-spec.

- Add a binding-based decision (does the image belong to a record and move with its row?) to route +cells-set-image vs +float-image-create across the SKILL entry, float-image and write-cells references.
- Add routing rows to the SKILL command cheat-sheet and warn against defaulting to float-image out of familiarity.
- Replace mislabeled 本 skill / 子 skill / 跨 skill wording in references with 本文 / reference names, matching the existing convention.

* feat(sheets): add --styles to +table-put for one-step typed write with styling

+table-put now accepts --styles (same shape as +workbook-create's --styles):
cell_styles merge into the set_cell_range matrix, while cell_merges /
row_sizes / col_sizes apply as their own tool calls after the write. The
styles payload is name-matched against the written sheets and validated up
front, so a malformed or mismatched style fails before any write lands.

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

Adds unit tests (dry-run styles, name-mismatch reject, execute) and a
dry-run E2E (tests/cli_e2e/sheets/sheets_table_put_dryrun_test.go).

* docs(lark-sheets): point read-data to +sheet-info for hidden row/col identification

skip-hidden defaults to false (lossless reads), but the read primitives don't mark which rows/cols are hidden. Cross-reference +sheet-info --include hidden_rows,hidden_cols + row_indices/col_indices so agents can identify hidden ranges when they need to filter or interpret hidden data.

Synced from sheet-skill-spec.

* feat(sheets): document link requirement for @document mentions in cells flag schema

@document mentions (mention_type != 0) must pass link (doc URL) to render a
clickable card; @user mentions (mention_type=0) don't need it. Synced from the
upstream tools-schema.

* fix(sheets): reject cond-format attrs whose shape mismatches rule_type

A conditional-format rule created with --rule-type colorScale but
cellIs-shaped attrs ({compare_type,value}, no color) was accepted by
the CLI and written through to the server, producing a color-less
color-scale segment. That dirty data crashes the frontend on snapshot
deserialization, so the spreadsheet can no longer be opened (5005).

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

Add a cross-field validator (validateCondFormatAttrs) wired into both
create and update via the new objectCRUDSpec.validateCreateInput hook
(twin of validateUpdateInput). It enforces, per rule_type, the keys
every attrs entry must carry — mirroring the tool's converter contract
— and treats an empty required string (notably color) as missing.
Rule types that take no attrs (duplicateValues / uniqueValues /
containsBlanks / notContainsBlanks) and updates that omit rule_type are
left to the server.

* test(sheets): guard condFormatAttrsRequired against flag-schemas drift

Add TestCondFormatAttrsRequired_MatchesSchemaOneOf, comparing the
hand-maintained condFormatAttrsRequired table against the embedded
flag-schemas.json attrs oneOf (multiset of required-key sets, for both
create and update). The cross-field validator only holds if its
per-rule_type required keys mirror the schema branches, and the two
share no compile-time link — this pins them together so a future schema
sync that adds/drops a required key can't silently desync the table.

* fix(sheets): default +table-get to full used range, not A1 current region

+table-get without --range anchored its current_region probe at A1, so an
internal blank row or column silently truncated everything past it — agents
then treated the partial data as complete (the pro016 / pro025 incident).

- Probe the used range over the full physical grid (row_count × column_count
  from the workbook structure) so it spans internal blank rows/columns; fall
  back to the legacy A1 anchor when dimensions are unknown.
- Emit the actually-read `range` on every sheet so callers can detect
  truncation (get_cell_ranges has no has_more flag).
- Fix the same A1-anchor bug in append mode's last-data-row probe, which could
  otherwise overwrite data past an internal blank row.
- Add unit + dry-run/live E2E coverage; refresh synced skill docs.

* docs(sheets): fix csv-get current_region guidance to cross-check row_count

current_region is a blank-row/column-bounded block, not the true sheet extent:
an internal blank row truncates it, so it can miss rows past the gap. The
read-data reference previously called it the "真实数据边界" and told agents to
prefer it over row_count — which drove the "read only to current_region's last
row, miss the tail" failure.

- current_region: warn it can be both smaller (internal blank rows truncate)
  and larger (trailing summary/signature rows) than the real data range.
- csv-get output contract: clarify its row_count/col_count is the returned size
  (= actual_range), not the physical sheet size; has_more only reflects the
  current range, not whether the whole sheet was read.
- "确定数据范围的正确流程": add a step to cross-check against +workbook-info's
  physical row_count and probe past current_region's last row for data beyond an
  internal blank row.

* fix(sheets): collapse duplicate validateCreateInput from bad merge resolution

A prior merge kept both branches' independently-added validateCreateInput
fields on objectCRUDSpec with conflicting signatures (pivot's
func(rt, input) and cond-format's func(input)), plus both call sites in
objectCreateInput, which failed to compile (validateCreateInput redeclared).

Collapse to the single richer func(rt flagView, input) signature and one
call site. cond-format's validateCondFormatAttrs (func(input), still shared
with validateUpdateInput) is wrapped in a closure that ignores rt. Both
behaviors are preserved: pivot --target-position/--range mutex and
cond-format attrs-shape-vs-rule_type validation.

* refactor(sheets): migrate legacy error helpers to typed errs in sheets domain

golangci-lint forbidigo (errs-no-legacy-helper / errs-no-bare-wrap) flagged
the table I/O, workbook, and dataframe shortcuts that landed on this branch:
93 common.FlagErrorf and 48 fmt.Errorf calls.

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

No behavior change: error messages and partial-success shapes are preserved;
gofmt, go vet, golangci-lint (0 issues) and sheets tests all pass.

* fix(shortcuts): clarify single-stdin constraint in flag help and error hint

Input flags advertised '(supports @file, - for stdin)' per flag, leading
AI agents to write '--a - <x --b - <y' where the second '<' silently
clobbers the first and the first flag reads the wrong payload. A process
has a single stdin, so at most one flag per call can use '-'.

- Reword the generated help hint to '- reads stdin (one flag per call;
  use @file for others)'.
- Add an actionable .WithHint to the stdin-conflict validation error
  pointing callers to @file for the extra flags.
- Assert the new hint in TestResolveInputFlags_DuplicateStdin.

* feat(sheets): +cells-get/+csv-get --max-chars 默认值 200000 → 500000

放宽默认防爆上限。flag_defs_gen.go 由 go generate 重生;flag_defs_test.go
的 expected default 同步;flag-schemas.json schema_version 2 → 3 是上游
spec-tables 架构调整带来的元数据 bump,与本业务改动无关、go:embed 不解析
该字段、无功能影响。

Synced from sheet-skill-spec@93f7a78.

* docs(lark-sheets): sync from spec — +csv-put 含逗号公式正例 + 收敛警示标签

源同步自 sheet-skill-spec:write-cells 补含逗号公式 RFC 4180 转义正例与结构化写入优先指引;全 reference 收敛「高频致命错误」类标签。

* docs(lark-sheets): sync from spec — --max-chars 放出为可见 flag + 落盘优先指引

源同步自 sheet-skill-spec:--max-chars 放出(默认 500000,可调小避免大输出被 Bash/终端转存为文件、改 has_more 分页);read-data 增「大数据优先落盘」指引。

* feat(sheets): 写操作报错增强 + --token 别名

- 复合 JSON shape 校验失败时报错附 --print-schema 提示,agent 可直接拿到精确结构(pro26 头号:+cells-set --cells 反复猜 shape)
- JSON 解析失败且该 flag 支持 stdin 时提示改用 stdin(公式/引号/逗号内联到 shell 被转义弄坏 JSON)
- --token 作为 --spreadsheet-token 的解析期别名:复用 sheets 已有 PostMount 钩子 + pflag normalize,仅 sheets 包,common 零改动

* docs(lark-sheets): sync from spec — set+H 改单引号 / 速查表补臆造命令名 / workbook-import 引导

* fix(sheets): migrate +table-put to typed error contract

The merge from main brought in #1449 (retire legacy error envelopes),
which removed output.ExitError / output.ErrDetail and forbids
constructing them. Port tablePutPartial off the legacy envelope:

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

Also fix two drifts the same merge introduced:
- regenerate flag_defs_gen.go to match the committed flag-defs.json
- update the --max-chars flag test to assert visible (no longer hidden)

* docs(lark-sheets): sync from spec — set+H 告诫通则化(移入 stdin 段)

* feat(sheets): styles 接受 halign/valign 等对齐字段别名

把模型常幻觉的 horizontal_align / halign / vertical_align / valign 映射到
规范字段 horizontal_alignment / vertical_alignment,覆盖 --styles 与 typed
--cells;与规范字段冲突时报错而非静默择一。同步 lark-sheets skill 文档补
对齐字段说明 + --print-schema --flag-name styles 提示。

* feat(sheets): resolve wiki URLs to the backing spreadsheet for --url

Sheets shortcuts only accepted /sheets/ and /spreadsheets/ URLs via --url.
A /wiki/<node_token> URL was rejected with "must be a spreadsheet URL"
because the wiki node_token is not a spreadsheet token: resolving it to the
backing spreadsheet needs a wiki get_node call, which Validate/DryRun (kept
network-free) must not make.

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

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

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

Tested: unit (parse classification + wiki get_node e2e via httpmock) and
live end-to-end against a real wiki spreadsheet (read: +workbook-info,
+cells-get, +csv-get; write: +sheet-create, +sheet-rename, +csv-put).

* docs(sheets): note --url accepts wiki URLs (synced from spec)

* fix(sheets): match --url path segment via url.Parse, not substring

parseSpreadsheetRef classified /wiki/ with strings.Index over the whole URL, so a /sheets/ link whose query or fragment merely contained /wiki/ (e.g. .../sheets/sht?from=/wiki/x) was hijacked into a get_node call. Now parse the URL and match /sheets/, /spreadsheets/, /wiki/ only as a path prefix, mirroring slides parsePresentationRef which already fixed this class. Drop the substring helpers. Also align wiki resolution with slides: CallAPITyped (typed error + log_id) and classify an incomplete get_node response as InternalError instead of a --url validation error. Add regression tests for query/fragment /wiki/ and incomplete node.

* fix(sheets): satisfy errorlint/copyloopvar + regen flag defs

- helpers_test.go: drop the Go 1.22+ redundant `tc := tc` loop copy
  (copyloopvar).
- lark_sheet_dataframe.go, lark_sheet_table_io.go: switch the
  intermediate-error fmt.Errorf calls from %v to %w so errorlint passes.
  Behavior unchanged — these errors are always rewrapped into typed
  validation errors at the command layer.
- flag_defs_gen.go: regenerate from data/flag-defs.json (drift from the
  wiki-URL merge).

* ci: allow Apache Arrow module in license check

Arrow is Apache-2.0 overall, but it vendors c-ares (LicenseRef-C-Ares,
ISC-like) inside the module which go-licenses classifies as Unknown and
the strict disallowed_types=...,unknown gate rejects.

Pass --ignore github.com/apache/arrow/go/v17 since Arrow is required by
sheets +table-put / +table-get / +workbook-create --dataframe (Arrow IPC
ingest) and the vendored c-ares is not redistributed by us.

* fix(sheets): resolve wiki URL in +range-move/+range-copy Execute

transformExecuteFn (the named Execute helper shared by +range-move and +range-copy) still called the network-free resolveSpreadsheetToken, so a /wiki/ URL reached transform_range as an unresolved node_token and failed. #1519's sweep over Execute hooks only rewrote inline closures; this is the only Execute backed by a named helper. Switch it to resolveSpreadsheetTokenExec (Validate/DryRun stay network-free) and add a +range-move wiki-URL regression test.

* refactor(sheets): drop +table-put manual capacity grow; rely on set_cell_range auto-grow

set_cell_range now auto-grows the sub-sheet to fit the write, so the
ensureSheetCapacity helper (and its modify_sheet_structure dim-insert
call before each write) is no longer needed. This also closes a data-
safety hole flagged in review: inserting before the last existing row
could push real data down into the area set_cell_range was about to
write, and allow_overwrite=false could not protect against it because
the structural insert had already mutated the sheet by the time the
write-collision check ran.

Verified end-to-end against a real spreadsheet: +table-put writing
300x25 into a fresh Sheet1 (default 200x20) succeeds in one write and
the sheet ends up 301x25.

* fix(sheets): close --dataframe stdin guard hole

--dataframe is binary and bypasses the common Input resolver, which is
where the existing single-stdin guard lives. Result: an invocation like
+table-put --dataframe - --styles - was accepted, then one of the two
consumers raced for stdin and the other silently saw an empty stream.

Add a stdinConsumed marker on RuntimeContext that both consumers share:
common.resolveInputFlags sets it when an Input flag uses '-', and
readDataframeBytes both checks and sets it. A second consumer is
rejected up front with an actionable hint pointing at @file.

Flagged in code review (lark_sheet_dataframe.go:93).

* fix(sheets): harden +table-put / +table-get input validation and round-trip safety

Four review-flagged correctness gaps in table I/O, all bundled because
they touch the same file:

1. --sheets accepted trailing data after the first JSON value
   (json.Decoder does not surface that, unlike json.Unmarshal). A new
   decoderExpectEOF helper rejects e.g. `--sheets '{...} oops'` with a
   typed validation error instead of letting the leading object pass
   through and surface as a confusing downstream failure.

2. +table-get with a duplicate header (e.g. `amount, amount`) used to
   read back successfully — the dtypes map silently collapsed to one
   entry — and only failed later on +table-put because the writer
   rejects duplicate column names. Fail fast at read time with an
   actionable hint to rename or pass --no-header. --no-header mode is
   exempt (fallback col<N> names are always unique).

3. +table-put dry-run rendered an invalid range like A1:C0 when
   header=false with rows=[]. tablePutFullRange returns "" for an
   empty matrix or zero columns instead of building a degenerate
   rectangle.

4. +table-get with --sheet-id and a get_workbook_structure miss (read
   failure or selector mismatch) used to return a target with
   name="", which then broke +table-get → +table-put round-trip (the
   writer requires a non-empty sheet name). Fall back to using the id
   as the name.

End-to-end verified against a real spreadsheet: trailing data, duplicate
header, and --no-header fallback all behave as advertised.

* fix(sheets): apply +workbook-create style-only ops instead of silently dropping them

A +workbook-create call carrying only cell_merges / row_sizes / col_sizes
(no --values / --sheets and no cell_styles) used to create the workbook
but silently drop the requested visual ops. Two reasons, both fixed:

- workbookCreateStyleDimensions only counted cell_styles when computing
  the write extent, so cell_merges / row_sizes / col_sizes always
  contributed 0 → buildValuesPayload returned a nil payload → Execute
  skipped writeTypedSheets entirely → no visual ops ran. Extend the
  helper to fold the merge / resize ranges in.

- Pure row_sizes / col_sizes payloads can never expand a cell rectangle
  (they are dimension ranges, not cell ranges), so even with the extent
  fix Execute would still skip the write path. Add a no-data branch:
  when payload == nil but a styles item is present, look up the default
  sheet and apply visual ops directly via applyWorkbookCreateVisualOps.
  The dry-run plan mirrors this so the preview shows the visual ops.

Also picks up the --values trailing-JSON-data EOF check (mirror of the
--sheets one in lark_sheet_table_io.go).

End-to-end verified against a real spreadsheet: a cell_merges-only
+workbook-create now produces a sheet with merged_cells_count: 1.

* fix(sheets): preserve causes and render messages cleanly for typed validation errors

common.ValidationErrorf goes through fmt.Sprintf, which does not support
%w — the seven call sites that used `%w` were rendering the cause as
literal `%!w(*fmt.wrapError=&{...})` and dropping the cause from the
typed-error chain (so callers couldn't errors.As back to the underlying
error).

Switch each to `%v` for clean rendering and attach the cause via
.WithCause(err) so the typed contract is preserved. Touched call sites:

- lark_sheet_dataframe.go: --dataframe Arrow decode / stdin read / file
  read failures (3 call sites).
- lark_sheet_table_io.go: --sheets invalid JSON, payload-validate
  per-cell coercion error, buildSheetMatrix per-cell error,
  --dataframe-out arrow encode failure (4 call sites).

End-to-end verified against a real spreadsheet: both invalid-JSON and
typed-cell errors now render readable messages instead of %!w(...).

* sync(sheets): pick up +sheet-{show,hide}-gridline in +batch-update schema

Mirror of the sheet-skill-spec change adding the two gridline shortcuts
to cli-schemas.json batch_update.operations.shortcut enum. Synced from
the upstream canonical via generate:cli + sync:cli.

Verified end-to-end on a real spreadsheet — +batch-update with a
+sheet-hide-gridline op passes schema validation and the backend run
returns succeeded: 1.

* sync(sheets): pick up +workbook-export UX clarification from spec

Mirror of the sheet-skill-spec update that documents +workbook-export's
default-no-download behavior and its relationship to drive +export
--doc-type sheet. Synced from canonical via generate:cli + sync:cli +
go generate.

End-to-end verified against a real spreadsheet:
- Omit --output-path → ok:true, downloaded:false, file_token returned
- Pass --output-path ./crfix_test.xlsx → ok:true, file saved
  (17892 bytes), saved_path returned

The --help output for +workbook-export now states the default behavior
and points callers at `drive +export --doc-type sheet` when they need
the --output-dir / --file-name / --overwrite split.

* test(sheets): assert typed errs.Problem instead of err.Error() substrings

Per the coding guideline "Error-path tests must assert typed metadata via
errs.ProblemOf (category / subtype / param) and cause preservation, not
message substrings alone." — sweep through every error-path assertion in
the sheets domain and replace the
`strings.Contains(stdout+stderr+err.Error(), ...)` pattern with two
small helpers landed in helpers_test.go:

  requireProblem(t, err, wantCategory, wantSubtype, msgContains)
    -> *errs.Problem
  requireValidation(t, err, msgContains)
    -> *errs.ValidationError   // shorthand for CategoryValidation +
                               //   SubtypeInvalidArgument; lets callers
                               //   also assert .Param / .Params / .Cause

~60 assertion sites across 18 test files now check the typed envelope
shape, with message-substring checks moved onto the returned Problem
(.Message / .Hint / .Param). The substring is preserved as a sanity
check rather than the sole assertion, so a category drift like
validation → internal would now fail loudly instead of slipping past.

Cases intentionally left as substring (each with a one-line reason):
  - Errors that come straight from cobra's native flag parser (untyped
    *errors.errorString — e.g. "required flag(s) ... not set", mutually-
    exclusive groups). Re-typing these needs a custom FlagErrorFunc and
    is out of scope here.
  - Intermediate errors from decodeArrowToSheet that the caller wraps
    into a typed envelope (`//nolint:forbidigo` reason). Those unit
    tests assert the unwrapped intermediate directly.

One production tweak:
  - shortcuts/sheets/flag_schema.go: printFlagSchemaFor returns typed
    *errs.ValidationError (with WithParam("--flag-name") on the
    unknown-flag branch) instead of raw fmt.Errorf. The framework
    already wraps this when called via --print-schema, so user-facing
    behaviour is unchanged; direct callers (and tests) now get the
    typed envelope.

Verified: go test ./shortcuts/sheets/... passes; golangci-lint
--new-from-rev=origin/main reports 0 issues.

* test(common): assert typed errs.Problem instead of err.Error() substrings

Mirror of the sweep just landed in shortcuts/sheets: replace error-path
substring assertions with typed-envelope checks via two small helpers
landed in a new shortcuts/common/typed_error_assertions_test.go:

  requireProblem(t, err, wantCategory, wantSubtype, msgContains)
    -> *errs.Problem
  requireValidation(t, err, msgContains)
    -> *errs.ValidationError   // shorthand for CategoryValidation +
                               //   SubtypeInvalidArgument; lets callers
                               //   also assert .Param / .Params / .Cause

8 sites moved to typed assertions across runner_jq_test.go,
mcp_client_test.go, drive_media_upload_typed_test.go, and
runner_input_test.go (the input tests already used a typed-param helper;
this just retargets the substring follow-up onto the typed Message).

Sites intentionally left as substring + comment (production returns raw
fmt.Errorf, not a typed envelope):
  - runner_botinfo_test.go (6 sites): BotInfo / fetchBotInfo wrap upstream
    errors with fmt.Errorf so the SDK-level message ([99991], 403,
    invalid character, etc.) shows through.
  - runner_args_test.go (4 sites in 2 tests): rejectPositionalArgs returns
    raw fmt.Errorf to satisfy cobra's PositionalArgs contract.
  - permission_grant_test.go (2 sites): assert on stderr / hint strings,
    not error messages — already out of the err.Error() substring class.

No production code changes.

Verified: go test ./shortcuts/common/... passes;
golangci-lint --new-from-rev=origin/main ./shortcuts/common/... reports
0 issues.

* fix(sheets): plug four +table-put / +table-get correctness gaps flagged in CR

Four review-flagged bugs, all in lark_sheet_table_io.go (bundled because
they touch the same file and the same +table-put / +table-get domain):

1. +table-get --dry-run dropped the --sheet-id / --sheet-name selector
   from the get_cell_ranges body, while Execute always passed it. Agents
   that validate the dry-run shape and then run live would see a request
   shape mismatch. The dry-run now calls sheetSelectorForToolInput so
   the body matches Execute.

2. isDateNumberFormat used a simple `strings.ContainsRune(_, 'y')` so
   number formats like "JPY #,##0" (a currency prefix that happens to
   contain a lone 'Y') were misread as date formats — round-tripping
   integer cells out as ISO dates. The detector is now token-aware:
   it skips quoted "...", `\\x`-escaped, and `[...]` bracket sections,
   and only fires on an unescaped `yy` (a real Excel year token).

3. sheetCreateDims sized new append-mode sheets by `headerOn(s)` only,
   but writeSheetData forces a header on empty append sheets when
   Header == nil. Near 50000 rows / 200 cols this created the sheet one
   row short and the follow-up set_cell_range bounced off the backend
   ceiling. Size now matches the forced-header logic exactly.

4. tableGetTargets fallback paths (read-failure / selector mismatch on
   --sheet-id) returned a target with name="" — already corrected for
   --sheet-id structure-success path in 086876d2, but the structure-
   failure fallback still left it empty. Use the id as the name there
   too so the +table-get → +table-put round-trip never breaks on a
   nameless sheet.

End-to-end verified against a real spreadsheet:
- table-get --dry-run with --sheet-name / --sheet-id both render the
  selector field in the get_cell_ranges body
- A real round-trip (typed put → get) preserves dtypes + formats

* fix(sheets): bound --dataframe memory use with byte / row / column caps

readDataframeBytes used to read the whole Arrow file unbounded — a
stdin / file > 1 GiB would OOM the CLI long before the backend
per-sheet ceilings kicked in. decodeArrowToSheet then materialized
every record into [][]interface{} regardless of size.

Three caps now match the backend's per-sheet hard ceilings:
- byte cap: 256 MiB (covers worst-case 200×50000 cells × ~25 B Arrow
  overhead). File path pre-Stat()s before opening; both file and stdin
  paths read through io.LimitReader so an oversized input is rejected
  without allocating the full payload.
- column cap: 200, checked at schema-decode time before allocating any
  per-column slices.
- row cap: 50000, checked during record-batch iteration so a 1M-row
  Arrow file is rejected mid-stream instead of fully decoding first.

End-to-end verified against PPE — a 257 MiB file is rejected at file-
Stat with a typed validation error before any read happens.

* fix(drive): wrap +export ctx cancellation/deadline as typed errs.NetworkError

The poll loop in RunExport returned ctx.Err() directly in two places —
on the inter-attempt sleep cancel and on the pre-attempt deadline check.
That let context.Canceled / context.DeadlineExceeded escape as untyped
errors at the cobra layer, bypassing the typed-error contract every
other failure path already honors.

Add wrapExportContextErr that maps both into errs.NewNetworkError with
SubtypeNetworkTransport / SubtypeNetworkTimeout respectively and
preserves the cause via .WithCause(err), so callers can still
errors.Is(err, context.Canceled) downstream.

CR-flagged at drive_export.go:229 / :234.

* ci(license): narrow Apache Arrow workaround with a follow-up assertion

The dependency-license check still has to --ignore Apache Arrow wholesale
because go-licenses' classifier parses its LICENSE.txt as a single license
and mis-reports the module as LicenseRef-C-Ares / Unknown (Arrow inlines
the c-ares 3rdparty notice alongside its own Apache-2.0). Re-classifying
on our side isn't possible without changing go-licenses itself.

The CR concern was that --ignore is too wide — a future Arrow re-license
or new inlined dep would silently sail through. Add a follow-up step that
re-checks Arrow's LICENSE.txt independently: it must still open with
"Apache License" AND must still inline the c-ares 3rdparty notice (the
two facts that make the --ignore safe today). If either invariant breaks,
CI fails here and forces a human to re-evaluate the ignore.

Verified locally — both assertions pass against the current pinned
Arrow v17.

* sync(sheets): pick up +table-put payload-shape doc corrections from spec

Mirror of the sheet-skill-spec change that fixes three places teaching
an invalid +table-put payload shape — the typed protocol only has
columns / data / dtypes / formats (no formula field) and must always
be wrapped in an outer {"sheets":[...]} envelope. write-cells and the
SKILL.md decision table previously used the wrong field names (type /
format) and pointed users at +table-put for formula writes, which the
shortcut can't actually accept.

Synced from upstream canonical via generate:cli + sync:cli.

* test(sheets/e2e): add E2E coverage for new shortcuts + typed workbook-create

AGENTS.md requires a dry-run E2E for every new shortcut and a live E2E
for new flows. Three new files cover the four shortcuts this branch
adds or materially changes:

- sheets_gridline_dryrun_test.go — pins +sheet-show-gridline /
  +sheet-hide-gridline as a single modify_workbook_structure call with
  the right operation name (show_gridline / hide_gridline) and
  sheet_id, so an op-name typo would trip CI before any live run.

- sheets_workbook_import_dryrun_test.go — pins +workbook-import as a
  two-step plan (drive media upload + drive import-task create) with
  the doc type hard-coded to "sheet" — the wrapper's whole reason for
  existing on top of generic drive +import. --name reaches file_name
  on the wire; file_extension is sniffed from the local file.

- sheets_table_put_typed_workflow_test.go — two live workflows running
  against a freshly created spreadsheet. The first runs the full
  typed +table-put → +table-get round-trip (date / numeric / object
  columns with custom number_format) and asserts the dtype + format
  contract holds end-to-end. The second exercises the typed
  +workbook-create --sheets path: create + write in one shortcut, the
  payload sheet name adopts the workbook's default sheet (no empty
  "Sheet1" left behind), and the typed contract still survives the
  read-back.

End-to-end verified locally (user identity): typed put round-trips
preserve dtypes (date → datetime64[ns], numeric → float64, object →
object) + formats verbatim; workbook-create adopts the named sheet as
the first sheet with the same typed shape intact.

* sync(sheets): pick up sheets_df.py — pandas ↔ JSON skill script from spec

Mirror of the sheet-skill-spec change that adds a DataFrame ↔ JSON
bridge as a skill-bundled Python script instead of inside the CLI
binary. Per PR #1355 review (docx NcmxdRo2yoZ4OXxoMUZcxRZ7nHd, §4.2):
keep the CLI a thin JSON/REST client; pandas / Arrow editing lives in
the caller's Python process. Synced from canonical via generate:cli +
sync:cli.

- skills/lark-sheets/scripts/sheets_df.py (new): pandas DataFrame ↔
  one sheet, .parquet / .feather / .arrow / .csv / .json. Shells out to
  `+table-put` / `+table-get` over typed JSON — no CLI changes.
- SKILL.md decision tree + write-cells.md +table-put section: explicit
  pointers so pandas users land on the script instead of hand-rolling
  the `--sheets` payload.

End-to-end verified against PPE: 3-row DataFrame (datetime / float /
object) round-trips parquet → script put → real sheet → script get →
parquet with dtypes preserved.

* Revert "sync(sheets): pick up sheets_df.py — pandas ↔ JSON skill script from spec"

This reverts commit 2964983b92.

* sync(sheets): pick up sheets_df.py + doc DRY cleanup from spec

Mirror of the sheet-skill-spec change that ships a 32-line helper-only
sheets_df.py (df_to_sheet + sheet_to_df) and removes the corresponding
inline `def` blocks from three reference docs.

- skills/lark-sheets/scripts/sheets_df.py (new): pandas DataFrame ↔
  one +table-put / +table-get sheet, importable as a library. Same
  helper pair the docs already taught, lifted out of the prose so
  callers can `from sheets_df import df_to_sheet, sheet_to_df`.
- lark-sheets-write-cells.md / lark-sheets-read-data.md /
  lark-sheets-workbook.md: drop the inline helper definitions; keep
  the usage examples (single/multi-sheet, round-trip) and switch them
  to import-from-script. workbook reference's +workbook-create
  --sheets section now points pandas users at the helper directly
  (was previously a textual reference back to write-cells).

End-to-end verified against PPE (--as user):
- +workbook-create with df_to_sheet for three sheets (income / balance
  / cashflow): create ok, dtypes (datetime64[ns] / float64) + formats
  (#,##0 / 0.0% / yyyy-mm-dd) survive on read-back through sheet_to_df.
- read → pandas mutate → write-back round-trip preserves both data
  and formats.

* chore: drop accidentally-committed __pycache__/ and gitignore .pyc

The previous commit (5fac9c39) shipped sheets_df.py and inadvertently
included its `__pycache__/sheets_df.cpython-312.pyc` — local Python
import created the bytecode cache during PPE round-trip verification and
`git add skills/lark-sheets/` swept it in.

Remove the pyc and add Python bytecode patterns to .gitignore so the
skill-bundled helper scripts don't pull cache files into future commits.

* refactor(sheets): drop --dataframe / --dataframe-out + apache/arrow dep

Per the design review at NcmxdRo2yoZ4OXxoMUZcxRZ7nHd, the Arrow IPC binary
input/output channel adds a heavy columnar runtime to the CLI for no new
capability — the typed JSON --sheets path already covers everything, and
the column-major / zero-copy advantages collapse the moment the CLI re-
encodes into the row-oriented sheets OpenAPI JSON body. Removing it also
lets us drop the `--ignore github.com/apache/arrow/go/v17` license-check
escape hatch.

Deleted:
- shortcuts/sheets/lark_sheet_dataframe.go (+ test)
- --dataframe branches in +table-put / +workbook-create
- --dataframe-out branch in +table-get
- StdinConsumed / MarkStdinConsumed exported methods (the binary stdin
  reader was the only out-of-band consumer); internal stdinConsumed
  guard against duplicate `-` input flags stays
- apache/arrow/go/v17 + transitive deps via `go mod tidy`
- CI go-licenses --ignore for arrow and the LICENSE.txt assertion step
- --dataframe / --dataframe-out coverage in skill references

Pandas users keep the round-trip via the existing skill script
skills/lark-sheets/scripts/sheets_df.py over the JSON path.

The full pre-removal state is preserved on branch feat/sheets-arrow-stash.

Upstream sheet-skill-spec follow-up: the two flag rows in the canonical
spec + base table tblV2F6fqIjyCFQW must also be dropped so the next sync
does not re-add them.

* sync(sheets): pick up --sheets one-liner fix from spec

Mirrors sheet-skill-spec 5562f83. The +table-put / +workbook-create
--sheets flag descriptions (and the --print-schema description on the
sheets array) now point at the existing df_to_sheet helper instead of
the previous misleading one-liner that produced a dict missing the
outer {"sheets":[...]} envelope and the per-sheet `name`. Agents that
copy-paste the description verbatim now build a valid payload.

Auto-synced via spec's generate:cli + sync:consumers; go generate
./shortcuts/sheets/... regenerated flag_defs_gen.go so its embedded
flagDefs stays byte-equal to data/flag-defs.json.

* test(sheets/e2e): close E2E coverage gaps for newly added shortcuts

AGENTS.md requires both dry-run and live E2E for every newly registered
shortcut, and behavior-changing refactors need at least the matching
half. Three gaps remained on feat/lark-sheets-develop:

- +sheet-show-gridline / +sheet-hide-gridline (new): only dry-run E2E.
  Add sheets_gridline_workflow_test.go — create a real spreadsheet,
  toggle hide then show against a live sub-sheet, assert ok=true on
  both (gridline state is write-only — there is no read-back field on
  +sheet-info / +workbook-info — so a successful envelope is the
  meaningful signal; the dry-run E2E already pins the wire shape).

- +workbook-import (new): only dry-run E2E. Add
  sheets_workbook_import_workflow_test.go — write a local CSV, run
  the full upload → create-task → poll, assert ready=true with a
  sheet token, +info confirms the imported workbook is reachable,
  cleanup deletes the spreadsheet.

- +workbook-export refactor (no-download default changed): had live
  E2E but no dry-run E2E in tests/cli_e2e/. Add
  sheets_workbook_export_dryrun_test.go — pin the three sheet-
  specific differences vs drive +export: type=sheet hard-coded,
  csv mode routes --sheet-id onto sub_id (xlsx mode omits it), and
  --output-path maps onto the dry-run plan's top-level output_dir.
  Also pins the csv-without-sheet-id validation error.

* refactor(sheets): unify workbookCreatedButFillFailed with OutPartialFailure

Three "made it halfway and stopped" exits in the sheets domain previously
disagreed on shape, which made the post-failure recovery flow hard for
agents to predict from one command to another:

- +table-put partial write           → exit 1, stdout ok:false envelope
- +table-put zero-sheet write        → exit 1, stderr api/server_error
- +workbook-create create-but-fill   → exit 2, stderr validation/failed_precondition

OutPartialFailure exists exactly for "the side effect landed but the
follow-up didn't" — it stamps an ok:false result envelope on stdout
(carrying the state the caller needs to recover) and returns the bare
partial-failure exit signal. The workbook-create fill-failure path was
the odd one out: it surfaced as a typed failed_precondition error on
stderr, which agents couldn't tell apart from a plain validation refusal
even though the spreadsheet really did exist and a retry / cleanup was
possible.

Migrate workbookCreatedButFillFailed onto OutPartialFailure so the four
call sites in +workbook-create's Execute (sheet-resolve failure, initial
fill failure, style-only resolve failure, style-only apply failure) emit
the same envelope shape +table-put's partial write does:

  {
    "ok": false,
    "data": {
      "spreadsheet_token": "shtNEW",
      "reason": "spreadsheet shtNEW created but initial fill failed",
      "hint":   "the spreadsheet exists; retry the fill … or delete it",
      "cause":  {"category": "...", "subtype": "...", "message": "..."}
    }
  }

The inner failure's typed problem (category / subtype / message) is
flattened into the `cause` field so agents stay diagnosable from the JSON
envelope alone, instead of having to errors.Unwrap a Go error.

Updated TestExecute_WorkbookCreate_FillFailureKeepsToken to assert the
new shape (ok:false envelope on stdout, *output.PartialFailureError exit
signal, structured cause carrying the underlying invalid_response
subtype) — preserving the original test intent (token must survive for
recovery; inner cause must stay diagnosable) under the new contract.

* chore(sheets): three review nits — WithCause + stale comment + unexport

- shortcuts/sheets/flag_schema_validate.go:106 — composite-JSON shape
  validation was wrapping vErr's message into a typed sheets validation
  error without preserving vErr as the typed cause; add the missing
  .WithCause(vErr) so errors.Unwrap and ProblemOf still find the
  underlying validator error (matches every other typed-error chain
  helper in the file).

- shortcuts/sheets/lark_sheet_batch_update.go:92 — comment claimed
  batchUpdateInput returns "FlagErrorf-typed errors", but FlagErrorf no
  longer exists (the typed-error migration replaced it with
  common.ValidationErrorf / errs.ValidationError); update the comment
  to reflect what is actually returned.

- shortcuts/drive/drive_export.go:121 — drop the ValidateExport public
  alias and rename to validateExport. sheets +workbook-export reuses
  RunExport / PlanExportDryRun from this package but inlines its own
  (sheet-specific) Validate, so there is no cross-package call site —
  ValidateExport was a misleading sibling of the genuinely-shared
  ValidateImport. Comment added to record the asymmetry so future
  readers do not export it back.

* chore(deps): drop stale indirect bumps left by the arrow removal

The earlier --dataframe / --dataframe-out + apache/arrow/go/v17 removal
deleted the arrow consumer but left two indirect lines in go.mod pinned
to the versions arrow had pulled in:

  - github.com/kr/text                   v0.2.0
  - golang.org/x/exp  v0.0.0-20240222234643-814bf88cf225

With arrow gone, larksuite/cli was the only requirer of those exact
versions; every real consumer needs lower ones (kr/pretty wants
kr/text v0.1.0; charmbracelet/huh wants x/exp …20231006; xo/terminfo
wants x/exp …20220909). Removing the two indirect lines and running
`go mod tidy` lets MVS pick the real-consumer versions and drops the
explicit indirect entries entirely — go.mod net-diff against main is
now zero for this branch.

Verified locally: go build ./...; go test ./shortcuts/sheets/...
./shortcuts/drive/... ./shortcuts/common/... ./internal/auth/...
./cmd/auth/... — all green.

---------

Co-authored-by: zhengzhijie <zhengzhijie.j@bytedance.com>
Co-authored-by: Chenweifeng-bd <chenweifeng.1534@bytedance.com>
2026-06-25 10:48:13 +08:00
ZEden0
898e6d4b3b fix: prefix docs resource shortcuts (#1564) 2026-06-24 22:39:57 +08:00
zgz2048
7df37ed715 feat(base): Add Base URL and title resolve shortcuts (#1338)
* feat(base): add URL and title resolve shortcuts

* docs: clarify base coordinate resolution

* fix(base): address resolve shortcut ci

* fix(base): format resolved record share hint

* fix(base): simplify record share hint data

* fix(base): use field ids in resolved record data

* fix(base): guide record share resolve to update record

* fix(base): include record upsert example in resolve hint

* fix(base): reject add-record urls in resolver

* fix(base): validate title resolve query length

* fix(base): hide resolve alias flags from help

* fix(base): prefer title flag for title resolve

* docs(base): clarify token resolution wording
2026-06-24 22:26:29 +08:00
91-enjoy
3f9ace8af5 feat: support card action trigger (#1528)
Add support for card.action.trigger, the event fired when a user interacts with an
interactive card (button click, form submit, dropdown, checkbox, input, date picker, etc.).
The handler flattens the V2 envelope into a structured output and auto-fetches the original
card content (card_content) at consume time, enabling a complete read-then-update workflow
without extra API calls.
2026-06-24 22:00:08 +08:00
Zhang-986
b3514e5519 fix(binding): skip unix mode audit on windows (#1525) 2026-06-24 20:54:24 +08:00
ILUO
b46e60c156 feat: add task event consumer (#1510)
* feat: add task event consumer

* fix: address task event review feedback

* feat: remove legacy task event subscription shortcut

* test: strengthen task preconsume error assertions
2026-06-24 17:32:02 +08:00
chenxingtong-bytedance
d71bab0061 docs(im): clarify audio message opus requirement (#1271)
Fix IM shortcut behavior for audio messages to match the Feishu/Lark file upload API: --audio is for voice messages and supports only Opus audio. Non-Opus local/URL inputs such as mp3 and wav are now rejected before upload with an actionable typed validation error. Users can still send those files as attachments with --file
2026-06-24 14:41:54 +08:00
liangshuo-1
d11a6e97a4 chore: release v1.0.57 (#1553) 2026-06-23 20:43:41 +08:00
raistlin042
e4248d1154 fix: harden lark-apps +init/+html-publish and skill guidance (#1517)
* fix: reject +init into a different app's project directory

* fix: reject single HTML files larger than 10MB in +html-publish

* docs: clarify publish visibility, domain routing, and role/permission boundary
2026-06-23 20:18:10 +08:00
fangshuyu-768
cb54bea00d docs(lark-doc): refine rich block, path, and block ID guidance (#1508) 2026-06-23 18:27:36 +08:00
hanshaoshuai
036e5799d3 fix(ci): bind semantic review to workflow run head 2026-06-23 18:21:29 +08:00
xukuncx
c4106f50b2 fix(mail): resolve folder/label filter once per +triage list call (#1512)
buildListParams used to re-call resolveFolderID / resolveFolderName (and the
label counterparts) on every list page to assemble folder_id / label_id.
Because resolveListFilter already resolves the filter once before the
pagination loop, the second pass hit the folders/labels list API again on
every page — 1 + page_count calls total, which easily trips rate limits.

buildListParams now only assembles API params from the already-resolved
FolderID / LabelID produced by resolveListFilter; it no longer resolves
names or aliases. The default folder_id=INBOX is still applied when no
explicit filter is present, and only overridden when the caller supplied a
canonical folder ID. The runtime / mailboxID / dryRun parameters are kept
for signature stability (resolveListFilter and buildSearchParams share the
same call shape).

Adds TestMailTriageCustomFolderResolvesOnceAcrossListPages: a custom-folder
filter forced across two messages-list pages, with a non-reusable folders
list stub so any second folders API call fails the test. Updated the two
existing buildListParams alias tests to run resolveListFilter first, mirroring
the real DryRun/Execute call order.

sprint: S1

Co-authored-by: xukuncx <283114605+xukuncx@users.noreply.github.com>
2026-06-23 18:05:08 +08:00
liangshuo-1
736b131cdf fix(meta): backfill enum value descriptions from options (#1541) 2026-06-23 16:14:42 +08:00
arnold9672
5efaf65aec feat: surface search API notices (#1413)
* feat: surface search API notices

sa: safe
doc: none
cfg: none
test: unit test

* fix: surface search notices in default output

* docs: add search notice doc comments

* docs: expand search notice doc comments
2026-06-23 14:27:04 +08:00
linchao5102
0991da7446 fix: add missing CLI headers for git credential helper (#1539) 2026-06-23 14:25:26 +08:00
zgz2048
80bea45c6a feat: support base record comments (#1043)
* feat: support base record comments

* fix: tighten base comment validation

* fix: validate wiki base comment flags
2026-06-23 11:20:07 +08:00
bubbmon233
c775cb4360 docs(mail): trim lark-mail skill context (#1527) 2026-06-22 21:32:31 +08:00
jiangguozhou
824aa9edf8 docs: add lark-drive permission governance workflow (#1292)
Change-Id: Ib62bd439669fec3e9d5589d1fbe266d3aef964a8
2026-06-22 14:20:02 +08:00
zhanghuanxu
9d4ae94394 feat(slides):slide screenshot 2026-06-22 13:20:39 +08:00
liangshuo-1
bba13cfe0f chore: release v1.0.56 (#1518) 2026-06-18 18:53:21 +08:00
liujiashu-shiro
815cdb8f1c feat(im/convert): support content_v2 blocks in post message conversion (#1411)
Support content_v2 post message conversion in IM shortcuts so newer post payloads render with the expected markdown, mention, and image formats while preserving fallback compatibility with legacy content.
2026-06-18 17:53:22 +08:00
liangshuo-1
4f3ae0c71a fix: pin fetch_meta.py output to utf-8 encoding (#1516) 2026-06-18 17:18:45 +08:00
91-enjoy
96d70143c5 feat: support message recieve event card format (#1480)
Previously, im.message.receive events with message_type: interactive surfaced the raw JSON
payload as content, requiring callers to manually parse the card schema. This PR introduces a
user_dsl renderer (ConvertInteractiveEventContent) that converts interactive card content into
structured human-readable text — consistent with how text, post, image, and other message
types are already handled.

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

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

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

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

Change-Id: I92b658e0cc42ffa8cbdaec2ec628a079bcfc38f5

* fix: skill simplify & minor fix

Change-Id: I3f27a01cdae2122f26e48ee2acb7f334f2bab7d2

* fix: CR issue

Change-Id: Id9fab84e06f0d67e9f79c1fb9946b6b633200592

* fix: CR issue 2

Change-Id: I6a5e57dd4b10dc79f8681ec614354fbba82abc04

* fix: error handle of +weight shortcut

Change-Id: I6e2a39269e62e3b504e681110843b2ccc315a527
2026-06-18 16:25:23 +08:00
hanshaoshuai
1f2164c7c2 fix: trim semantic review input for broad changes 2026-06-17 20:15:04 +08:00
raistlin042
76f5419a0d feat: add +session-messages-list for session turn reply messages (#1402)
* feat(apps): add +message-get to fetch session turn reply messages

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

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

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

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

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

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

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

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

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

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

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

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

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

* test: align cli_e2e success assertions to the ok envelope

The api and service success path now emits the {"ok":true} envelope, so the
cli_e2e workflow assertions that still expected the old {"code":0} shape via
AssertStdoutStatus(t, 0) fail once they run with live credentials. Switch those
workflow assertions to AssertStdoutStatus(t, true); the fake-payload helper test
in core_test.go keeps its code-shape assertion.
2026-06-17 19:42:38 +08:00
fangshuyu-768
d687a76c79 feat: soften lark doc style guidance (#1463) 2026-06-17 19:16:02 +08:00
guokexin.02
4a4c3344c8 fix: align api success envelopes (#1489) 2026-06-17 17:41:48 +08:00
hanshaoshuai
c61acb5264 feat: add ci quality gate 2026-06-17 16:29:33 +08:00
zgz2048
7eeb111a2d fix: reject out-of-range base pagination flags (#1495) 2026-06-17 15:41:59 +08:00
liangshuo-1
714da970d0 chore(release): v1.0.55 (#1490) 2026-06-16 22:26:40 +08:00
sang-neo03
ed7fdd1a27 feat: optimize event subscription precheck, links, and consumer guard (#1447)
* feat: add SubscriptionType and SingleConsumer to EventKey definition

* feat: fetch subscribed callbacks from application/get

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

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

* feat: add reject fields to hello_ack protocol message

* feat: add exclusive registration to event bus hub

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

* feat: surface bus consumer rejection as failed_precondition error

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

* fix: report missing callbacks when console has none subscribed

* feat: bound exclusive consumer cleanup wait with configurable timeout

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

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

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

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

* refactor: simplify SubscriptionType normalization to match validateAuth style
2026-06-16 19:41:52 +08:00
wangweiming-01
4464ba7660 fix: validate drive import folder target (#1485)
Change-Id: I43755c3966b0daa06b708d2b3d03294f439547fa
2026-06-16 18:14:08 +08:00
zhicong666-bytedance
bb03c8ac4d feat(vc): support agent meeting event workflows (#1483)
* feat: support vc agent active meetings

* docs: clarify vc agent active meeting flow

* fix: align active meeting shortcut scope

* docs: clarify active meeting id fields

* fix: reject meeting numbers for vc events

* docs: clarify vc agent active meeting flow

* docs: refine vc agent meeting flow guidance

* docs: address vc agent skill review feedback

* docs: clarify vc meeting product wording

* docs: align vc agent skill with quality guidelines

* docs: trim vc agent skill token budget

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

This reverts commit 8560bb9c19.
2026-06-16 18:08:07 +08:00
yballul-bytedance
3feb70b32a feat(drive): 支持导出 Base 结构快照 (#1481)
1. 为 drive +export 增加 --only-schema 参数,并透传 only_schema 到导出任务请求。
2. 限制该参数仅用于 bitable 导出 .base,并补充单测与 dry-run E2E 覆盖。

Change-Id: I736cebf5841cc1c6acaa8a3ab16be51ba4cb355d
2026-06-16 16:36:31 +08:00
ZEden0
64b1b3f3ed feat(docs): support lang for fetch v2 (#1459) 2026-06-16 16:25:36 +08:00
ZEden0
a0e83c7e59 feat(docs): add docx cover resource commands (#1468)
Spec source: active@bd186a6373948acc76d8b0872334b1a53ad40f5645b1a4e129937d7a51f5596c
2026-06-16 15:25:37 +08:00
liangshuo-1
297b2a222e chore(release): v1.0.54 (#1476) 2026-06-15 21:58:07 +08:00
Zhang-986
80a5f30f4d fix(event): clarify remote bus blocker recovery (#1454) 2026-06-15 20:27:59 +08:00
xzcong0820
cf35d1e499 feat(mail): auto-attach default signature on send/reply/forward (#1415)
* feat(mail): auto-attach default signature on send/reply/forward

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

---------

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

fg: none

cfg: none

doc: none

test: ppe
Change-Id: I88bedd02a5daa3307b05c9b6f94748e1544d279a
2026-06-15 14:18:45 +08:00
sammi-bytedance
37f4f899b2 docs(lark-im): document @mention format per message type (text/post/card) (#1419)
Split the send/reply @Mention sections by message type:
- text: <at user_id="ou_xxx">name</at> (inner name optional), @all
- post: inline form in text/md elements, or a dedicated {"tag":"at"} node
- interactive card: card-native <at id=>, <at ids=>, <at email=>
2026-06-15 14:08:50 +08:00
713 changed files with 68752 additions and 9140 deletions

View File

@@ -5,13 +5,12 @@ on:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
permissions:
contents: read
actions: read
checks: write
pull-requests: write
jobs:
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
@@ -72,6 +71,7 @@ jobs:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
@@ -80,10 +80,84 @@ jobs:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Resolve changed-from baseline
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
- name: Run errs/ lint guards (lintcheck)
run: go run -C lint . ..
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
script-test:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Run script tests
run: make script-test
deterministic-gate:
needs: fast-gate
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Resolve changed-from baseline
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Write public content metadata
if: ${{ github.event_name == 'pull_request' }}
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_BRANCH: ${{ github.head_ref }}
run: |
mkdir -p .tmp/quality-gate
python3 - <<'PY'
import json
import os
with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f:
json.dump({
"title": os.environ.get("PR_TITLE", ""),
"body": os.environ.get("PR_BODY", ""),
"branch": os.environ.get("PR_BRANCH", ""),
}, f)
f.write("\n")
PY
- name: Run CLI deterministic gate
run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
- name: Upload quality gate facts
if: ${{ always() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: quality-gate-facts-${{ github.event.pull_request.base.sha }}-${{ github.event.pull_request.head.sha }}
path: .tmp/quality-gate/facts.json
if-no-files-found: error
retention-days: 7
coverage:
needs: fast-gate
@@ -103,6 +177,7 @@ jobs:
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
with:
files: coverage.txt
@@ -184,7 +259,7 @@ jobs:
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint]
needs: [unit-test, lint, script-test, deterministic-gate]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
@@ -205,9 +280,12 @@ jobs:
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint]
needs: [unit-test, lint, script-test, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
@@ -254,6 +332,9 @@ jobs:
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
security:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -291,7 +372,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -303,6 +384,8 @@ jobs:
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
@@ -318,6 +401,8 @@ jobs:
"${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.script-test.result }}" \
"${{ needs.deterministic-gate.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \
"${{ needs.e2e-dry-run.result }}" \

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

@@ -0,0 +1,28 @@
name: Comment Audit
on:
issue_comment:
types: [created, edited]
pull_request_review:
types: [submitted, edited]
pull_request_review_comment:
types: [created, edited]
permissions:
contents: read
jobs:
public-content-comment-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Post-publication comment audit
run: |
mkdir -p .tmp/comment-audit
cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json
go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME"

611
.github/workflows/semantic-review.yml vendored Normal file
View File

@@ -0,0 +1,611 @@
name: Semantic Review
on:
workflow_run:
workflows: ["CI"]
types: [completed]
permissions:
actions: read
contents: read
jobs:
pr-quality-summary:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
issues: write
pull-requests: write
steps:
- name: Verify workflow run and pull request for summary
id: pr
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
if (runPRs.length > 1) {
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
const eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
}
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
let factsArtifactName = "";
let artifactBaseSha = "";
let artifactError = "";
if (factsArtifacts.length !== 1) {
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
} else {
factsArtifactName = factsArtifacts[0].name;
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
}
}
}
if (!prNumber) {
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
} else {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.state !== "open") {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
if (pr.head.sha !== targetHeadSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
return;
}
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
core.setOutput("stale", "true");
return;
}
if (artifactError) {
core.warning(`quality gate facts artifact binding is unavailable: ${artifactError}`);
}
core.setOutput("pr_number", String(prNumber));
core.setOutput("head_sha", targetHeadSha);
core.setOutput("base_sha", baseSha);
core.setOutput("run_id", String(run.id));
core.setOutput("facts_artifact_name", factsArtifactName);
core.setOutput("artifact_error", artifactError);
core.setOutput("stale", "false");
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
id: checkout
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
ref: ${{ steps.pr.outputs.base_sha }}
persist-credentials: false
- name: Verify summary facts artifact metadata
id: artifact
if: ${{ steps.pr.outputs.stale != 'true' && steps.pr.outputs.facts_artifact_name != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
const artifact = artifacts[0];
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
}
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
core.setOutput("artifact_id", String(artifact.id));
core.setOutput("artifact_digest", artifact.digest);
- name: Download facts artifact zip
if: ${{ steps.pr.outputs.stale != 'true' && steps.artifact.outputs.artifact_id != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
id: download
with:
script: |
const fs = require("fs");
const path = require("path");
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
const { data } = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifactId,
archive_format: "zip",
});
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
fs.writeFileSync(zipPath, Buffer.from(data));
core.setOutput("zip_path", zipPath);
- name: Verify and extract summary facts artifact
if: ${{ steps.pr.outputs.stale != 'true' && steps.download.outputs.zip_path != '' }}
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_DECISION_OUT: decision.json
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
- name: Publish PR quality summary
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
CI_QUALITY_SUMMARY_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
CI_QUALITY_SUMMARY_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
CI_QUALITY_SUMMARY_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
CI_QUALITY_SUMMARY_RUN_ID: ${{ steps.pr.outputs.run_id }}
CI_QUALITY_SUMMARY_ARTIFACT_ERROR: ${{ steps.pr.outputs.artifact_error }}
with:
script: |
const { publish } = require("./scripts/ci-quality-summary-publish.js");
await publish({ github, context, core });
semantic-review:
needs: pr-quality-summary
if: always() && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
checks: write
contents: read
issues: write
pull-requests: write
steps:
- name: Verify workflow run and pull request
id: pr
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
if (runPRs.length > 1) {
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
const eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
}
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
let factsArtifactName = "";
let artifactBaseSha = "";
let artifactError = "";
if (factsArtifacts.length !== 1) {
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
} else {
factsArtifactName = factsArtifacts[0].name;
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
}
}
}
if (!prNumber) {
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
} else {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.state !== "open") {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
if (!pr.head.repo) {
core.notice("semantic review skipped: workflow_run target PR head repository is unavailable");
core.setOutput("stale", "true");
return;
}
if (pr.head.sha !== targetHeadSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
return;
}
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR base");
core.setOutput("stale", "true");
return;
}
if (artifactError) {
core.warning(`semantic review facts artifact binding is unavailable: ${artifactError}`);
}
core.setOutput("pr_number", String(prNumber));
core.setOutput("head_sha", targetHeadSha);
core.setOutput("base_sha", baseSha);
core.setOutput("head_owner", pr.head.repo.owner.login);
core.setOutput("head_repo", pr.head.repo.name);
core.setOutput("head_repo_id", String(pr.head.repo.id));
core.setOutput("head_is_base_repo", pr.head.repo.id === context.payload.repository.id ? "true" : "false");
core.setOutput("run_id", String(run.id));
core.setOutput("facts_artifact_name", factsArtifactName);
core.setOutput("artifact_error", artifactError);
core.setOutput("stale", "false");
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
id: checkout
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
ref: ${{ steps.pr.outputs.base_sha }}
persist-credentials: false
- name: Publish pre-checkout semantic review failure
if: ${{ failure() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome != 'success' && steps.pr.outputs.head_sha != '' && steps.pr.outputs.pr_number != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
with:
script: |
const runtimeBlockMode = process.env.SEMANTIC_REVIEW_BLOCK === "true";
const pr = Number(process.env.SEMANTIC_REVIEW_PR_NUMBER || 0);
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
const baseSha = process.env.SEMANTIC_REVIEW_BASE_SHA || "";
if (!Number.isInteger(pr) || pr <= 0 || !/^[a-f0-9]{40}$/i.test(headSha) || !/^[a-f0-9]{40}$/i.test(baseSha)) {
throw new Error("missing verified semantic review target");
}
const { data: pull } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr,
});
if (pull.state !== "open") {
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
return;
}
if (pull.head.sha !== headSha) {
core.notice("semantic review skipped infrastructure failure check: PR head changed");
return;
}
if (pull.base.sha !== baseSha) {
core.notice("semantic review skipped infrastructure failure check: PR base changed");
return;
}
if (pull.base.repo.id !== context.payload.repository.id) {
throw new Error("PR base repo mismatch before infrastructure failure check");
}
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: runtimeBlockMode ? "semantic-review/result" : "semantic-review/observe",
head_sha: headSha,
status: "completed",
conclusion: runtimeBlockMode ? "failure" : "neutral",
output: {
title: "Semantic review infrastructure failure",
summary: "Semantic review could not checkout the verified base commit. Inspect the workflow logs before relying on semantic review output.",
},
});
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
go-version-file: go.mod
- name: Verify semantic facts artifact metadata
id: artifact
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
if (!/^quality-gate-facts-[a-f0-9]{40}-[a-f0-9]{40}$/i.test(factsArtifactName)) {
throw new Error("missing verified facts artifact binding");
}
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
const artifact = artifacts[0];
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
}
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
core.setOutput("artifact_id", String(artifact.id));
core.setOutput("artifact_digest", artifact.digest);
- name: Download facts artifact zip
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
id: download
with:
script: |
const fs = require("fs");
const path = require("path");
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
const { data } = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifactId,
archive_format: "zip",
});
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
fs.writeFileSync(zipPath, Buffer.from(data));
core.setOutput("zip_path", zipPath);
- name: Verify and extract semantic facts artifact
if: ${{ steps.pr.outputs.stale != 'true' }}
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_DECISION_OUT: decision.json
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
- name: Download PR semantic waiver config
id: waiver_config
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_HEAD_OWNER: ${{ steps.pr.outputs.head_owner }}
SEMANTIC_REVIEW_HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
SEMANTIC_REVIEW_HEAD_IS_BASE_REPO: ${{ steps.pr.outputs.head_is_base_repo }}
with:
script: |
const fs = require("fs");
const path = require("path");
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
if (!/^[a-f0-9]{40}$/i.test(headSha)) {
throw new Error("missing verified semantic review target");
}
const headOwner = process.env.SEMANTIC_REVIEW_HEAD_OWNER || "";
const headRepo = process.env.SEMANTIC_REVIEW_HEAD_REPO || "";
if (!headOwner || !headRepo) {
throw new Error("missing verified semantic review head repository");
}
const waiverPath = "internal/qualitygate/config/semantic/waivers.txt";
const outPath = path.join(process.env.RUNNER_TEMP, "semantic-review-waivers.txt");
const headIsBaseRepo = process.env.SEMANTIC_REVIEW_HEAD_IS_BASE_REPO === "true";
if (!headIsBaseRepo) {
core.notice("fork PR semantic waiver config is ignored");
core.setOutput("path", "");
return;
}
let content = "";
try {
const { data } = await github.rest.repos.getContent({
owner: headOwner,
repo: headRepo,
path: waiverPath,
ref: headSha,
});
if (Array.isArray(data) || data.type !== "file" || data.encoding !== "base64") {
throw new Error(`${waiverPath} is not a base64 file at PR head`);
}
if (data.size > 256 * 1024) {
throw new Error(`${waiverPath} is too large: ${data.size} bytes`);
}
content = Buffer.from(data.content, "base64").toString("utf8");
} catch (err) {
if (err.status !== 404) {
throw err;
}
}
fs.writeFileSync(outPath, content);
core.setOutput("path", outPath);
- name: Run semantic review
id: semantic
if: ${{ steps.pr.outputs.stale != 'true' }}
env:
ARK_API_KEY: ${{ secrets.ARK_API_KEY }}
ARK_BASE_URL: ${{ vars.ARK_BASE_URL }}
ARK_MODEL: ${{ vars.ARK_MODEL }}
ARK_TIMEOUT_SECONDS: ${{ vars.ARK_TIMEOUT_SECONDS }}
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
run: |
args=(
--repo .
--facts facts.json
--decision-out decision.json
--markdown-out semantic-review.md
)
if [ -n "${{ steps.waiver_config.outputs.path }}" ]; then
args+=(--waivers-file '${{ steps.waiver_config.outputs.path }}')
fi
if [ "$SEMANTIC_REVIEW_BLOCK" = "true" ]; then
args+=(--block)
fi
go run ./internal/qualitygate/cmd/semantic-review "${args[@]}"
- name: Publish semantic review
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
with:
script: |
const { publish } = require("./scripts/semantic-review-publish.js");
await publish({ github, context, core });

6
.gitignore vendored
View File

@@ -7,6 +7,11 @@ bin/
# Node
node_modules/
# Python (skill-bundled helper scripts)
__pycache__/
*.py[cod]
*$py.class
# OS
.DS_Store
@@ -46,3 +51,4 @@ app.log
cover*.out
lark-env.sh
/automations/

View File

@@ -29,11 +29,11 @@ linters:
- unused # checks for unused constants, variables, functions and types
- depguard # blocks forbidden package imports
- forbidigo # forbids specific function calls
- errorlint # enforces error wrapping (%w) and errors.Is/As over == and type asserts
# To enable later after fixing existing issues:
# - errcheck # checks for unchecked errors
# - errname # checks that error types are named XxxError
# - errorlint # checks error wrapping best practices
# - gosec # security-oriented linter
# - misspell # finds commonly misspelled English words
# - staticcheck # comprehensive static analysis
@@ -49,9 +49,16 @@ linters:
- gocritic
- depguard
- forbidigo
# Paths that run forbidigo. Add an entry when a path joins one of
# the rules below.
- errorlint # tests legitimately do identity (==) and concrete type-assert checks
# forbidigo runs repo-wide (minus the boundaries below) so errs-no-bare-wrap
# has no gap. The framework bans (os/vfs, raw HTTP, fmt.Print, filepath,
# log) stay scoped to shortcuts/ + internal/ + config/auth/service via the
# next rule; elsewhere only errs-no-bare-wrap fires.
- path-except: (shortcuts/|internal/|cmd/|events/)
linters:
- forbidigo
- path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/)
text: (vfs|IOStreams|ctx\.Out|shortcuts-no-raw-http|filepath functions|os\.Exit|structured error return)
linters:
- forbidigo
- path: internal/vfs/
@@ -65,31 +72,26 @@ linters:
- path: shortcuts/.*/internal/gen/
linters:
- forbidigo
# internal/qualitygate/cmd contains standalone CI tools. Their main
# entrypoints legitimately own process exit codes and stdio, matching the
# old tools/ layout before these packages moved under internal/.
- path: internal/qualitygate/cmd/[^/]+/main\.go$
linters:
- forbidigo
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer.
- path-except: shortcuts/
text: shortcuts-no-raw-http
linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
# errs-no-bare-wrap enforced across every command/wire boundary by
# structural prefix, so any future business domain or command is covered
# without editing an allowlist. Genuine intermediate wraps inside these
# paths use //nolint:forbidigo with a reason.
- path-except: (cmd/|shortcuts/|events/)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors.
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
text: errs-no-legacy-helper
linters:
- forbidigo
settings:
depguard:
@@ -108,22 +110,6 @@ linters:
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
forbidigo:
forbid:
# ── legacy output.Err* helpers banned on migrated paths ──
# output.ErrBare is intentionally not listed — it is the predicate-
# command silent-exit signal, outside the typed envelope contract.
- pattern: output\.(ErrValidation|ErrAuth|ErrNetwork|ErrAPI|ErrWithHint|Errorf)\b
msg: >-
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── legacy shared error helpers banned on migrated domains ──
# These helpers emit legacy output.Err* / bare error shapes or drop
# typed metadata such as Param/Cause. Migrated domains must use typed
# common replacements or local typed helpers instead.
- pattern: (common\.FlagErrorf|common\.RejectDangerousChars|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
msg: >-
[errs-no-legacy-helper] these shared helpers emit legacy or
metadata-poor error shapes. Use typed common replacements, typed
errs.NewXxxError builders, or domain-local typed helpers.
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-

View File

@@ -2,6 +2,156 @@
All notable changes to this project will be documented in this file.
## [v1.0.60] - 2026-06-29
### Features
- **affordance**: Per-command usage guidance system with markdown source (#1565)
- **event**: Support VC meeting lifecycle events (#1632)
- **sheets**: Use `office_sheet_file` parent_type for imported office spreadsheets (#1606)
- **authorization**: Expand lark-shared auth guidance and assert clean logout JSON (#1598)
- **transport**: Add `LARK_CLI_NO_PROXY_WARN` to silence proxy warning (#1647)
### Bug Fixes
- **install**: Load `@clack/prompts` via dynamic import to avoid `ERR_REQUIRE_ESM` (#1652)
### Tests
- **doc**: Derive fetch test flag defaults from `v2FetchFlags` (#1428)
### Build
- **ci**: Reduce public content false positives
## [v1.0.59] - 2026-06-26
### Features
- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585)
- **minutes**: Support speaker list and no-Lark speaker replace (#1594)
- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571)
### Bug Fixes
- **docs**: Hide docs `api-version` compat flag (#1580)
## [v1.0.58] - 2026-06-25
### Features
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
- **base**: Add Base URL and title resolve shortcuts (#1338)
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
- **doc**: Support `create` title option (#1536)
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
- **task**: Add task event consumer (#1510)
### Bug Fixes
- **doc**: Prefix docs resource shortcuts (#1564)
- **binding**: Skip unix mode audit on Windows (#1525)
### Documentation
- **approval**: Sync approval skill for meta API commands (#1499)
- **doc**: Restore lark-doc style requirements (#1579)
- **im**: Document `chat.nickname` get/update/delete (#1378)
- **im**: Clarify audio message opus requirement (#1271)
### Build
- **ci**: Add public content safeguards and reduce false positives
## [v1.0.57] - 2026-06-23
### Features
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
- **base**: Support record comments (#1043)
- **search**: Surface search API notices (#1413)
### Bug Fixes
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
- **meta**: Backfill enum value descriptions from options (#1541)
- **cli**: Add missing CLI headers for git credential helper (#1539)
### Documentation
- **doc**: Refine rich block, path, and block ID guidance (#1508)
- **mail**: Trim lark-mail skill context (#1527)
- **drive**: Add permission governance workflow guidance (#1292)
### Build
- **ci**: Bind semantic review to workflow run head (#1551)
## [v1.0.56] - 2026-06-18
### Features
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
### Bug Fixes
- **api**: Align API success envelopes (#1489)
- **base**: Reject out-of-range pagination flags (#1495)
### Refactor
- Retire legacy error envelopes and enforce typed contract (#1449)
### Documentation
- **skills**: Soften lark-doc style guidance (#1463)
### Build
- Add CI quality gate with semantic review
## [v1.0.55] - 2026-06-16
### Features
- **vc**: Support agent meeting event workflows (#1483)
- **drive**: Support exporting Base structure snapshots (#1481)
- **doc**: Add docx cover resource commands (#1468)
- **doc**: Support `lang` for docx fetch v2 (#1459)
- **event**: Optimize subscription precheck, links, and consumer guard (#1447)
### Bug Fixes
- **drive**: Validate drive import folder target (#1485)
## [v1.0.54] - 2026-06-15
### Features
- **mail**: Auto-attach default signature on send/reply/forward (#1415)
- **drive**: Support `original_creator_ids` filter in search (#1046)
- **cli**: Simplify proxy plugin warning and gate it on TTY (#1448)
### Bug Fixes
- **doc**: Fix docs fetch and update ergonomics (#1466)
- **vfs**: Reject blank local paths (#1460)
- **vfs**: Reject Windows absolute paths cross-platform (#1401)
- **event**: Clarify remote bus blocker recovery (#1454)
### Refactor
- Converge command pipelines onto a typed metadata model + catalog (#1191)
### Documentation
- **im**: Document `@mention` format per message type (text/post/card) (#1419)
- **doc**: Clarify lark-doc create title guidance (#1474)
- **skills**: Add rename prompt for import without `--name` (#1461)
- **apps**: Drop Miaoda brand word from apps command help text (#1399)
## [v1.0.53] - 2026-06-12
### Features
@@ -1149,6 +1299,13 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51

View File

@@ -5,6 +5,14 @@ BINARY := lark-cli
MODULE := github.com/larksuite/cli
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
DATE := $(shell date +%Y-%m-%d)
NODE ?= node
QUALITY_GATE_CHANGED_FROM ?= $(shell bash scripts/resolve-changed-from.sh)
QUALITY_GATE_CHANGED_FROM_RESOLVED = $(if $(strip $(QUALITY_GATE_CHANGED_FROM)),$(QUALITY_GATE_CHANGED_FROM),$(shell bash scripts/resolve-changed-from.sh))
QUALITY_GATE_DIR ?= .tmp/quality-gate
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
@@ -15,7 +23,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
all: test
@@ -39,6 +47,12 @@ fmt-check:
exit 1; \
fi
script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
@@ -53,7 +67,32 @@ examples-build:
integration-test: build
go test -v -count=1 ./tests/...
test: vet fmt-check unit-test examples-build integration-test
test: vet fmt-check script-test unit-test examples-build integration-test
quality-gate: build
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) $(dir $(PUBLIC_CONTENT_METADATA))
test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA)
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
go run ./internal/qualitygate/cmd/manifest-export \
--manifest-out $(QUALITY_GATE_MANIFEST_OUT) \
--command-index-out $(QUALITY_GATE_COMMAND_INDEX_OUT)
LARKSUITE_CLI_APP_ID=dry-run \
LARKSUITE_CLI_APP_SECRET=dry-run \
LARKSUITE_CLI_BRAND=feishu \
LARKSUITE_CLI_CONFIG_DIR=$${TMPDIR:-/tmp}/quality-gate-cli-config \
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
go run ./internal/qualitygate/cmd/quality-gate check \
--repo . \
--cli-bin ./$(BINARY) \
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
--public-content-metadata $(PUBLIC_CONTENT_METADATA) \
--facts-out $(QUALITY_GATE_FACTS_OUT)
install: build
install -d $(PREFIX)/bin

View File

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

View File

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

49
affordance/README.md Normal file
View File

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

19
affordance/contact.md Normal file
View File

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

View File

@@ -10,6 +10,7 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -66,8 +67,21 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd := &cobra.Command{
Use: "api <method> <path>",
Short: "Generic Lark API requests",
Args: cobra.ExactArgs(2),
Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)",
Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path.
Prefer the typed domain command when one exists — it validates parameters,
shows the Risk level, gates destructive calls behind --yes, and carries usage
guidance that this raw command does not. If a domain command covers your task
(browse with ` + "`lark-cli <domain> --help`" + `), use it instead of this.
Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g.
newer/preview APIs), where you already have the HTTP path from the Lark docs.
Examples:
lark-cli api GET /open-apis/calendar/v4/calendars
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Method = strings.ToUpper(args[0])
opts.Path = args[1]
@@ -123,7 +137,13 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--params and --data cannot both read from stdin (-)").
WithHint("pass at most one flag as '-'; give the other inline JSON or @file").
WithParams(
errs.InvalidParam{Name: "--params", Reason: "reads from stdin (-)"},
errs.InvalidParam{Name: "--data", Reason: "reads from stdin (-)"},
)
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
@@ -153,7 +173,10 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
return client.RawApiRequest{}, nil, err
}
if _, ok := dataFields.(map[string]any); !ok {
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--data must be a JSON object when used with --file").
WithHint(`with --file, --data carries multipart form fields, e.g. --data '{"image_type":"message"}'`).
WithParam("--data")
}
}
@@ -196,7 +219,13 @@ func apiRun(opts *APIOptions) error {
}
if opts.PageAll && opts.Output != "" {
return output.ErrValidation("--output and --page-all are mutually exclusive")
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--output and --page-all are mutually exclusive").
WithHint("drop --page-all to save a binary response, or drop --output to paginate JSON").
WithParams(
errs.InvalidParam{Name: "--output", Reason: "conflicts with --page-all"},
errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"},
)
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
@@ -233,7 +262,7 @@ func apiRun(opts *APIOptions) error {
}
if opts.PageAll {
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
}
@@ -243,7 +272,7 @@ func apiRun(opts *APIOptions) error {
// pass on *output.ExitError values. Typed *errs.* errors that flow
// through here keep their canonical message / hint from BuildAPIError;
// MarkRaw is a no-op on those (it only flips a flag on *ExitError).
return output.MarkRaw(err)
return errs.MarkRaw(err)
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
@@ -263,7 +292,7 @@ func apiRun(opts *APIOptions) error {
// MarkRaw: see comment above on the DoAPI path. Skips legacy
// *ExitError enrichment; typed errors flow through unchanged.
if err != nil {
return output.MarkRaw(err)
return errs.MarkRaw(err)
}
return nil
}
@@ -272,46 +301,76 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
return output.MarkRaw(err)
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
return nil
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return output.MarkRaw(err)
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
return errs.MarkRaw(apiErr)
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
output.FormatValue(out, result, output.FormatJSON)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return output.MarkRaw(err)
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
return errs.MarkRaw(apiErr)
}
output.FormatValue(out, result, format)
return nil
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -4,6 +4,8 @@
package api
import (
"context"
"encoding/json"
"errors"
"os"
"sort"
@@ -11,6 +13,7 @@ import (
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -101,8 +104,19 @@ func TestApiCmd_BotMode(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "success") {
t.Error("expected 'success' in output")
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
if got["ok"] != true || got["identity"] != "bot" {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if !ok || data["result"] != "success" {
t.Fatalf("data = %#v, want result=success", got["data"])
}
}
@@ -328,8 +342,16 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
t.Error("expected 'falling back to json' in stderr")
}
// Should output JSON result to stdout
if !strings.Contains(stdout.String(), "u123") {
t.Error("expected user_id in JSON output")
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok || data["user_id"] != "u123" {
t.Fatalf("unexpected fallback envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("fallback success envelope leaked outer code: %s", stdout.String())
}
}
@@ -342,7 +364,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
Body: map[string]interface{}{
"code": 230001, "msg": "no permission",
"code": 230027, "msg": "user not authorized",
},
})
@@ -354,12 +376,20 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
t.Fatal("expected an error for non-zero code")
}
// Should still output the response body so user can see the error details
if !strings.Contains(stdout.String(), "230001") {
if !strings.Contains(stdout.String(), "230027") {
t.Errorf("expected error response in stdout, got: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "no permission") {
if !strings.Contains(stdout.String(), "user not authorized") {
t.Errorf("expected error message in stdout, got: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
}
}
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
@@ -395,6 +425,274 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
}
}
func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code on later page")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier successful page to remain streamed, got: %s", out)
}
if strings.Contains(out, "230027") || strings.Contains(out, "user not authorized") {
t.Fatalf("streaming stdout should not contain raw error JSON, got: %s", out)
}
if strings.Contains(out, "\n \"code\"") {
t.Fatalf("streaming stdout should not contain indented JSON error dump, got: %s", out)
}
}
func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("data.items = %#v, want one item", data["items"])
}
}
type apiContentSafetyProvider struct {
called bool
path string
data interface{}
match string
}
func (p *apiContentSafetyProvider) Name() string { return "api-test" }
func (p *apiContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
p.called = true
p.path = req.Path
p.data = req.Data
if p.match != "" {
b, _ := json.Marshal(req.Data)
if !strings.Contains(string(b), p.match) {
return nil, nil
}
}
return &extcs.Alert{Provider: "api-test", MatchedRules: []string{"pagination"}}, nil
}
func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &apiContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan paginated output")
}
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
data, ok := provider.data.(map[string]interface{})
if !ok {
t.Fatalf("scanned data type = %T, want map", provider.data)
}
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
alert, ok := got["_content_safety_alert"].(map[string]interface{})
if !ok || alert["provider"] != "api-test" {
t.Fatalf("missing content safety alert in envelope: %#v", got)
}
}
func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &apiContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan streamed paginated output")
}
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from api-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
}
if !strings.Contains(stdout.String(), `"id":"1"`) {
t.Fatalf("expected streamed ndjson output, got: %s", stdout.String())
}
}
func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
provider := &apiContentSafetyProvider{match: "blocked"}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
"has_more": false,
},
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
t.Fatal("expected content safety block error")
}
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
}
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
}
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "pagination" {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
}
}
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, code int) {
t.Helper()
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype || p.Code != code {
t.Fatalf("problem = %s/%s/%d, want %s/%s/%d", p.Category, p.Subtype, p.Code, category, subtype, code)
}
}
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
for _, tt := range []struct {
name string

View File

@@ -33,12 +33,9 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
if got := output.ExitCodeOf(err); got != 1 {
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
}
var bare *output.ExitError
var bare *output.BareError
if !errors.As(err, &bare) {
t.Fatalf("expected *output.ExitError (ErrBare), got %T: %v", err, err)
}
if bare.Detail != nil {
t.Errorf("ErrBare must carry no Detail (no envelope), got %+v", bare.Detail)
t.Fatalf("expected *output.BareError (ErrBare), got %T: %v", err, err)
}
if stderr.Len() != 0 {

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -59,7 +60,7 @@ func authListRun(opts *ListOptions) error {
// keep the same contract here. We still want the hint to be
// workspace-aware, so we pull the message+hint out of
// NotConfiguredError() instead of hard-coding it.
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
if cfgErr.Hint != "" {

View File

@@ -878,7 +878,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
// contract that when --json is set and pollDeviceToken returns OK=false,
// stdout carries the structured authorization_failed event and stderr is
// NOT polluted with a typed envelope. The returned error is a bare
// ExitError with ExitAuth so the dispatcher only propagates the exit code
// BareError with ExitAuth so the dispatcher only propagates the exit code
// without emitting a second envelope on top of the JSON event.
func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
keyring.MockInit()
@@ -945,16 +945,13 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr)
}
// Returned error must be the bare *output.ExitError signal (no envelope).
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
// Returned error must be the bare *output.BareError signal (no envelope).
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAuth {
t.Fatalf("ExitError.Code = %d, want %d", exitErr.Code, output.ExitAuth)
}
if exitErr.Detail != nil {
t.Errorf("ExitError.Detail should be nil for bare signal, got: %+v", exitErr.Detail)
if bareErr.Code != output.ExitAuth {
t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth)
}
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -33,9 +34,13 @@ import (
type BuildOption func(*buildConfig)
type buildConfig struct {
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
skipPlugins bool
skipStrictMode bool
skipService bool
serviceCatalog *apicatalog.Catalog
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
@@ -75,6 +80,41 @@ func HideProfile(hide bool) BuildOption {
}
}
// WithoutPlugins builds only repository-owned commands. It is intended for
// inspection tools that need a deterministic command tree.
func WithoutPlugins() BuildOption {
return func(c *buildConfig) {
c.skipPlugins = true
}
}
// WithoutStrictMode builds the complete repository-owned command tree without
// applying user/profile strict-mode pruning. It is intended for offline
// inspection tools, not production execution.
func WithoutStrictMode() BuildOption {
return func(c *buildConfig) {
c.skipStrictMode = true
}
}
// WithoutServiceCommands builds only hand-authored commands. It is intended for
// repository quality gates that should not depend on the remote OpenAPI
// metadata command surface.
func WithoutServiceCommands() BuildOption {
return func(c *buildConfig) {
c.skipService = true
}
}
// WithServiceCatalog builds generated service commands from a specific metadata
// catalog. It is intended for offline inspection tools that need deterministic
// embedded metadata while production execution keeps using the runtime catalog.
func WithServiceCatalog(catalog apicatalog.Catalog) BuildOption {
return func(c *buildConfig) {
c.serviceCatalog = &catalog
}
}
// Build constructs the full command tree. It also installs registered
// plugins and emits the Startup lifecycle event during assembly --
// so Plugin.On(Startup) handlers run even if the returned command is
@@ -130,6 +170,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.SetOut(cfg.streams.Out)
rootCmd.SetErr(cfg.streams.ErrOut)
// Root-only usage template (curated Usage synopsis + skills footer); see
// rootUsageTemplate.
rootCmd.SetUsageTemplate(rootUsageTemplate)
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
@@ -156,15 +200,28 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f))
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
if !cfg.skipService {
if cfg.serviceCatalog != nil {
service.RegisterServiceCommandsFromCatalog(ctx, rootCmd, f, *cfg.serviceCatalog)
} else {
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
}
}
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
groupRootCommands(rootCmd)
installUnknownSubcommandGuard(rootCmd)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
pruneForStrictMode(rootCmd, mode)
}
if cfg.skipPlugins {
recordInventory(nil)
return f, rootCmd, nil
}
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
if installErr != nil {
installPluginInstallErrorGuard(rootCmd, installErr)

46
cmd/build_test.go Normal file
View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
func TestBuildWithoutPluginsStillBuildsBuiltinCommands(t *testing.T) {
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
if root == nil {
t.Fatal("Build returned nil root")
}
if findCommand(root, "api") == nil {
t.Fatal("builtin api command missing")
}
if findCommand(root, "docs +fetch") == nil {
t.Fatal("builtin docs +fetch shortcut missing")
}
}
func findCommand(root *cobra.Command, path string) *cobra.Command {
parts := strings.Fields(path)
cmd := root
for _, part := range parts {
var next *cobra.Command
for _, child := range cmd.Commands() {
if child.Name() == part {
next = child
break
}
}
if next == nil {
return nil
}
cmd = next
}
return cmd
}

View File

@@ -4,8 +4,7 @@
package completion
import (
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
@@ -32,7 +31,9 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
case "powershell":
return root.GenPowerShellCompletionWithDesc(out)
default:
return fmt.Errorf("unsupported shell: %s", args[0])
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported shell: %s", args[0]).
WithHint("supported shells: bash, zsh, fish, powershell")
}
},
}

View File

@@ -212,10 +212,7 @@ func finalizeSource(opts *BindOptions) (string, error) {
if opts.IsTUI && !opts.langExplicit {
lang, err := promptLangSelection()
if err != nil {
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
return "", langSelectionError(err)
}
opts.Lang = string(lang)
opts.UILang = lang

View File

@@ -20,35 +20,29 @@ import (
"github.com/larksuite/cli/internal/output"
)
// assertExitError checks the full structured error in one assertion. It
// accepts both *output.ExitError (used by output.ErrWithHint) and the
// typed errors (ValidationError, ConfigError) — they normalize to the same
// wantDetail fields. The wantDetail.Type is matched against the typed error's
// Category string ("validation", "config", etc.).
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
// wantErrDetail is the normalized comparison shape for a typed error's wire
// fields: Type is the error's Category string ("validation", "config", ...),
// alongside Message and Hint.
type wantErrDetail struct {
Type string
Message string
Hint string
}
// assertExitError checks the full structured error in one assertion against a
// typed error (ValidationError or ConfigError), normalizing its Category /
// Message / Hint to wantDetail.
func assertExitError(t *testing.T, err error, wantCode int, wantDetail wantErrDetail) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
if exitErr.Code != wantCode {
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
}
if exitErr.Detail == nil {
t.Fatal("expected non-nil error detail")
}
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
}
return
}
var ve *errs.ValidationError
if errors.As(err, &ve) {
if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", got, wantCode)
}
gotDetail := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
gotDetail := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
}
@@ -59,13 +53,13 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.Er
if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", got, wantCode)
}
gotDetail := output.ErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
gotDetail := wantErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
}
return
}
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
t.Fatalf("error type = %T, want *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
}
// assertEnvelope decodes stdout and checks it matches want exactly — every key
@@ -179,15 +173,21 @@ func TestConfigBindRun_InvalidLang(t *testing.T) {
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
if valErr.Param != "--lang" {
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
}
})
}
@@ -365,7 +365,7 @@ func TestConfigBindRun_InvalidSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "invalid"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`,
})
@@ -382,7 +382,7 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
// TestFactory has IsTerminal=false by default
err := configBindRun(&BindOptions{Factory: f, Source: ""})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context",
@@ -421,7 +421,7 @@ func TestConfigBindRun_SourceEnvMismatch_OpenClawFlagInHermesEnv(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--source "openclaw" does not match detected Agent environment (hermes)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -437,7 +437,7 @@ func TestConfigBindRun_SourceEnvMismatch_HermesFlagInOpenClawEnv(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--source "hermes" does not match detected Agent environment (openclaw)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -566,7 +566,7 @@ func TestConfigBindRun_HermesMissingEnvFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
Hint: "verify Hermes is installed and configured at " + envPath,
@@ -584,7 +584,7 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify OpenClaw is installed and configured",
@@ -731,7 +731,7 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -750,7 +750,7 @@ func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify lark-channel-bridge is installed and configured",
@@ -770,7 +770,7 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "accounts.app.id missing in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
@@ -789,7 +789,7 @@ func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "accounts.app.secret is empty in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
@@ -835,17 +835,19 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
t.Fatal("expected error for unbound workspace")
}
// Should be a structured ConfigError suggesting config bind, not config init.
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
// Config errors share ExitAuth (3); the workspace is detected but no
// binding exists yet, which is a config error.
if cfgErr.Code != output.ExitAuth {
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
// The workspace name stays out of the wire subtype; it only appears in
// the message.
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
@@ -1187,7 +1189,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
// iterates a map — ordering is non-deterministic. DeepEqual inline against
// each accepted variant so every ErrDetail field (Type, Code, Message,
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
base := output.ErrDetail{
base := wantErrDetail{
Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
}
@@ -1203,7 +1205,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err)
}
got := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
got := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) {
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
got, wantWorkFirst, wantPersonalFirst)
@@ -1230,7 +1232,7 @@ func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only_one",
@@ -1250,7 +1252,7 @@ func TestConfigBindRun_InvalidIdentity(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes", Identity: "invalid"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `invalid --identity "invalid"; valid values: bot-only, user-default`,
})
@@ -1536,7 +1538,7 @@ func TestConfigBindRun_HermesMissingAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "FEISHU_APP_ID not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials",
@@ -1556,7 +1558,7 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "FEISHU_APP_SECRET not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials",
@@ -1582,7 +1584,7 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "openclaw.json missing channels.feishu section",
Hint: "configure Feishu in OpenClaw first",
@@ -1610,7 +1612,7 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) {
openclawPath := filepath.Join(openclawDir, "openclaw.json")
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
Hint: "configure channels.feishu.appSecret in openclaw.json",
@@ -1672,7 +1674,7 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",

View File

@@ -51,7 +51,7 @@ func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",
@@ -64,7 +64,7 @@ func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
// even before it has a bespoke error message.
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "hermes: no app configured",
})
@@ -100,7 +100,7 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
{AppID: "cli_home", Label: "home"},
}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
@@ -117,7 +117,7 @@ func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
{AppID: "cli_home", Label: "home"},
}
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
@@ -152,7 +152,7 @@ func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{{AppID: "cli_only"}}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only",

View File

@@ -12,6 +12,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -92,16 +93,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Fatal("expected error")
}
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
// Config errors share ExitAuth (3), not ExitValidation.
if cfgErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
}
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
if cfgErr.Subtype != errs.SubtypeNotConfigured || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want not_configured/not configured", cfgErr)
}
}
@@ -233,15 +234,21 @@ func TestConfigInitCmd_InvalidLang(t *testing.T) {
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
if valErr.Param != "--lang" {
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
}
})
}
@@ -385,8 +392,38 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
if err == nil {
t.Fatal("expected conflict error")
}
if !strings.Contains(err.Error(), "conflicts with existing appId") {
t.Fatalf("error = %v, want conflict with existing appId", err)
// A name/appId conflict is user input — a typed validation error naming the
// offending flag, not a system storage failure.
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if verr.Param != "--name" {
t.Errorf("param = %q, want --name", verr.Param)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", output.ExitCodeOf(err), output.ExitValidation)
}
if !strings.Contains(verr.Message, "conflicts with existing appId") {
t.Errorf("message = %q, want conflict description", verr.Message)
}
}
// TestWrapSaveConfigError_PassesTypedValidationThrough pins that a user-input
// validation error (e.g. the --name conflict) is not reclassified as an
// internal storage failure on its way up through the save call sites.
func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
conflict := errs.NewValidationError(errs.SubtypeInvalidArgument, "name conflict").WithParam("--name")
var verr *errs.ValidationError
if !errors.As(wrapSaveConfigError(conflict), &verr) {
t.Fatalf("typed validation must pass through unchanged, got %T", wrapSaveConfigError(conflict))
}
var ierr *errs.InternalError
if !errors.As(wrapSaveConfigError(errors.New("disk full")), &ierr) || ierr.Subtype != errs.SubtypeStorage {
t.Fatalf("untyped failure must become internal/storage")
}
}

View File

@@ -6,13 +6,11 @@ package config
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
@@ -127,12 +125,9 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
if ws.IsLocal() {
return nil
}
return &core.ConfigError{
Code: 2,
Type: ws.Display(),
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
Hint: "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.",
}
return errs.NewConfigError(errs.SubtypeNotConfigured,
"config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()).
WithHint("see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.")
}
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
@@ -183,6 +178,20 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
}
// wrapSaveConfigError passes an already-typed error (e.g. the --name conflict
// validation error from saveAsProfile) through unchanged, and classifies any
// other failure as an internal storage error. Without the passthrough a user
// input error would surface to agents as a system storage failure.
func wrapSaveConfigError(err error) error {
if err == nil {
return nil
}
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
// saveAsProfile appends or updates a named profile in the config.
// If a profile with the same name exists, it updates it; otherwise appends.
// When updating, cleans up old keychain secrets if AppId changed.
@@ -207,7 +216,9 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
} else {
if findAppIndexByAppID(multi, profileName) >= 0 {
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"profile name %q conflicts with existing appId", profileName).
WithParam("--name")
}
// Append new profile
multi.Apps = append(multi.Apps, core.AppConfig{
@@ -249,8 +260,8 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
// wrapUpdateExistingProfileErr classifies the error returned by
// updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError
// for blank-input) pass through unchanged so their exit code semantics
// survive; legacy *output.ExitError also passes through; everything else
// (filesystem, keychain, etc.) is wrapped as InternalError.
// survive; everything else (filesystem, keychain, etc.) is wrapped as
// InternalError.
func wrapUpdateExistingProfileErr(err error) error {
if err == nil {
return nil
@@ -258,10 +269,6 @@ func wrapUpdateExistingProfileErr(err error) error {
if errs.IsTyped(err) {
return err
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
}
@@ -336,7 +343,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
@@ -353,10 +360,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
lang, err := promptLangSelection()
if err != nil {
if err == huh.ErrUserAborted {
return output.ErrBare(1)
}
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
return langSelectionError(err)
}
opts.Lang = string(lang)
opts.UILang = lang
@@ -379,7 +383,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return wrapSaveConfigError(err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
@@ -409,7 +413,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return wrapSaveConfigError(err)
}
} else if result.Mode == "existing" && result.AppID != "" {
// Existing app with unchanged secret — update app ID and brand only
@@ -514,7 +518,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)

View File

@@ -8,7 +8,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/errs"
)
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
@@ -26,12 +26,15 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
if err == nil {
t.Fatal("expected refusal in OpenClaw context, got nil")
}
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "openclaw") {
t.Errorf("message must name the openclaw workspace; got %q", cfgErr.Message)
}
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
@@ -48,12 +51,15 @@ func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
if err == nil {
t.Fatal("expected refusal in Hermes context, got nil")
}
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
if cfgErr.Type != "hermes" {
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "hermes") {
t.Errorf("message must name the hermes workspace; got %q", cfgErr.Message)
}
}

View File

@@ -4,10 +4,14 @@
package config
import (
"errors"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
type initMsg struct {
@@ -97,3 +101,12 @@ func promptLangSelection() (i18n.Lang, error) {
}
return lang, nil
}
// langSelectionError maps a promptLangSelection failure to its exit surface:
// user abort exits bare with code 1; any other failure is internal.
func langSelectionError(err error) error {
if errors.Is(err, huh.ErrUserAborted) {
return output.ErrBare(1)
}
return errs.NewInternalError(errs.SubtypeUnknown, "language selection failed: %v", err).WithCause(err)
}

View File

@@ -65,8 +65,8 @@ func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t
// wrapUpdateExistingProfileErr is the caller-side classifier for the error
// returned by updateExistingProfileWithoutSecret. It must preserve typed-error
// exit semantics (regression: typed ValidationError was being downgraded to
// InternalError by the legacy *output.ExitError-only passthrough).
// exit semantics: a typed ValidationError must keep ExitValidation rather than
// being downgraded to InternalError.
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
if got := wrapUpdateExistingProfileErr(nil); got != nil {
@@ -90,18 +90,6 @@ func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T
}
}
func TestWrapUpdateExistingProfileErr_LegacyExitErrorPreserved(t *testing.T) {
in := &output.ExitError{Code: 7, Err: errors.New("legacy")}
got := wrapUpdateExistingProfileErr(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected *output.ExitError to pass through, got %T: %v", got, got)
}
if exitErr.Code != 7 {
t.Errorf("Code = %d, want 7", exitErr.Code)
}
}
func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) {
in := fmt.Errorf("disk full")
got := wrapUpdateExistingProfileErr(in)

View File

@@ -14,6 +14,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -94,7 +95,7 @@ func doctorRun(opts *DoctorOptions) error {
// underlying problem is still visible.
msg, hint := err.Error(), ""
if errors.Is(err, os.ErrNotExist) {
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
msg, hint = cfgErr.Message, cfgErr.Hint
}
@@ -108,7 +109,7 @@ func doctorRun(opts *DoctorOptions) error {
cfg, err := f.Config()
if err != nil {
hint := ""
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
hint = cfgErr.Hint
}

View File

@@ -15,7 +15,6 @@ import (
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
@@ -49,32 +48,6 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
authErr.Hint += "\n" + scopeHint
}
// enrichMissingScopeError appends a "current command requires scope(s): X"
// hint to a legacy *output.ExitError when the underlying error carries the
// need_user_authorization marker AND the current command declares scopes
// locally.
//
// Deprecated: enrichment for the legacy envelope; the typed path is
// applyNeedAuthorizationHint above.
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr == nil || exitErr.Detail == nil {
return
}
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
return
}
scopes := resolveDeclaredScopesForCurrentCommand(f)
if len(scopes) == 0 {
return
}
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
if exitErr.Detail.Hint == "" {
exitErr.Detail.Hint = scopeHint
return
}
exitErr.Detail.Hint += "\n" + scopeHint
}
// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
// current command for the resolved identity, checking shortcuts first and then
// service methods from local registry metadata.

View File

@@ -8,7 +8,7 @@ import (
"regexp"
)
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen the host alternation when adding brands.
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.

View File

@@ -4,21 +4,117 @@
package event
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
host, appID, strings.Join(scopes, ","))
// Landing-page contract for the scan-to-enable deep link, verified against the
// open platform: {open-host}/page/launcher?clientID=<appID>&addons=<encoded>.
// Note the param is camelCase "clientID" (not snake_case), and the value is the
// consuming app's own ID. Centralized so it can be corrected in one place.
const (
addonsLandingPath = "/page/launcher"
addonsClientIDParam = "clientID"
)
// ManifestAddons mirrors the 5 public manifest sections the launcher page accepts.
// Encoded form: JSON -> gzip -> base64url(no padding).
type ManifestAddons struct {
Scopes *AddonsScopes `json:"scopes,omitempty"`
Events *AddonsEvents `json:"events,omitempty"`
Callbacks *AddonsCallbacks `json:"callbacks,omitempty"`
}
// consoleEventSubscriptionURL points at the app's event subscription console page.
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s/app/%s/event", host, appID)
type AddonsScopes struct {
Tenant []string `json:"tenant"`
User []string `json:"user"`
}
type AddonsEvents struct {
Items AddonsEventItems `json:"items"`
}
type AddonsEventItems struct {
Tenant []string `json:"tenant"`
User []string `json:"user"`
}
type AddonsCallbacks struct {
Items []string `json:"items"`
}
// encodeAddons: JSON -> gzip -> base64url(no padding). Matches the front-end decode chain.
func encodeAddons(a ManifestAddons) (string, error) {
raw, err := json.Marshal(a)
if err != nil {
return "", err
}
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
if _, err := gw.Write(raw); err != nil {
return "", err
}
if err := gw.Close(); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil
}
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
encoded, err := encodeAddons(a)
if err != nil {
return "", err
}
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
}
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
func consoleLandingURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
}
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
url, err := consoleAddonsURL(brand, appID, a)
if err != nil {
return consoleLandingURL(brand, appID)
}
return url
}
// missingScopeAddons routes missing scopes into the identity-appropriate section.
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
// the addons spec treats a missing tenant/user as an empty array.
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
if identity.IsBot() {
s.Tenant = missing
} else {
s.User = missing
}
return ManifestAddons{Scopes: s}
}
// missingSubscriptionAddons routes missing events/callbacks into the right section.
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
if subType == eventlib.SubTypeCallback {
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
}
ev := &AddonsEvents{Items: AddonsEventItems{Tenant: []string{}, User: []string{}}}
if identity.IsBot() {
ev.Items.Tenant = missing
} else {
ev.Items.User = missing
}
return ManifestAddons{Events: ev}
}

View File

@@ -4,33 +4,109 @@
package event
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"io"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
"im:message:readonly",
"im:message.group_at_msg",
})
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
t.Helper()
gz, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("base64url decode: %v", err)
}
zr, err := gzip.NewReader(bytes.NewReader(gz))
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
raw, err := io.ReadAll(zr)
if err != nil {
t.Fatalf("gunzip: %v", err)
}
var a ManifestAddons
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatalf("json: %v", err)
}
return a
}
func TestEncodeAddons_RoundTrip(t *testing.T) {
in := ManifestAddons{Scopes: &AddonsScopes{Tenant: []string{"im:message"}}}
encoded, err := encodeAddons(in)
if err != nil {
t.Fatalf("encode: %v", err)
}
for _, r := range encoded {
if !(r == '-' || r == '_' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
t.Fatalf("encoded contains non-base64url char %q in %q", r, encoded)
}
}
out := decodeAddons(t, encoded)
if out.Scopes == nil || len(out.Scopes.Tenant) != 1 || out.Scopes.Tenant[0] != "im:message" {
t.Errorf("roundtrip mismatch: %+v", out)
}
}
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
if err != nil {
t.Fatalf("url: %v", err)
}
host := core.ResolveEndpoints(core.BrandFeishu).Open
prefix := host + "/page/launcher?clientID=cli_x&addons="
if !strings.HasPrefix(url, prefix) {
t.Errorf("url = %q, want prefix %q", url, prefix)
}
out := decodeAddons(t, strings.TrimPrefix(url, prefix))
if out.Callbacks == nil || len(out.Callbacks.Items) != 1 || out.Callbacks.Items[0] != "card.action.trigger" {
t.Errorf("decoded callbacks mismatch: %+v", out)
}
}
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
t.Errorf("unexpected url: %s", got)
func TestMissingScopeAddons_ByIdentity(t *testing.T) {
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
}
user := missingScopeAddons(core.AsUser, []string{"im:message"})
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
}
}
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
}
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
}
}
func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
// Unused identity sides must encode as [] (not null) so the launcher page's
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
cases := []ManifestAddons{
missingScopeAddons(core.AsBot, []string{"im:message"}),
missingScopeAddons(core.AsUser, []string{"im:message"}),
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
}
for i, a := range cases {
raw, err := json.Marshal(a)
if err != nil {
t.Fatalf("case %d marshal: %v", i, err)
}
if bytes.Contains(raw, []byte("null")) {
t.Errorf("case %d encodes a null array, want []: %s", i, raw)
}
}
}

View File

@@ -146,14 +146,28 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
}
// Callback subscriptions live in application/get, not app_versions; fetch the
// callback 底账 only for callback-type EventKeys. Weak dependency: on error,
// leave subscribedCallbacks nil so the callback precheck skips.
var subscribedCallbacks []string
if keyDef.SubscriptionType == eventlib.SubTypeCallback {
cbs, cbErr := appmeta.FetchSubscribedCallbacks(cmd.Context(), botRuntime, cfg.AppID)
if cbErr != nil {
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(cbErr))
} else {
subscribedCallbacks = cbs
}
}
pf := &preflightCtx{
factory: f,
appID: cfg.AppID,
brand: cfg.Brand,
eventKey: eventKey,
identity: identity,
keyDef: keyDef,
appVer: appVer,
factory: f,
appID: cfg.AppID,
brand: cfg.Brand,
eventKey: eventKey,
identity: identity,
keyDef: keyDef,
appVer: appVer,
subscribedCallbacks: subscribedCallbacks,
}
if err := preflightEventTypes(pf); err != nil {
return err
@@ -229,6 +243,9 @@ type preflightCtx struct {
identity core.Identity
keyDef *eventlib.KeyDefinition
appVer *appmeta.AppVersion
// subscribedCallbacks is the application/get 底账 for callback-type EventKeys;
// nil means "not fetched / unavailable" → callback precheck skips (weak dependency).
subscribedCallbacks []string
}
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
@@ -266,46 +283,66 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
WithIdentity(string(pf.identity)).
WithMissingScopes(missing...).
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
}
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
// Bot: the scan-to-enable link adds the scopes to the app manifest, after which
// the tenant token carries them. User: the scan link only updates the app
// manifest — the user's own token still lacks the scopes until it is
// re-authorized — so direct the user to re-login instead.
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
if identity.IsBot() {
return fmt.Sprintf(
"grant these scopes and publish a new app version at: %s",
consoleScopeGrantURL(brand, appID, missing),
)
return fmt.Sprintf("grant these scopes by scanning: %s",
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
}
return fmt.Sprintf(
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
strings.Join(missing, " "),
)
strings.Join(missing, " "))
}
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed
// in the app's console 底账 — published app_versions for event subscriptions,
// application/get subscribed_callbacks for callback subscriptions.
func preflightEventTypes(pf *preflightCtx) error {
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
if len(pf.keyDef.RequiredConsoleEvents) == 0 {
return nil
}
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
for _, t := range pf.appVer.EventTypes {
subscribed[t] = true
var subscribed []string
noun := "event types"
if pf.keyDef.SubscriptionType == eventlib.SubTypeCallback {
if pf.subscribedCallbacks == nil {
return nil
}
subscribed = pf.subscribedCallbacks
noun = "callbacks"
} else {
if pf.appVer == nil {
return nil
}
subscribed = pf.appVer.EventTypes
}
have := make(map[string]bool, len(subscribed))
for _, t := range subscribed {
have[t] = true
}
var missing []string
for _, t := range pf.keyDef.RequiredConsoleEvents {
if !subscribed[t] {
if !have[t] {
missing = append(missing, t)
}
}
if len(missing) == 0 {
return nil
}
url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing))
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"EventKey %s requires event types not subscribed in console: %s",
pf.keyDef.Key, strings.Join(missing, ", ")).
WithHint("subscribe these events and publish a new app version at: %s",
consoleEventSubscriptionURL(pf.brand, pf.appID))
"EventKey %s requires %s not subscribed in console: %s",
pf.keyDef.Key, noun, strings.Join(missing, ", ")).
WithHint("subscribe these %s by scanning: %s", noun, url)
}
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
@@ -349,9 +386,9 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
var (
errInvalidParamFormat = errors.New("invalid --param format")
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
errOutputDirUnsafe = errors.New("unsafe --output-dir")
errInvalidParamFormat = errors.New("invalid --param format") //nolint:forbidigo // sentinel, typed at call sites
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion") //nolint:forbidigo // sentinel, typed at call sites
errOutputDirUnsafe = errors.New("unsafe --output-dir") //nolint:forbidigo // sentinel, typed at call sites
)
func parseParams(raw []string) (map[string]string, error) {

View File

@@ -270,15 +270,15 @@ func TestExitForOrphan(t *testing.T) {
if err == nil {
t.Fatal("flag on + orphan → expected error, got nil")
}
var exit *output.ExitError
var exit *output.BareError
if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
t.Errorf("exit code = %v, want ExitValidation", err)
}
}
func errorAs(err error, target interface{}) bool {
if e, ok := err.(*output.ExitError); ok {
if t, ok := target.(**output.ExitError); ok {
if e, ok := err.(*output.BareError); ok {
if t, ok := target.(**output.BareError); ok {
*t = e
return true
}

View File

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

View File

@@ -97,9 +97,9 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
}
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
wantURL := "https://open.feishu.cn/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons="
if !strings.Contains(p.Hint, wantURL) {
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.Hint)
}
}
@@ -157,9 +157,8 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
}
hint := permErr.Hint
wantSubstrings := []string{
"https://open.feishu.cn/app/cli_x/auth?q=",
"im:message.group_at_msg",
"token_type=tenant",
"grant these scopes by scanning: ",
"https://open.feishu.cn/page/launcher?clientID=cli_x&addons=",
}
for _, want := range wantSubstrings {
if !strings.Contains(hint, want) {
@@ -174,3 +173,109 @@ func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
}
}
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{"profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
err := preflightEventTypes(pf)
if err == nil {
t.Fatal("expected error for missing callback")
}
if !strings.Contains(err.Error(), "callbacks not subscribed") {
t.Errorf("error = %q, want mention of 'callbacks not subscribed'", err.Error())
}
if !strings.Contains(err.Error(), "card.action.trigger") {
t.Errorf("error should name the missing callback, got: %q", err.Error())
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("problem = %v, want validation/failed_precondition", p)
}
}
func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
if err := preflightEventTypes(pf); err != nil {
t.Errorf("expected skip (nil), got %v", err)
}
}
func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
// fetched but zero callbacks subscribed (non-nil empty) is a definitive
// console state: a required callback IS missing and must be reported,
// not skipped as a weak dependency.
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{}, // fetched, none subscribed
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
err := preflightEventTypes(pf)
if err == nil {
t.Fatal("expected error for missing callback when none are subscribed")
}
if !strings.Contains(err.Error(), "card.action.trigger") {
t.Errorf("error should name the missing callback, got: %q", err.Error())
}
}
func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
if err := preflightEventTypes(pf); err != nil {
t.Errorf("all callbacks subscribed, unexpected error: %v", err)
}
}
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
// bot: scan-to-enable link (adds scopes to app manifest)
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
t.Errorf("bot hint should give the scan link, got: %s", bot)
}
// user: re-login (scan link cannot grant scopes to the user's own token)
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
if !strings.Contains(user, "auth login --scope") {
t.Errorf("user hint should direct to auth login, got: %s", user)
}
if strings.Contains(user, "/page/launcher") {
t.Errorf("user hint must NOT use the scan link, got: %s", user)
}
}

View File

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

View File

@@ -19,12 +19,12 @@ func TestExitForOrphan_Orphan(t *testing.T) {
if err == nil {
t.Fatal("expected error when failOnOrphan=true and orphan present")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation)
if bareErr.Code != output.ExitValidation {
t.Errorf("Code = %d, want %d", bareErr.Code, output.ExitValidation)
}
}

View File

@@ -5,10 +5,10 @@ package cmd
import (
"errors"
"slices"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -40,31 +40,65 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if !strings.Contains(exitErr.Detail.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
detail, _ := exitErr.Detail.Detail.(map[string]any)
valid, _ := detail["valid_flags"].([]string)
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
t.Errorf("valid_flags should list find & range, got %v", valid)
// The offending flag is carried structurally on Params (replaces the
// legacy detail map) and named in the message.
if len(verr.Params) != 1 || verr.Params[0].Name != "--rang" {
t.Errorf("Params = %v, want one entry named --rang", verr.Params)
}
if len(verr.Params) == 1 && verr.Params[0].Reason == "" {
t.Error("Params[0].Reason must explain the rejection")
}
if !strings.Contains(verr.Message, "--rang") {
t.Errorf("message should name the offending flag, got %q", verr.Message)
}
// The ranked candidate rides on the param as a machine-readable suggestion
// so an agent can retry without parsing prose.
if len(verr.Params) == 1 {
found := false
for _, s := range verr.Params[0].Suggestions {
if s == "--range" {
found = true
}
}
if !found {
t.Errorf("Params[0].Suggestions should include --range, got %v", verr.Params[0].Suggestions)
}
}
// The same candidate is also carried in the human-facing hint.
if !strings.Contains(verr.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", verr.Hint)
}
}
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if exitErr.Detail.Type != "flag_error" {
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
// structured param, generic --help hint (no "did you mean" suggestion).
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if verr.Param != "" || len(verr.Params) != 0 {
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
}
}

View File

@@ -9,10 +9,12 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -102,7 +104,7 @@ func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Comma
}
// Happy path: a valid policy.yml denies one specific command. The denied
// command's RunE returns a typed ExitError envelope; allowed commands are
// command's RunE returns a typed error envelope; allowed commands are
// untouched.
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
cfgDir := tmpHome(t)
@@ -127,13 +129,27 @@ max_risk: write
if err == nil {
t.Fatalf("+delete-doc RunE should return an error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok || detail["reason_code"] != "command_denylisted" {
t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// The denial taxonomy (reason_code, layer, rule) is preserved on the
// wrapped *platform.CommandDeniedError cause and folded into the hint.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("error chain should expose *platform.CommandDeniedError")
}
if cd.ReasonCode != "command_denylisted" {
t.Errorf("CommandDeniedError.ReasonCode = %q, want command_denylisted", cd.ReasonCode)
}
if !strings.Contains(verr.Hint, "command_denylisted") {
t.Errorf("hint should surface reason_code command_denylisted, got %q", verr.Hint)
}
// im/+send must be denied (domain not in Allow).

View File

@@ -8,9 +8,9 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
internalplatform "github.com/larksuite/cli/internal/platform"
)
@@ -34,16 +34,8 @@ import (
// lands directly on their RunE, which now carries the guard.
//
// makeErr is called for every guarded dispatch; it must return a fresh
// *output.ExitError each time (the envelope writer mutates a few fields
// as it serialises).
// Deprecated: installFatalGuard accepts a *output.ExitError-producing lambda,
// which is part of the legacy error surface that predates the typed error
// contract introduced by errs/. New code MUST NOT add new callers — the
// platform-extension fatal-guard plumbing will switch to typed errs.* errors
// when the platform-extension framework migrates. This wrapper is retained
// only for the existing in-tree call sites; it will be removed once they
// have moved to the typed surface.
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
// typed error each time.
func installFatalGuard(rootCmd *cobra.Command, makeErr func() error) {
// Two cobra subcommands are injected lazily at Execute() time and
// would otherwise slip past walkGuard. We pre-register both so
// walkGuard catches them.
@@ -80,120 +72,65 @@ func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError)
}
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
// failure as a structured plugin_install envelope before any command
// runs.
// Deprecated: installPluginInstallErrorGuard produces a legacy
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
// such producers — plugin install failures should surface as a typed
// *errs.XxxError once the platform-extension framework migrates. This
// helper is retained only while existing call sites are migrated; it will
// be removed once they have moved to the typed surface.
// failure as a typed validation error (failed_precondition) before any
// command runs.
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
makeErr := func() *output.ExitError {
makeErr := func() error {
var pi *internalplatform.PluginInstallError
if errors.As(installErr, &pi) {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_install",
Message: pi.Error(),
Detail: map[string]any{
"plugin": pi.PluginName,
"reason_code": pi.ReasonCode,
"reason": pi.Reason,
},
},
Err: installErr,
}
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_install",
Message: installErr.Error(),
Detail: map[string]any{
"reason_code": internalplatform.ReasonInstallFailed,
},
},
Err: installErr,
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", pi.Error()).
WithHint("plugin %q failed to install (reason_code %s); fix or remove the plugin before running commands", pi.PluginName, pi.ReasonCode).
WithCause(installErr)
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", installErr.Error()).
WithHint("a plugin failed to install (reason_code %s); fix or remove the plugin before running commands", internalplatform.ReasonInstallFailed).
WithCause(installErr)
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration
// error (single plugin invalid Rule or multiple plugins each contributing
// Restrict). The design separates the envelope type:
// Restrict). The hint separates the two failure modes by reason code:
//
// - "plugin_install" with reason_code "invalid_rule" - single bad rule
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
// - "invalid_rule" - single bad rule
// - "multiple_restrict_plugins" - multiple Restrict plugins conflict
//
// Either way the CLI must NOT silently continue with a broken policy.
// Deprecated: installPluginConflictGuard produces a legacy *output.ExitError
// via its internal makeErr lambda. New code MUST NOT add such producers —
// plugin conflict failures should surface as a typed *errs.XxxError once the
// platform-extension framework migrates. This helper is retained only while
// existing call sites are migrated; it will be removed once they have moved
// to the typed surface.
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
makeErr := func() *output.ExitError {
envelopeType := "plugin_install"
makeErr := func() error {
reasonCode := internalplatform.ReasonInvalidRule
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
envelopeType = "plugin_conflict"
reasonCode = internalplatform.ReasonMultipleRestricts
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: envelopeType,
Message: err.Error(),
Detail: map[string]any{
"reason_code": reasonCode,
},
},
Err: err,
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
WithHint("plugin policy configuration is broken (reason_code %s); fix the plugin's Restrict rule or remove the conflicting plugin", reasonCode).
WithCause(err)
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
// failure as a plugin_lifecycle envelope. The reason_code splits
// returned-error vs panic so consumers (audit / on-call) can tell the
// two failure modes apart.
// Deprecated: installPluginLifecycleErrorGuard produces a legacy
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
// such producers — plugin lifecycle failures should surface as a typed
// *errs.XxxError once the platform-extension framework migrates. This
// helper is retained only while existing call sites are migrated; it will
// be removed once they have moved to the typed surface.
// failure as a typed validation error (failed_precondition). The hint's
// reason code splits returned-error vs panic so consumers (audit /
// on-call) can tell the two failure modes apart.
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
makeErr := func() *output.ExitError {
makeErr := func() error {
reasonCode := "lifecycle_failed"
detail := map[string]any{
"reason_code": reasonCode,
}
hookName := ""
var le *hook.LifecycleError
if errors.As(err, &le) {
if le.Panic {
reasonCode = "lifecycle_panic"
}
detail = map[string]any{
"reason_code": reasonCode,
"hook_name": le.HookName,
"event": "startup",
}
hookName = le.HookName
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_lifecycle",
Message: err.Error(),
Detail: detail,
},
Err: err,
typed := errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
WithCause(err)
if hookName != "" {
return typed.WithHint("plugin startup hook %q failed (reason_code %s); fix or remove the plugin before running commands", hookName, reasonCode)
}
return typed.WithHint("a plugin startup hook failed (reason_code %s); fix or remove the plugin before running commands", reasonCode)
}
installFatalGuard(rootCmd, makeErr)
}
@@ -219,14 +156,7 @@ func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
//
// This way the very first non-nil step in cobra's chain is always our
// guard, regardless of which leaf the user invoked.
// Deprecated: walkGuard accepts a *output.ExitError-producing lambda, part
// of the legacy error surface that predates the typed error contract
// introduced by errs/. New code MUST NOT add new callers — the platform-
// extension guard plumbing will switch to typed errs.* errors when the
// platform-extension framework migrates. This wrapper is retained only for
// the existing in-tree call sites; it will be removed once they have moved
// to the typed surface.
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
func walkGuard(cmd *cobra.Command, makeErr func() error) {
if cmd == nil {
return
}

View File

@@ -6,12 +6,14 @@ package cmd
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
@@ -32,7 +34,7 @@ func (failClosedAbortingPlugin) Install(platform.Registrar) error {
}
// When a FailClosed plugin fails to install, buildInternal must
// install a PersistentPreRunE that returns a structured *output.ExitError.
// install a PersistentPreRunE that returns a typed *errs.ValidationError.
// The user must NEVER see a silent partial-install state.
//
// This pins the build.go fix for codex's NEW ISSUE about
@@ -93,26 +95,31 @@ func TestBuildInternal_failClosedAbortsCLI(t *testing.T) {
checkGuardError(t, leaf.RunE(leaf, nil))
}
// checkGuardError asserts that err is the structured plugin_install
// ExitError the guard produces.
// checkGuardError asserts that err is the typed validation error the
// install guard produces: a failed_precondition *errs.ValidationError
// (exit 2) whose message + hint preserve the plugin name and the
// install_failed reason code (the recovery info that lived in the legacy
// detail map).
func checkGuardError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatalf("PersistentPreRunE must surface the install error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_install" {
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
detail := exitErr.Detail.Detail.(map[string]any)
if detail["plugin"] != "policy" {
t.Errorf("detail.plugin = %v, want policy", detail["plugin"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if detail["reason_code"] != internalplatform.ReasonInstallFailed {
t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"])
if !strings.Contains(verr.Hint, "policy") {
t.Errorf("hint should name the failing plugin %q, got %q", "policy", verr.Hint)
}
if !strings.Contains(verr.Hint, internalplatform.ReasonInstallFailed) {
t.Errorf("hint should surface reason_code %q, got %q", internalplatform.ReasonInstallFailed, verr.Hint)
}
}

View File

@@ -8,11 +8,13 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -156,19 +158,23 @@ func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
}
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
detail := exitErr.Detail.Detail.(map[string]any)
if detail["reason_code"] != "aborted" {
t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if detail["hook_name"] != "policy-plugin.policy" {
t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"])
// The namespaced hook name and the abort semantics are preserved in the
// message so a caller can identify which plugin hook rejected the call.
if !strings.Contains(verr.Message, "policy-plugin.policy") {
t.Errorf("message should name the aborting hook policy-plugin.policy, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "aborted") {
t.Errorf("message should describe the abort, got %q", verr.Message)
}
// errors.As must still reach the original AbortError so consumers
@@ -409,15 +415,20 @@ func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) {
t.Fatalf("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_conflict" {
t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" {
t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// reason_code multiple_restrict_plugins is folded into the hint so the
// operator can distinguish a multi-Restrict conflict from a bad rule.
if !strings.Contains(verr.Hint, "multiple_restrict_plugins") {
t.Errorf("hint should surface reason_code multiple_restrict_plugins, got %q", verr.Hint)
}
}
@@ -447,15 +458,20 @@ func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) {
t.Fatalf("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_install" {
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" {
t.Errorf("reason_code = %v, want invalid_rule", rc)
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// reason_code invalid_rule is folded into the hint, distinct from the
// multiple_restrict_plugins conflict path.
if !strings.Contains(verr.Hint, "invalid_rule") {
t.Errorf("hint should surface reason_code invalid_rule, got %q", verr.Hint)
}
}
@@ -484,19 +500,24 @@ func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) {
leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_lifecycle" {
t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "lifecycle_failed" {
t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if d["hook_name"] != "lc.start" {
t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
// reason_code lifecycle_failed (vs lifecycle_panic) and the failing
// hook name are folded into the hint so audit / on-call can tell the
// failure mode and which hook failed.
if !strings.Contains(verr.Hint, "lifecycle_failed") {
t.Errorf("hint should surface reason_code lifecycle_failed, got %q", verr.Hint)
}
if !strings.Contains(verr.Hint, "lc.start") {
t.Errorf("hint should name the failing hook lc.start, got %q", verr.Hint)
}
}
@@ -520,12 +541,20 @@ func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) {
}
leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" {
t.Errorf("reason_code = %v, want lifecycle_panic", rc)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// A panicking startup hook is distinguished from a returned error by
// reason_code lifecycle_panic in the hint.
if !strings.Contains(verr.Hint, "lifecycle_panic") {
t.Errorf("hint should surface reason_code lifecycle_panic, got %q", verr.Hint)
}
}
@@ -579,19 +608,24 @@ func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) {
}()
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "panic" {
t.Errorf("reason_code = %v, want panic", d["reason_code"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if d["hook_name"] != "p.boom" {
t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"])
// The recovered panic surfaces as a structured error naming the
// namespaced hook (p.boom) and describing the panic, so the process
// never crashes and the caller can attribute the failure.
if !strings.Contains(verr.Message, "p.boom") {
t.Errorf("message should name the namespaced hook p.boom, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "panic") {
t.Errorf("message should describe the panic, got %q", verr.Message)
}
}
@@ -653,19 +687,24 @@ func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) {
}()
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "panic" {
t.Errorf("reason_code = %v, want panic", d["reason_code"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if d["hook_name"] != "fac.bad-factory" {
t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
// A panic in the wrapper FACTORY (not just the inner handler) is
// recovered into the same structured panic error, naming the
// namespaced hook fac.bad-factory.
if !strings.Contains(verr.Message, "fac.bad-factory") {
t.Errorf("message should name the namespaced hook fac.bad-factory, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "panic") {
t.Errorf("message should describe the panic, got %q", verr.Message)
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
@@ -53,7 +54,9 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error {
if err := core.ValidateProfileName(name); err != nil {
return output.ErrValidation("%v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).
WithCause(err).
WithParam("--name")
}
langPref, err := cmdutil.ParseLangFlag(lang)
@@ -64,46 +67,57 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
// Read secret from stdin
if !appSecretStdin {
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret must be provided via stdin").
WithHint("use --app-secret-stdin and pipe the secret").
WithParam("--app-secret-stdin")
}
scanner := bufio.NewScanner(f.IOStreams.In)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return output.ErrValidation("failed to read secret from stdin: %v", err)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to read secret from stdin: %v", err).
WithCause(err).
WithParam("--app-secret-stdin")
}
return output.ErrValidation("stdin is empty, expected app secret")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret").
WithHint("pipe the app secret to stdin").
WithParam("--app-secret-stdin")
}
appSecret := strings.TrimSpace(scanner.Text())
if appSecret == "" {
return output.ErrValidation("app secret read from stdin is empty")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty").
WithHint("pipe a non-empty app secret to stdin").
WithParam("--app-secret-stdin")
}
// Load or create config
multi, err := core.LoadMultiAppConfig()
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return output.Errorf(output.ExitInternal, "internal", "failed to load config: %v", err)
return errs.NewInternalError(errs.SubtypeFileIO, "failed to load config: %v", err).WithCause(err)
}
multi = &core.MultiAppConfig{}
}
// Check name uniqueness
if multi.FindApp(name) != nil {
return output.ErrValidation("profile %q already exists", name)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", name).
WithHint("choose a different name, or remove the existing profile first").
WithParam("--name")
}
// Check app-id uniqueness — keychain stores secrets by appId, so
// multiple profiles sharing the same appId would collide on credentials.
for _, a := range multi.Apps {
if a.AppId == appID {
return output.ErrValidation("app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName())
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName()).
WithParam("--app-id")
}
}
// Store secret securely
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
return errs.NewInternalError(errs.SubtypeStorage, "%v", err).WithCause(err)
}
parsedBrand := core.ParseBrand(brand)
@@ -134,7 +148,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q added (%s, %s)", name, appID, parsedBrand))

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -45,7 +46,7 @@ func profileListRun(f *cmdutil.Factory) error {
output.PrintJson(f.IOStreams.Out, []profileListItem{})
return nil
}
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to load config: %v", err).WithCause(err)
}
if multi == nil || len(multi.Apps) == 0 {
output.PrintJson(f.IOStreams.Out, []profileListItem{})

View File

@@ -11,6 +11,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
@@ -50,6 +51,16 @@ func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
if !strings.Contains(err.Error(), "failed to load config") {
t.Fatalf("error = %v, want failed to load config", err)
}
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err)
}
if internalErr.Subtype != errs.SubtypeFileIO {
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
}
if code := output.ExitCodeOf(err); code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
}
}
// TestProfileAddRun_Lang covers the unified --lang contract on profile add:
@@ -95,9 +106,9 @@ func TestProfileAddRun_Lang(t *testing.T) {
if err == nil {
t.Fatal("expected validation error for --lang ZH, got nil")
}
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Code != output.ExitValidation {
t.Fatalf("expected ExitValidation, got %T: %v", err, err)
var valErr *errs.ValidationError
if !errors.As(err, &valErr) || output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("expected typed validation error with ExitValidation, got %T: %v", err, err)
}
})
}
@@ -406,17 +417,226 @@ func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
func assertInternalExitError(t *testing.T, err error, wantMsg string) {
t.Helper()
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; err=%v", err, err)
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err)
}
if exitErr.Code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
if internalErr.Subtype != errs.SubtypeStorage {
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeStorage)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "internal" {
t.Fatalf("detail = %#v, want internal detail", exitErr.Detail)
if internalErr.Cause == nil {
t.Fatalf("cause = nil, want wrapped underlying error")
}
if !strings.Contains(exitErr.Detail.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", exitErr.Detail.Message, wantMsg)
if !strings.Contains(internalErr.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", internalErr.Message, wantMsg)
}
if code := output.ExitCodeOf(err); code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
}
}
// assertValidationError asserts err is a typed *errs.ValidationError with the
// given subtype, message fragment, and exit code 2.
func assertValidationError(t *testing.T, err error, wantSubtype errs.Subtype, wantMsg string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
}
if valErr.Subtype != wantSubtype {
t.Fatalf("subtype = %q, want %q", valErr.Subtype, wantSubtype)
}
if !strings.Contains(valErr.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", valErr.Message, wantMsg)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
return valErr
}
func saveTwoProfiles(t *testing.T) {
t.Helper()
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
func TestProfileAddRun_ValidationErrors(t *testing.T) {
t.Run("invalid profile name", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "bad name!", "app-x", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "")
if valErr.Param != "--name" {
t.Fatalf("param = %q, want %q", valErr.Param, "--name")
}
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped validation error")
}
})
t.Run("missing app-secret-stdin flag", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileAddRun(f, "p", "app-x", false, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret must be provided via stdin")
if valErr.Param != "--app-secret-stdin" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin")
}
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
t.Run("empty stdin", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("")
err := profileAddRun(f, "p", "app-x", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "stdin is empty")
if valErr.Param != "--app-secret-stdin" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin")
}
})
t.Run("blank secret on stdin", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader(" \n")
err := profileAddRun(f, "p", "app-x", true, "feishu", "", false)
assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret read from stdin is empty")
})
t.Run("duplicate profile name", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "default", "app-new", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "default" already exists`)
if valErr.Param != "--name" {
t.Fatalf("param = %q, want %q", valErr.Param, "--name")
}
})
t.Run("duplicate app-id", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "fresh", "app-default", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "already used by profile")
if valErr.Param != "--app-id" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-id")
}
})
}
func TestProfileUseRun_ValidationErrors(t *testing.T) {
t.Run("no previous profile for toggle", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileUseRun(f, "-")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "no previous profile to switch back to")
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
t.Run("profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileUseRun(f, "ghost")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
}
func TestProfileRenameRun_ValidationErrors(t *testing.T) {
t.Run("invalid new name", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "default", "bad name!")
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "")
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped validation error")
}
})
t.Run("old profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "ghost", "fresh")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
t.Run("new name already exists", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "default", "target")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "target" already exists`)
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
}
func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
t.Run("profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRemoveRun(f, "ghost")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
t.Run("cannot remove the only profile", func(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "solo",
Apps: []core.AppConfig{
{Name: "solo", AppId: "app-solo", AppSecret: core.PlainSecret("secret-solo"), Brand: core.BrandFeishu},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRemoveRun(f, "solo")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "cannot remove the only profile")
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
}
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
dir := setupProfileConfigDir(t)
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileListRun(f)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "failed to load config")
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped load error")
}
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -40,11 +41,12 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
idx := multi.FindAppIndex(name)
if idx < 0 {
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
}
if len(multi.Apps) == 1 {
return output.ErrValidation("cannot remove the only profile")
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile").
WithHint("add another profile first: lark-cli profile add")
}
app := &multi.Apps[idx]
@@ -65,7 +67,7 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
// Best-effort credential cleanup after config commit

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
@@ -30,7 +31,7 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
if err := core.ValidateProfileName(newName); err != nil {
return output.ErrValidation("%v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
}
multi, err := core.LoadOrNotConfigured()
@@ -40,7 +41,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
idx := multi.FindAppIndex(oldName)
if idx < 0 {
return output.ErrValidation("profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
}
// Check new name uniqueness across other profiles, allowing renames to this
@@ -50,7 +51,8 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
continue
}
if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName {
return output.ErrValidation("profile %q already exists", newName)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", newName).
WithHint("choose a different name")
}
}
@@ -66,7 +68,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName))

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
@@ -40,14 +41,15 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
// Handle "-" for toggle-back
if name == "-" {
if multi.PreviousApp == "" {
return output.ErrValidation("no previous profile to switch back to")
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "no previous profile to switch back to").
WithHint("switch to a profile by name first: lark-cli profile use <name>")
}
name = multi.PreviousApp
}
app := multi.FindApp(name)
if app == nil {
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
}
targetName := app.ProfileName()
@@ -66,7 +68,7 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
multi.CurrentApp = targetName
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q (%s, %s)", targetName, app.AppId, app.Brand))

View File

@@ -9,10 +9,10 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// pruneForStrictMode removes commands incompatible with the active strict mode.
@@ -65,10 +65,10 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
// pick auth's instead of our denial. A leaf-level no-op makes
// cobra stop here and proceed to the wrapped RunE.
//
// strict-mode keeps its short Message + independent Hint and
// composes the shared detail.* / wrapped-CommandDeniedError shape
// by hand; BuildDenialError would override Message with the
// CommandDeniedError.Error() long form.
// strict-mode keeps its short Message + independent Hint and wraps
// the CommandDeniedError as the Cause by hand; BuildDenialError
// would override Message with the CommandDeniedError.Error() long
// form.
stubMessage := fmt.Sprintf(
"strict mode is %q, only %s-identity commands are available",
mode, mode.ForcedIdentity())
@@ -105,20 +105,9 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
},
RunE: func(c *cobra.Command, _ []string) error {
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
// Legacy *output.ExitError producer: this literal predates the
// typed error contract introduced by errs/. New denial sites MUST
// NOT construct *output.ExitError directly — they should return a
// typed *errs.XxxError once the cmdpolicy framework migrates.
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "command_denied",
Message: stubMessage,
Hint: stubHint,
Detail: cmdpolicy.DenialDetailMap(cd),
},
Err: cd,
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
WithCause(cd)
},
}
}

View File

@@ -8,6 +8,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -247,9 +248,12 @@ func TestStrictModeStub_BypassesArgsValidator(t *testing.T) {
}
}
// Pins the strict-mode envelope shape: structured detail.* / wrapped
// CommandDeniedError for external agents, AND the historical short
// Message + independent Hint for existing consumers.
// Pins the strict-mode typed envelope: a failed_precondition
// *errs.ValidationError (exit 2) carrying the short historical Message,
// a Hint that still surfaces the policy layer + reason code (the
// safety-critical recovery info that lived in the legacy detail map),
// and the wrapped *platform.CommandDeniedError so external agents can
// still inspect the structured denial taxonomy via errors.As.
func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
@@ -262,30 +266,33 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
t.Fatalf("strict-mode stub RunE should return error")
}
var ee *output.ExitError
if !errors.As(err, &ee) {
t.Fatalf("err is not *output.ExitError: %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("err is not *errs.ValidationError: %T", err)
}
if ee.Detail == nil {
t.Fatalf("ExitError.Detail is nil; envelope writer cannot emit JSON")
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if ee.Detail.Type != "command_denied" {
t.Errorf("Detail.Type = %q, want command_denied", ee.Detail.Type)
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
dm, ok := ee.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("Detail.Detail = %T, want map[string]any", ee.Detail.Detail)
// Short historical Message is preserved verbatim.
if verr.Message != `strict mode is "bot", only bot-identity commands are available` {
t.Errorf("Message = %q, want short historical form", verr.Message)
}
if got, _ := dm["layer"].(string); got != cmdpolicy.LayerStrictMode {
t.Errorf("Detail.Detail[layer] = %q, want %q", got, cmdpolicy.LayerStrictMode)
// The denial layer + reason code remain user-readable in the hint, and
// the historical switch-policy guidance is still appended.
if !strings.Contains(verr.Hint, cmdpolicy.LayerStrictMode) {
t.Errorf("Hint = %q, want substring %q (policy layer)", verr.Hint, cmdpolicy.LayerStrictMode)
}
if got, _ := dm["reason_code"].(string); got != "identity_not_supported" {
t.Errorf("Detail.Detail[reason_code] = %q, want identity_not_supported", got)
if !strings.Contains(verr.Hint, "identity_not_supported") {
t.Errorf("Hint = %q, want substring identity_not_supported (reason code)", verr.Hint)
}
if got, _ := dm["policy_source"].(string); got != "strict-mode" {
t.Errorf("Detail.Detail[policy_source] = %q, want strict-mode", got)
if !strings.Contains(verr.Hint, "if the user explicitly wants to switch policy") {
t.Errorf("Hint = %q, want historical switch-policy guidance", verr.Hint)
}
// The structured denial taxonomy survives on the wrapped cause.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("err does not unwrap to *platform.CommandDeniedError")
@@ -296,15 +303,12 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
if cd.ReasonCode != "identity_not_supported" {
t.Errorf("CommandDeniedError.ReasonCode = %q, want identity_not_supported", cd.ReasonCode)
}
if cd.PolicySource != "strict-mode" {
t.Errorf("CommandDeniedError.PolicySource = %q, want strict-mode", cd.PolicySource)
}
if !strings.Contains(cd.Reason, `strict mode is "bot"`) {
t.Errorf("CommandDeniedError.Reason = %q, want substring 'strict mode is \"bot\"'", cd.Reason)
}
if ee.Detail.Message != `strict mode is "bot", only bot-identity commands are available` {
t.Errorf("Detail.Message = %q, want short historical form", ee.Detail.Message)
}
if !strings.HasPrefix(ee.Detail.Hint, "if the user explicitly wants to switch policy") {
t.Errorf("Detail.Hint = %q, want historical hint", ee.Detail.Hint)
}
}
// strictModeStubFrom must write the denial annotations so the hook

View File

@@ -11,19 +11,16 @@ import (
"sort"
"strings"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
@@ -33,43 +30,60 @@ import (
const rootLong = `lark-cli — Lark/Feishu CLI tool.
USAGE:
lark-cli <command> [subcommand] [method] [options]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method>
AGENT QUICKSTART (driving this as an agent? start here):
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
Prefer a +shortcut over the raw API resource when one matches the task.
Risk: each command's --help shows read | write | high-risk-write;
high-risk-write needs --yes, only after the user confirms.
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
EXAMPLES:
# View upcoming events
lark-cli calendar +agenda
EXAMPLES (one per command style, in order of preference):
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
# List calendar events
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
// rootUsageTemplate is cobra's default usage template with two root-only
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
// footer. Subcommands render the stock template unchanged. The rest is verbatim
// cobra so the command groups and flags are untouched.
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{else}}Usage:
lark-cli <command> [subcommand] [method] [flags]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
# Search users
lark-cli contact +search-user --query "John"
Aliases:
{{.NameAndAliases}}{{end}}{{if .HasExample}}
# Generic API call
lark-cli api GET /open-apis/calendar/v4/calendars
Examples:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
AI AGENT SKILLS:
lark-cli pairs with AI agent skills (Claude Code, etc.) that
teach the agent Lark API patterns, best practices, and workflows.
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
Install all skills:
npx skills add larksuite/cli -g -y
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
Or pick specific domains:
npx skills add larksuite/cli -s lark-calendar -y
npx skills add larksuite/cli -s lark-im -y
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Learn more: https://github.com/larksuite/cli#agent-skills
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
COMMUNITY:
GitHub: https://github.com/larksuite/cli
Issues: https://github.com/larksuite/cli/issues
Docs: https://open.feishu.cn/document/
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
More help: lark-cli <command> --help`
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
`
// Execute runs the root command and returns the process exit code.
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
@@ -217,56 +231,37 @@ func configureFlagCompletions(args []string) {
// and returns the process exit code.
//
// Dispatch order:
// 1. Legacy shapes (*core.ConfigError, *internalauth.NeedAuthorizationError)
// are promoted via errcompat to their typed errs/ counterparts, with the
// original preserved in the Cause chain.
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
// *errs.SecurityPolicyError, *errs.AuthenticationError): render via the
// typed envelope writer, which lifts extension fields (missing_scopes,
// console_url, challenge_url, ...) to the top level. Routed by
// errs.CategoryOf via ExitCodeOf.
// 3. Legacy *output.ExitError: asExitError adapts it to the legacy
// envelope, written via WriteErrorEnvelope.
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
// 1. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
// *errs.SecurityPolicyError, *errs.AuthenticationError, *errs.ConfigError):
// render via the typed envelope writer, which lifts extension fields
// (missing_scopes, console_url, challenge_url, ...) to the top level.
// Routed by errs.CategoryOf via ExitCodeOf. Auth and config errors are
// constructed typed at their origin (internal/auth, internal/core), so the
// dispatcher no longer promotes any legacy shape here.
// 2. PartialFailure / BareError signals: the result envelope is already on
// stdout; honor the exit code and write nothing to stderr.
// 3. Residual cobra usage errors (missing required flag, unknown command,
// argument validation): typed as an invalid_argument envelope (exit 2),
// matching the explicit flag/subcommand guards. Flag parse errors are
// already typed upstream by the root FlagErrorFunc.
func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut
// Promote legacy error shapes into typed errs/ before envelope marshal.
// NeedAuthorizationError check is first because it is the more specific
// shape; *core.ConfigError check follows. errors.As preserves the original
// in the Cause chain, so external errors.As(&core.ConfigError{}) consumers
// (cmd/auth/list.go, cmd/doctor/doctor.go, ...) still match.
//
// Outer-typed short-circuit: if err is already a typed *errs.* error,
// skip PromoteXxxError so the producer's Subtype / Hint / extension
// fields are not overwritten by a coarser promoted shape derived from a
// legacy error buried in its Cause chain. Promotion is only for legacy
// untyped entry points.
if !isOuterTypedError(err) {
var needAuthErr *internalauth.NeedAuthorizationError
if errors.As(err, &needAuthErr) {
err = errcompat.PromoteAuthError(needAuthErr)
} else {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
err = errcompat.PromoteConfigError(cfgErr)
}
}
}
// When the typed error is a need_user_authorization signal, fold in the
// current command's declared scopes as a Hint so the user/AI sees the
// concrete scope(s) to re-auth with. The hint is computed on the fly from
// local shortcut/service metadata — it never depends on server state.
applyNeedAuthorizationHint(f, err)
if !errs.IsRaw(err) {
applyNeedAuthorizationHint(f, err)
}
// Staged dispatch: capture the typed exit code BEFORE attempting the
// envelope write. WriteTypedErrorEnvelope is best-effort on the wire
// (partial-write still returns true) so the exit code we read here is
// preserved even if stderr is torn — torn stderr must not downgrade
// typed exits 3/4/6/10 to the legacy "Error:" path with exit 1.
// typed exits 3/4/6/10 to the plain "Error:" path with exit 1.
// WriteTypedErrorEnvelope still returns false when err carries no
// Problem; in that case we fall through to the legacy bridge below.
// Problem; in that case we fall through to the signal / plain-text paths.
typedExit := output.ExitCodeOf(err)
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
return typedExit
@@ -279,58 +274,63 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return pfErr.Code
}
if exitErr := asExitError(err); exitErr != nil {
if !exitErr.Raw {
// Raw errors (e.g. from `api` command via output.MarkRaw)
// preserve the original API error detail; skip enrichment
// which would clear it.
enrichMissingScopeError(f, exitErr)
enrichPermissionError(f, exitErr)
// Silent-exit signal (e.g. `auth check` predicate, or `update --json`):
// stdout already carries the result; honor the requested exit code and
// write nothing to stderr.
var bareErr *output.BareError
if errors.As(err, &bareErr) {
return bareErr.Code
}
// Errors reaching here are untyped: every RunE returns a typed errs.* error
// and flag-parse errors are typed by the root FlagErrorFunc. The remainder
// is either a cobra usage mistake (missing required flag, unknown command,
// wrong arg count), which cobra surfaces as a plain error identified by its
// stable text — the same external contract unknownFlagName relies on — or an
// untyped error that leaked past the typed boundary. Classify the former as
// invalid_argument (exit 2, like the explicit guards); treat the latter as an
// internal fault (exit 5) rather than blaming the user's input. The message
// is preserved either way, and the typed envelope still carries any pending
// deprecation notice.
var fallback error
if isCobraUsageError(err) {
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error())
} else {
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
}
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
return output.ExitCodeOf(fallback)
}
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
// not a typed value we can match on, so the dispatcher recognizes them by text;
// this is the same external contract unknownFlagName already depends on. A
// residual error matching none of these has leaked the typed boundary and is
// treated as an internal fault, not a user error.
var cobraUsageErrorMarkers = []string{
"unknown command ",
"unknown flag: ",
"unknown shorthand",
"required flag(s) ",
"flag needs an argument",
"bad flag syntax:",
"no such flag ",
"invalid argument ",
"arg(s), ", // accepts / requires N arg(s), received / only received M
}
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
// identified by the stable error text of the pinned cobra version.
func isCobraUsageError(err error) bool {
msg := err.Error()
for _, m := range cobraUsageErrorMarkers {
if strings.Contains(msg, m) {
return true
}
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
return exitErr.Code
}
// A backward-compat alias records its deprecation notice in PreRunE, which
// runs before cobra's required-flag validation — but a missing required flag
// fails before RunE and lands here, where the bare "Error:" line would drop
// the notice. When a deprecation is pending, route through the structured
// envelope so the migration hint still reaches the caller; all other errors
// keep the existing plain output.
if deprecation.GetPending() != nil {
output.WriteErrorEnvelope(errOut, &output.ExitError{
Code: 1,
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
}, string(f.ResolvedIdentity))
return 1
}
fmt.Fprintln(errOut, "Error:", err)
return 1
}
// isOuterTypedError returns true if err is a typed *errs.* error AT THE
// TOP OF THE CHAIN (not buried inside Unwrap). Used by handleRootError
// to gate PromoteXxxError so a producer's outer typed envelope is never
// overwritten by a coarser shape derived from its legacy Cause.
func isOuterTypedError(err error) bool {
_, ok := err.(errs.TypedError)
return ok
}
// asExitError converts known structured error types to *output.ExitError.
// Returns nil for unrecognized errors (e.g. cobra flag errors).
//
// Deprecated: legacy *output.ExitError bridge.
func asExitError(err error) *output.ExitError {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return exitErr
}
return nil
return false
}
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
@@ -361,13 +361,10 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
}
}
// Deprecated: unknownSubcommandRunE produces a legacy *output.ExitError that
// predates the typed error contract introduced by errs/. New code MUST NOT
// add producers of this shape — unknown-subcommand signals should move to
// a typed *errs.ValidationError (or a dedicated typed error) carrying the
// agent-protocol metadata as typed extension fields. This helper is retained
// only while existing dispatch sites are migrated; it will be removed once
// they have moved to the typed surface.
// unknownSubcommandRunE replaces cobra's silent help fallback on group commands
// with a typed *errs.ValidationError: a flag that belongs to a missing
// subcommand, a misplaced subcommand-only flag, or an unknown subcommand name
// each fail structured (exit 2) instead of degrading to help + exit 0.
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
@@ -383,28 +380,13 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
return cmd.Help()
}
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
// Keep the same detail keys as flagDidYouMean's unknown_flag
// so a consumer keyed on Type can read a stable shape. The
// subcommand isn't resolved here, so suggestions/valid_flags
// have no meaningful universe to draw from — emit empty
// rather than the group's own (misleading) flags. unknown is
// the back-compat singular field; unknown_flags carries the
// full list when more than one flag was supplied.
"unknown": strings.Join(unknown, ", "),
"unknown_flags": unknown,
"command_path": cmd.CommandPath(),
"suggestions": []string{},
"valid_flags": []string{},
},
},
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()).
WithHint("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath())
for _, flag := range unknown {
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "unknown flag before a subcommand"})
}
return verr
}
// The remaining flags are all defined somewhere in the tree. Those valid
// on the group itself or inherited (e.g. the global --profile) do not
@@ -416,19 +398,13 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(misplaced) == 0 {
return cmd.Help()
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "missing_subcommand",
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
"command_path": cmd.CommandPath(),
"flags": misplaced,
"suggestions": []string{},
},
},
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")).
WithHint("run `%s --help` to list subcommands and their flags", cmd.CommandPath())
for _, flag := range misplaced {
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "flag belongs to a subcommand, not the group"})
}
return verr
}
unknown := args[0]
available, deprecated := availableSubcommandNames(cmd)
@@ -442,27 +418,12 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
strings.Join(suggestions, ", "), cmd.CommandPath())
}
detail := map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available,
}
// Only services with backward-compat aliases (currently sheets) carry a
// deprecated bucket; omit the key elsewhere so every other service's
// envelope is unchanged.
if len(deprecated) > 0 {
detail["deprecated"] = deprecated
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_subcommand",
Message: msg,
Hint: hint,
Detail: detail,
},
}
// Record the offending subcommand and its ranked candidates as a param with
// machine-readable Suggestions so an agent can retry without parsing the
// hint; the hint carries the same candidates as prose.
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
WithParams(errs.InvalidParam{Name: unknown, Reason: "unknown subcommand", Suggestions: suggestions}).
WithHint("%s", hint)
}
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
@@ -587,48 +548,78 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
return available, deprecated
}
// Root command help groups, so an agent sees content domains, agent tooling, and
// CLI management as distinct blocks instead of one flat alphabetical dump.
const (
groupDomains = "lark-domains"
groupTooling = "agent-tooling"
groupManagement = "cli-management"
)
// groupRootCommands classifies root's direct children into the help groups,
// called once after all commands are registered. Unclassified commands fall to
// cobra's "Additional Commands" section.
func groupRootCommands(root *cobra.Command) {
root.AddGroup(
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
)
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
for _, c := range root.Commands() {
if c.GroupID != "" {
continue
}
switch {
case tooling[c.Name()]:
c.GroupID = groupTooling
case management[c.Name()]:
c.GroupID = groupManagement
case isLarkDomain(c):
c.GroupID = groupDomains
}
}
}
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
func isLarkDomain(c *cobra.Command) bool {
if src, _ := cmdmeta.SourceOf(c); src == cmdmeta.SourceService {
return true
}
return cmdmeta.Domain(c) != ""
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
// --find, where edit distance alone finds nothing). Other flag errors stay
// structured but generic.
// converts cobra's flag-parse errors into a typed validation envelope: an
// unknown flag gets a focused "did you mean" hint (so agents recover even when
// the typo is semantic, e.g. --query vs --find, where edit distance alone finds
// nothing) and the offending flag in `params`. Other flag errors stay typed
// but generic.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "flag_error",
Message: ferr.Error(),
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
},
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if len(suggestions) > 0 {
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
Hint: hint,
Detail: map[string]any{
"unknown": "--" + name,
"command_path": c.CommandPath(),
"suggestions": suggestions,
"valid_flags": valid,
},
},
}
// The ranked candidates ride on the param as machine-readable Suggestions so
// an agent can retry without parsing the hint; the hint carries the same
// candidates as prose. The full valid-flag list stays recoverable via --help.
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
WithHint("%s", hint)
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
@@ -681,6 +672,17 @@ func installTipsHelpFunc(root *cobra.Command) {
defer func() { f.Hidden = true }()
}
}
// Domain and method commands compose their agent guidance into Long lazily
// here (shortcuts attach after service registration); both skip the generic
// bottom-of-help append below.
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}
if service.PrepareMethodHelp(cmd) {
defaultHelp(cmd, args)
return
}
defaultHelp(cmd, args)
out := cmd.OutOrStdout()
if level, ok := cmdutil.GetRisk(cmd); ok {
@@ -698,56 +700,3 @@ func installTipsHelpFunc(root *cobra.Command) {
}
})
}
// enrichPermissionError rewrites the legacy *output.ExitError envelope so its
// Message + Hint match the per-subtype canonical text produced by the typed
// dispatcher path (errclass.CanonicalPermissionMessage / errclass.PermissionHint).
// This guarantees a caller observing the wire envelope cannot tell whether
// the error reached the dispatcher via the legacy *ExitError bridge or via
// the typed *errs.PermissionError fast path.
//
// Deprecated: legacy *output.ExitError enrichment; typed PermissionError
// values produced by errclass.BuildAPIError already carry MissingScopes +
// ConsoleURL directly.
func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr.Detail == nil {
return
}
// Only the legacy permission-class envelope types route here. "app_status"
// covers 99991662 (app_disabled) / 99991673 (app_unavailable); "permission"
// covers the four scope-class codes (99991672 / 99991676 / 99991679 / 230027).
if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "app_status" {
return
}
larkCode := exitErr.Detail.Code
meta, ok := errclass.LookupCodeMeta(larkCode)
if !ok || meta.Category != errs.CategoryAuthorization {
return
}
// Extract required scopes from API error detail (shared helper). May be
// empty for app-status codes — canonical message + hint still apply.
missing := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
cfg, err := f.Config()
if err != nil {
return
}
// Reuse the same console URL builder as the typed path so both wire
// envelopes carry identical console_url values for the same input.
consoleURL := errclass.ConsoleURL(string(cfg.Brand), cfg.AppID, missing)
// Clear raw API detail — useful info is now in message/hint/console_url.
exitErr.Detail.Detail = nil
identity := string(f.ResolvedIdentity)
if identity == "" {
identity = "user"
}
exitErr.Detail.Message = errclass.CanonicalPermissionMessage(meta.Subtype, cfg.AppID, missing, exitErr.Detail.Message)
exitErr.Detail.Hint = errclass.PermissionHint(missing, identity, meta.Subtype, consoleURL)
exitErr.Detail.ConsoleURL = consoleURL
}

View File

@@ -8,7 +8,6 @@ import (
"context"
"encoding/json"
"os"
"reflect"
"strings"
"testing"
@@ -27,12 +26,12 @@ import (
"github.com/spf13/cobra"
)
// Canonical strict-mode envelope strings shared across fixtures
// (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom).
// Canonical strict-mode envelope messages shared across fixtures. The
// switch-policy hint text is asserted by substring in
// assertStrictModeDenialEnvelope.
const (
strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available`
strictModeUserMessage = `strict mode is "user", only user-identity commands are available`
strictModeHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)"
)
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
@@ -63,37 +62,46 @@ func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Com
return 0
}
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
// typedErrorEnvelope mirrors the typed wire shape produced by
// WriteTypedErrorEnvelope: the inner error marshals an errs.Problem
// directly, so "type" is the category, "subtype" is top-level, and there
// is no nested "detail" object. Recovery info (policy source, reason
// code, suggestions) is folded into "hint".
type typedErrorEnvelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Error struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
Message string `json:"message"`
Hint string `json:"hint"`
Param string `json:"param,omitempty"`
} `json:"error"`
}
// parseTypedEnvelope decodes stderr as the typed envelope and fails if the
// legacy nested "detail" object is present (the migration removed it).
func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
t.Helper()
if stderr.Len() == 0 {
t.Fatal("expected non-empty stderr, got empty")
}
var env output.ErrorEnvelope
var raw map[string]any
if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil {
t.Fatalf("failed to parse stderr as JSON: %v\nstderr: %s", err, stderr.String())
}
if errObj, ok := raw["error"].(map[string]any); ok {
if _, hasDetail := errObj["detail"]; hasDetail {
t.Errorf("typed envelope must not carry a nested 'detail' object, got: %s", stderr.String())
}
}
var env typedErrorEnvelope
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
t.Fatalf("failed to parse stderr as typed envelope: %v\nstderr: %s", err, stderr.String())
}
return env
}
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
// expected ErrorEnvelope exactly via reflect.DeepEqual.
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
t.Helper()
if code != wantCode {
t.Errorf("exit code: got %d, want %d", code, wantCode)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
got := parseEnvelope(t, stderr)
if !reflect.DeepEqual(got, want) {
gotJSON, _ := json.MarshalIndent(got, "", " ")
wantJSON, _ := json.MarshalIndent(want, "", " ")
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
}
}
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"}
@@ -205,23 +213,71 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
// auth login is user-only, so it gets pruned in strict-mode-bot and the
// stub error fires (not login.go's inline check, which is shadowed by
// pruning).
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeBotMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "auth/login",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeBotMessage,
},
},
})
// pruning). The typed envelope is a failed_precondition validation
// error (exit 2); the strict-mode layer + reason code are folded into
// the hint.
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage)
}
// assertStrictModeDenialEnvelope pins the shared strict-mode denial shape:
// a validation/failed_precondition envelope whose message is the short
// historical strict-mode line and whose hint still names the strict_mode
// layer + identity_not_supported reason code (the safety-critical recovery
// info), plus the historical switch-policy guidance.
func assertStrictModeDenialEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) {
t.Helper()
if env.OK {
t.Errorf("envelope ok = true, want false")
}
if env.Error.Type != "validation" {
t.Errorf("error.type = %q, want validation", env.Error.Type)
}
if env.Error.Subtype != "failed_precondition" {
t.Errorf("error.subtype = %q, want failed_precondition", env.Error.Subtype)
}
if env.Error.Message != wantMessage {
t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage)
}
if !strings.Contains(env.Error.Hint, "strict_mode") {
t.Errorf("error.hint = %q, want substring strict_mode (policy layer)", env.Error.Hint)
}
if !strings.Contains(env.Error.Hint, "identity_not_supported") {
t.Errorf("error.hint = %q, want substring identity_not_supported (reason code)", env.Error.Hint)
}
if !strings.Contains(env.Error.Hint, "config strict-mode --help") {
t.Errorf("error.hint = %q, want historical switch-policy guidance", env.Error.Hint)
}
}
// assertCheckStrictModeEnvelope pins the typed envelope produced by
// cmdutil.Factory.CheckStrictMode (the identity-guard path for explicit
// --as on shortcuts / service methods / api): a *errs.ValidationError with
// subtype invalid_argument, the canonical strict-mode message, and the
// switch-policy hint.
func assertCheckStrictModeEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) {
t.Helper()
if env.OK {
t.Errorf("envelope ok = true, want false")
}
if env.Error.Type != "validation" {
t.Errorf("error.type = %q, want validation", env.Error.Type)
}
if env.Error.Subtype != "invalid_argument" {
t.Errorf("error.subtype = %q, want invalid_argument", env.Error.Subtype)
}
if env.Error.Message != wantMessage {
t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage)
}
if !strings.Contains(env.Error.Hint, "config strict-mode --help") {
t.Errorf("error.hint = %q, want switch-policy guidance", env.Error.Hint)
}
}
func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) {
@@ -232,22 +288,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
"im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeBotMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "im/+messages-search",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeBotMessage,
},
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage)
}
func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) {
@@ -277,15 +325,14 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
"im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "user", only user-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeUserMessage)
}
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
@@ -296,15 +343,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage)
}
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
@@ -315,22 +361,14 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeUserMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "im/images/create",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeUserMessage,
},
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeUserMessage)
}
func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) {
@@ -341,15 +379,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage)
}
// --- shortcut command ---
@@ -372,16 +409,43 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
})
// shortcut: typed error via DoAPIJSON path
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api",
Code: 230002,
Message: "Bot/User can NOT be out of the chat.",
},
})
// shortcut: typed errs.APIError via the CallAPITyped → BuildAPIError path.
if code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", code, output.ExitAPI)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
if stderr.Len() == 0 {
t.Fatal("expected non-empty stderr, got empty")
}
var raw struct {
OK bool `json:"ok"`
Identity string `json:"identity"`
Error struct {
Type string `json:"type"`
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil {
t.Fatalf("failed to parse typed envelope: %v\nstderr: %s", err, stderr.String())
}
if raw.OK {
t.Errorf("envelope ok = true, want false")
}
if raw.Identity != "bot" {
t.Errorf("identity = %q, want bot", raw.Identity)
}
if raw.Error.Type != "api" {
t.Errorf("error.type = %q, want api", raw.Error.Type)
}
if raw.Error.Code != 230002 {
t.Errorf("error.code = %d, want 230002", raw.Error.Code)
}
if raw.Error.Message != "Bot/User can NOT be out of the chat." {
t.Errorf("error.message = %q, want %q", raw.Error.Message, "Bot/User can NOT be out of the chat.")
}
}
// TestSetupNotices_ColdStart_NoNotice verifies that missing state

View File

@@ -76,11 +76,13 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
}
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
// The human skills-install guidance now lives in the root usage-template
// footer (below the command list), not in the agent-facing Long.
if !strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help footer should link to the README Agent Skills section, got:\n%s", rootUsageTemplate)
}
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
if strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootUsageTemplate)
}
}
@@ -137,9 +139,6 @@ func TestIsCompletionCommand(t *testing.T) {
}
}
// TestPromoteConfigError_* lives with the implementation in
// internal/errcompat/promote_test.go.
// TestHandleRootError_SecurityPolicyCanonicalEnvelope verifies that
// *errs.SecurityPolicyError flows through the canonical typed envelope
// (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype,
@@ -269,12 +268,11 @@ func (f *failingWriter) Write(p []byte) (int, error) {
return len(p), nil
}
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
// backward-compat alias that fails on a cobra-level required flag (which
// short-circuits before RunE) still routes through the structured envelope,
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
// switches to WriteErrorEnvelope when a deprecation is pending — so the
// migration notice is no longer dropped on the plain "Error:" line.
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins that a
// backward-compat alias failing on a cobra-level required flag (which
// short-circuits before RunE) routes through the structured envelope, so the
// deprecation notice OnInvoke records in PreRunE is carried on the wire instead
// of being dropped on a plain "Error:" line.
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
@@ -286,9 +284,9 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
deprecation.SetPending(&deprecation.Notice{
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
})
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
// nor an *output.ExitError, so it reaches the legacy fallback.
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
// errs.* error, so it reaches the deprecation fallback.
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
@@ -297,12 +295,96 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
}
// The envelope is typed validation, so the exit code must derive from that
// category (2) — the wire type and the exit code must not disagree.
if exit != int(output.ExitValidation) {
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
}
}
// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
// fix does not reshape every unrecognized cobra error.
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
// TestHandleRootError_AuthConfigWireGolden is the wire-consistency regression
// baseline for auth/config errors: it pins the typed envelope and exit code the
// dispatcher produces for the two source-of-truth shapes, which are constructed
// typed at their origin in internal/auth and internal/core.
func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Run("token missing exits 3 with token_missing authentication envelope", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"))
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "authentication" {
t.Errorf("error.type = %v, want %q", got, "authentication")
}
if got := errObj["subtype"]; got != "token_missing" {
t.Errorf("error.subtype = %v, want %q", got, "token_missing")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "need_user_authorization") {
t.Errorf("error.message = %q, must keep the need_user_authorization marker", got)
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "u_golden") {
t.Errorf("error.message = %q, must carry the user open id", got)
}
if got, _ := errObj["hint"].(string); !strings.Contains(got, "auth login") {
t.Errorf("error.hint = %q, must point at auth login", got)
}
if got := errObj["user_open_id"]; got != "u_golden" {
t.Errorf("error.user_open_id = %v, want %q", got, "u_golden")
}
})
t.Run("not configured exits 3 with not_configured config envelope", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, core.NotConfiguredError())
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (config shares ExitAuth)", exit, int(output.ExitAuth))
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "config" {
t.Errorf("error.type = %v, want %q", got, "config")
}
if got := errObj["subtype"]; got != "not_configured" {
t.Errorf("error.subtype = %v, want %q", got, "not_configured")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "not configured") {
t.Errorf("error.message = %q, want the not-configured message", got)
}
if got, _ := errObj["hint"].(string); !strings.Contains(got, "config init") {
t.Errorf("error.hint = %q, must point at config init", got)
}
})
}
// decodeErrorEnvelope unmarshals a typed error envelope and returns its
// top-level "error" object, failing the test if the shape is unexpected.
func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any {
t.Helper()
var env map[string]any
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("envelope is not valid JSON: %v\n%s", err, raw)
}
errObj, ok := env["error"].(map[string]any)
if !ok {
t.Fatalf("envelope missing top-level error object: %s", raw)
}
return errObj
}
// TestHandleRootError_NoDeprecationTypesUsageError pins that a residual cobra
// usage error (missing required flag) is typed as invalid_argument with exit 2
// even with no deprecation pending — never cobra's plain "Error:" line.
func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
@@ -311,9 +393,45 @@ func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
if !strings.HasPrefix(errOut.String(), "Error:") {
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
t.Fatalf("want a structured envelope, got a plain Error: line:\n%s", out)
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "validation" {
t.Errorf("error.type = %v, want %q", got, "validation")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "values") {
t.Errorf("error.message = %q, must carry the failing flag name", got)
}
if exit != int(output.ExitValidation) {
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
}
}
// TestHandleRootError_LeakedUntypedErrorBecomesInternal pins that an untyped
// error that does NOT match a cobra usage shape (i.e. one that leaked past the
// typed boundary from a helper) is classified as an internal fault (exit 5),
// not blamed on the user's input as a validation error.
func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF))
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "internal" {
t.Errorf("error.type = %v, want %q (leaked untyped error must not be mislabeled validation)", got, "internal")
}
if exit != int(output.ExitInternal) {
t.Errorf("exit = %d, want %d (internal envelope → category-derived exit)", exit, int(output.ExitInternal))
}
}
@@ -337,12 +455,32 @@ func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) {
}
}
// TestHandleRootError_TypedOuterShortCircuitsPromote pins that when a typed
// *errs.AuthenticationError carries a legacy *NeedAuthorizationError in its
// Cause chain, the dispatcher does NOT run PromoteAuthError — doing so
// would replace the producer's TokenExpired subtype + custom hint with the
// promoted shape's TokenMissing.
func TestHandleRootError_TypedOuterShortCircuitsPromote(t *testing.T) {
// TestHandleRootError_BareErrorExitCodeNoStderr pins the silent-exit
// contract: a *output.BareError is honored for its exit code while stderr stays
// empty (stdout already carries the result, so the dispatcher must not layer a
// second envelope on top).
func TestHandleRootError_BareErrorExitCodeNoStderr(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, output.ErrBare(output.ExitAuth))
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (BareError code propagated)", exit, int(output.ExitAuth))
}
if errOut.Len() != 0 {
t.Errorf("stderr must stay empty for a bare predicate signal, got:\n%s", errOut.String())
}
}
// TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved pins that a typed
// *errs.AuthenticationError carrying a legacy *NeedAuthorizationError in its
// Cause chain renders the producer's TokenExpired subtype + custom hint
// verbatim — the legacy sentinel in the Cause chain never coarsens the wire
// shape.
func TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -494,136 +632,3 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
t.Errorf("expected appended hint %q, got %q", want, authErr.Hint)
}
}
// TestEnrichPermissionError_CanonicalConvergence pins that the legacy
// *output.ExitError dispatch path produces the same canonical Message + Hint
// + ConsoleURL as the typed *errs.PermissionError dispatch path. Both paths
// share errclass.CanonicalPermissionMessage / errclass.PermissionHint /
// errclass.ConsoleURL — so a wire consumer cannot tell which path produced
// the envelope.
func TestEnrichPermissionError_CanonicalConvergence(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
larkCode int
legacyErrType string
wantMsgSubstrs []string
wantHintSubstrs []string
wantConsoleURL bool
wantNoAuthLogin bool // hint must not suggest `auth login`
}{
{
name: "99991672 app_scope_not_applied",
larkCode: 99991672,
legacyErrType: "permission",
wantMsgSubstrs: []string{"access denied", "app cli_test", "drive:drive:read"},
wantHintSubstrs: []string{"developer console", "open.feishu.cn"},
wantConsoleURL: true,
wantNoAuthLogin: true,
},
{
name: "99991679 missing_scope",
larkCode: 99991679,
legacyErrType: "permission",
wantMsgSubstrs: []string{"unauthorized", "user authorization"},
wantHintSubstrs: []string{"lark-cli auth login"},
},
{
name: "99991673 app_unavailable",
larkCode: 99991673,
legacyErrType: "app_status",
wantMsgSubstrs: []string{"unauthorized app", "app cli_test", "not properly installed"},
wantHintSubstrs: []string{"tenant admin", "install status"},
},
{
name: "99991662 app_disabled",
larkCode: 99991662,
legacyErrType: "app_status",
wantMsgSubstrs: []string{"app cli_test", "not in use", "currently disabled"},
wantHintSubstrs: []string{"tenant admin", "re-enable"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
// Mimic the wire shape ErrAPI produces: legacy *ExitError with
// Detail.Type populated by ClassifyLarkError, Detail.Detail
// carrying the permission_violations block so ExtractRequiredScopes
// can recover the missing scope.
scopeForDetail := "drive:drive:read"
exitErr := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: tc.legacyErrType,
Code: tc.larkCode,
Message: "upstream raw message — must be replaced",
Detail: map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": scopeForDetail},
},
},
},
}
enrichPermissionError(f, exitErr)
for _, sub := range tc.wantMsgSubstrs {
if !strings.Contains(exitErr.Detail.Message, sub) {
t.Errorf("Message %q missing substring %q", exitErr.Detail.Message, sub)
}
}
if exitErr.Detail.Message == "upstream raw message — must be replaced" {
t.Errorf("Message must be rewritten to canonical text; got upstream verbatim")
}
for _, sub := range tc.wantHintSubstrs {
if !strings.Contains(exitErr.Detail.Hint, sub) {
t.Errorf("Hint %q missing substring %q", exitErr.Detail.Hint, sub)
}
}
if tc.wantNoAuthLogin && strings.Contains(exitErr.Detail.Hint, "auth login") {
t.Errorf("Hint must not suggest `auth login` for this subtype; got %q", exitErr.Detail.Hint)
}
if tc.wantConsoleURL && exitErr.Detail.ConsoleURL == "" {
t.Error("ConsoleURL should be populated when missing scopes are present")
}
})
}
}
// TestEnrichPermissionError_SkipsUnrelatedTypes pins that an ExitError whose
// Detail.Type is neither "permission" nor "app_status" is left untouched —
// no Message rewrite, no Hint rewrite, no ConsoleURL injection.
func TestEnrichPermissionError_SkipsUnrelatedTypes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
for _, ty := range []string{"api_error", "validation", "rate_limit", "auth"} {
exitErr := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: ty,
Code: 99991400,
Message: "untouched",
Hint: "original hint",
},
}
enrichPermissionError(f, exitErr)
if exitErr.Detail.Message != "untouched" {
t.Errorf("type=%q: Message was rewritten unexpectedly: %q", ty, exitErr.Detail.Message)
}
if exitErr.Detail.Hint != "original hint" {
t.Errorf("type=%q: Hint was rewritten unexpectedly: %q", ty, exitErr.Detail.Hint)
}
if exitErr.Detail.ConsoleURL != "" {
t.Errorf("type=%q: ConsoleURL should not be injected; got %q", ty, exitErr.Detail.ConsoleURL)
}
}
}

View File

@@ -5,9 +5,11 @@ package schema
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
@@ -209,6 +211,45 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
if !strings.Contains(err.Error(), "Unknown service") {
t.Errorf("expected 'Unknown service' error, got: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(ve.Hint, "Available:") {
t.Errorf("expected hint listing available services, got: %q", ve.Hint)
}
}
// TestSchemaCmd_UnknownMethod_TypedValidation pins the typed envelope for the
// JSON-mode unknown-method path: *errs.ValidationError with
// subtype invalid_argument and a hint listing the available methods.
func TestSchemaCmd_UnknownMethod_TypedValidation(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"calendar.events.nonexistent_method"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for unknown method")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(err.Error(), "Unknown method") {
t.Errorf("expected 'Unknown method' error, got: %v", err)
}
if !strings.Contains(ve.Hint, "Available:") {
t.Errorf("expected hint listing available methods, got: %q", ve.Hint)
}
}
// Completion candidate generation (dotted + space forms, strict-mode filtering,

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,12 +7,14 @@ import (
"context"
"fmt"
"io"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
@@ -32,13 +34,16 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
}
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
RegisterServiceCommandsFromCatalog(ctx, parent, f, registry.RuntimeCatalog())
}
func RegisterServiceCommandsFromCatalog(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory, catalog apicatalog.Catalog) {
// Drive the service list from the same navigation catalog the method walk
// uses — RuntimeCatalog().Services() is the deterministic, sorted view of the
// merged metadata — so registration is catalog-sourced end to end. Kept as a
// per-service loop rather than a flat WalkMethods(nil) drive precisely so a
// service with no methods still gets its bare command (WalkMethods yields one
// ref per method, so empty services would vanish).
for _, svc := range registry.RuntimeCatalog().Services() {
// uses, so registration is catalog-sourced end to end. Kept as a per-service
// loop rather than a flat WalkMethods(nil) drive precisely so a service with
// no methods still gets its bare command (WalkMethods yields one ref per
// method, so empty services would vanish).
for _, svc := range catalog.Services() {
if svc.Name == "" || svc.ServicePath == "" {
continue
}
@@ -60,15 +65,38 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc
// resource-command chain — one level for a flat dotted resource like
// "chat.members", deeper for genuinely nested resources. A service with no
// methods keeps its bare command (svcCmd is created above regardless).
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
refs := apicatalog.ServiceMethods(svc, nil)
// Collect each resource's verbs up front so resourceShort can summarize a
// resource as its verb list from the first ensureChildCommand call.
verbs := map[string][]string{}
for _, ref := range refs {
key := strings.Join(ref.ResourcePath, ".")
verbs[key] = append(verbs[key], ref.Method.Name)
}
for _, ref := range refs {
resCmd := svcCmd
var path []string
for _, seg := range ref.ResourcePath {
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
path = append(path, seg)
resCmd = ensureChildCommand(resCmd, seg, resourceShort(seg, verbs[strings.Join(path, ".")]))
}
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
}
}
// resourceShort summarizes a resource as its sorted verb list, or the
// "<name> operations" placeholder for an intermediate group with no methods.
func resourceShort(seg string, verbs []string) string {
if len(verbs) == 0 {
return seg + " operations"
}
sorted := append([]string(nil), verbs...)
sort.Strings(sorted)
return strings.Join(sorted, ", ")
}
// serviceShort is the service command's help summary: the localized description
// from the registry, falling back to the metadata's own description.
func serviceShort(svc meta.Service) string {
@@ -84,10 +112,12 @@ func serviceShort(svc meta.Service) string {
func ensureChildCommand(parent *cobra.Command, name, short string) *cobra.Command {
for _, c := range parent.Commands() {
if c.Name() == name {
cmdmeta.SetSource(c, cmdmeta.SourceService, true)
return c
}
}
cmd := &cobra.Command{Use: name, Short: short}
cmdmeta.SetSource(cmd, cmdmeta.SourceService, true)
parent.AddCommand(cmd)
return cmd
}
@@ -171,7 +201,19 @@ type methodCommandSpec struct {
// the API declares a body.
acceptsBody bool
declaresBody bool
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
paginates bool // method accepts a page_token param (so --page-all is meaningful)
serviceName string // owning service name (e.g. "approval"), for the lazy affordance lookup
}
// methodPaginates reports whether a method takes a page_token param, the signal
// that makes the --page-all/--page-limit/--page-delay flags meaningful.
func methodPaginates(m meta.Method) bool {
for _, f := range m.Params() {
if f.Name == "page_token" {
return true
}
}
return false
}
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
@@ -180,6 +222,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
method: m,
schemaPath: ref.SchemaPath(),
servicePath: ref.Service.ServicePath,
serviceName: ref.Service.Name,
risk: m.Risk,
restricts: m.RestrictsIdentity(),
identities: m.Identities(),
@@ -187,7 +230,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
fileFields: detectFileFields(m),
acceptsBody: methodTakesBody(m.HTTPMethod),
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
affordance: renderAffordance(m),
paginates: methodPaginates(m),
}
}
@@ -231,6 +274,7 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
return serviceMethodRun(opts)
},
}
cmdmeta.SetSource(cmd, cmdmeta.SourceService, true)
cmd.Flags().StringVar(&opts.Params, "params", "", "Raw URL/query params JSON. Supports - and @file.")
if spec.acceptsBody {
@@ -247,6 +291,14 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
// Keep the pagination flags registered (a harmless no-op if passed) but hide
// them from help on non-paginating commands, so help doesn't imply a
// get/write can paginate.
if !spec.paginates {
for _, name := range []string{"page-all", "page-limit", "page-delay"} {
_ = cmd.Flags().MarkHidden(name)
}
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
@@ -264,10 +316,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
// Registered last so the collision guard sees the standard flags above.
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
// Single composition point for Long: description, affordance, schema
// pointer, and the binder's params-only addendum (params whose flag name is
// taken, reachable via --params only).
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
// Build-time Long; the agent guidance is added lazily by PrepareMethodHelp
// (setMethodHelpData records the coordinates it needs).
paramsOnly := opts.binder.paramsOnlyHelp()
cmd.Long = methodLong(m.Description, spec.schemaPath, paramsOnly)
setMethodHelpData(cmd, spec.serviceName, m.ID, spec.schemaPath, paramsOnly)
// Group flags for the grouped --help renderer (typed param flags are grouped
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
@@ -285,13 +338,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
tagFlagGroup(cmd.Flags(), "file", groupBody)
if fl := cmd.Flags().Lookup("params"); fl != nil {
annotate(fl, flagGroupAnnotation, []string{groupRaw})
// State the precedence rule where the agent reads it: --params is the
// base, typed flags override. Only meaningful when typed flags exist.
// Keep the precedence rule on the flag's own one line (not a multi-line
// note that breaks the one-entry-per-flag rhythm an agent parses). Only
// meaningful when typed flags exist to override.
if len(spec.params) > 0 {
annotate(fl, flagNoteAnnotation, []string{
"Typed API parameter flags above are preferred.",
"If both are set, typed flags override matching keys in --params.",
})
fl.Usage = "Raw URL/query params JSON. Supports - and @file. If both set, typed flags override matching keys in --params."
}
}
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
@@ -380,7 +431,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
checkErr := ac.CheckResponse
if opts.PageAll {
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
}
@@ -620,20 +671,45 @@ func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *cor
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return err
@@ -643,7 +719,12 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
output.FormatValue(out, result, output.FormatJSON)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
return nil
default:
@@ -652,9 +733,14 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
output.FormatValue(out, result, format)
return nil
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -4,10 +4,15 @@
package service
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -407,8 +412,19 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "success") {
t.Errorf("expected 'success' in output, got:\n%s", stdout.String())
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
if got["ok"] != true || got["identity"] != "bot" {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if !ok || data["result"] != "success" {
t.Fatalf("data = %#v, want result=success", got["data"])
}
}
@@ -436,8 +452,312 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"id"`) {
t.Errorf("expected items in output, got:\n%s", stdout.String())
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("data.items = %#v, want one item", data["items"])
}
}
type serviceContentSafetyProvider struct {
called bool
path string
data interface{}
match string
}
func (p *serviceContentSafetyProvider) Name() string { return "service-test" }
func (p *serviceContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
p.called = true
p.path = req.Path
p.data = req.Data
if p.match != "" {
b, _ := json.Marshal(req.Data)
if !strings.Contains(string(b), p.match) {
return nil, nil
}
}
return &extcs.Alert{Provider: "service-test", MatchedRules: []string{"pagination"}}, nil
}
func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &serviceContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-safety", AppSecret: "test-secret-service-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan paginated output")
}
if provider.path != "list" {
t.Fatalf("scan path = %q, want list", provider.path)
}
data, ok := provider.data.(map[string]interface{})
if !ok {
t.Fatalf("scanned data type = %T, want map", provider.data)
}
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
alert, ok := got["_content_safety_alert"].(map[string]interface{})
if !ok || alert["provider"] != "service-test" {
t.Fatalf("missing content safety alert in envelope: %#v", got)
}
}
func TestServiceMethod_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &serviceContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-stream-safety", AppSecret: "test-secret-service-stream-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan streamed paginated output")
}
if provider.path != "list" {
t.Fatalf("scan path = %q, want list", provider.path)
}
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from service-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
}
if !strings.Contains(stdout.String(), `"id":"1"`) {
t.Fatalf("expected streamed ndjson output, got: %s", stdout.String())
}
}
func TestServiceMethod_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
provider := &serviceContentSafetyProvider{match: "blocked"}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-stream-block", AppSecret: "test-secret-service-stream-block", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
t.Fatal("expected content safety block error")
}
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
}
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
}
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "pagination" {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
}
}
func TestServiceMethod_BusinessErrorReturnsTypedErrorWithoutSuccessEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-err", AppSecret: "test-secret-service-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
}
func TestServiceMethod_PageAll_DefaultBusinessErrorOutputsRawResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-err", AppSecret: "test-secret-service-pageall-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
if !strings.Contains(stdout.String(), "230027") || !strings.Contains(stdout.String(), "user not authorized") {
t.Fatalf("expected raw error response on stdout, got: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
}
func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-stream-err", AppSecret: "test-secret-service-pageall-stream-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027,
"msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier successful page to remain streamed, got: %s", out)
}
if strings.Contains(out, "230027") || strings.Contains(out, "user not authorized") {
t.Fatalf("streaming stdout should not contain raw error JSON, got: %s", out)
}
if strings.Contains(out, "\n \"code\"") {
t.Fatalf("streaming stdout should not contain indented JSON error dump, got: %s", out)
}
}
@@ -629,6 +949,51 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
}
}
func TestServiceMethod_PageAll_WithJqBusinessErrorOutputsRawResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-spjq-err", AppSecret: "test-secret-spjq-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
}
if !strings.Contains(stdout.String(), "230027") || !strings.Contains(stdout.String(), "user not authorized") {
t.Fatalf("expected raw error response on stdout, got: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
}
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, code int) {
t.Helper()
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype || p.Code != code {
t.Fatalf("problem = %s/%s/%d, want %s/%s/%d", p.Category, p.Subtype, p.Code, category, subtype, code)
}
}
// ── file upload ──
func imImageMethod() meta.Method {

View File

@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -126,29 +127,20 @@ func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
}
// The detail must stay schema-compatible with flagDidYouMean's unknown_flag
// (same Type → same keys), so a consumer keyed on Type reads a stable shape.
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError with Detail, got %T", err)
// Typed surface: a validation error (exit 2) whose Params carries the
// offending flag so an agent can recover the token without parsing prose.
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail)
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
}
if detail["unknown"] != "--badflag" {
t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"])
}
if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" {
t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"])
}
for _, key := range []string{"suggestions", "valid_flags"} {
if _, present := detail[key]; !present {
t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--badflag" {
t.Errorf("params = %v, want one entry named --badflag", verr.Params)
}
}
@@ -172,25 +164,21 @@ func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing
if err == nil {
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
if !strings.Contains(verr.Message, "missing subcommand") {
t.Errorf("message = %q, want it to mention a missing subcommand", verr.Message)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
if len(verr.Params) != 1 || verr.Params[0].Name != "--query" {
t.Errorf("params = %v, want one entry named --query", verr.Params)
}
if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" {
t.Errorf("detail.flags = %v, want [--query]", detail["flags"])
}
if detail["command_path"] != "lark-cli drive" {
t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"])
if !strings.Contains(verr.Message, "lark-cli drive") {
t.Errorf("message = %q, want it to name the group path", verr.Message)
}
}
@@ -241,45 +229,23 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
t.Fatal("expected error for unknown subcommand")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("expected exit code %d, got %d", output.ExitValidation, exitErr.Code)
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("expected exit code %d, got %d", output.ExitValidation, output.ExitCodeOf(err))
}
if exitErr.Detail == nil {
t.Fatal("expected ExitError to carry Detail")
if !strings.Contains(verr.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", verr.Message)
}
if exitErr.Detail.Type != "unknown_subcommand" {
t.Errorf("expected Detail.Type=unknown_subcommand, got %q", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
if !strings.Contains(verr.Message, "lark-cli drive") {
t.Errorf("message should name the group path, got %q", verr.Message)
}
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
// back to pointing at --help; the full machine-readable list lives in
// detail.available below (which also excludes hidden commands).
if !strings.Contains(exitErr.Detail.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("expected Detail.Detail to be map[string]any, got %T", exitErr.Detail.Detail)
}
if detail["unknown"] != "+bogus" {
t.Errorf("detail.unknown should be +bogus, got %v", detail["unknown"])
}
if detail["command_path"] != "lark-cli drive" {
t.Errorf("detail.command_path should be %q, got %v", "lark-cli drive", detail["command_path"])
}
available, ok := detail["available"].([]string)
if !ok {
t.Fatalf("detail.available should be []string, got %T", detail["available"])
}
if len(available) != 3 {
t.Errorf("expected 3 available entries (hidden excluded), got %d: %v", len(available), available)
// back to pointing at --help (suggestions, when present, are folded into hint).
if !strings.Contains(verr.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", verr.Hint)
}
}
@@ -288,13 +254,12 @@ func TestUnknownSubcommandRunE_NestedResourceGroup(t *testing.T) {
installUnknownSubcommandGuard(root)
err := files.RunE(files, []string{"bogus"})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError on nested group, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError on nested group, got %T", err)
}
if exitErr.Detail.Detail.(map[string]any)["command_path"] != "lark-cli drive files" {
t.Errorf("command_path should reflect the nested resource, got %v",
exitErr.Detail.Detail.(map[string]any)["command_path"])
if !strings.Contains(verr.Message, "lark-cli drive files") {
t.Errorf("message should reflect the nested resource path, got %q", verr.Message)
}
}
@@ -337,10 +302,10 @@ func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
}
}
// unknownSubcommandRunE must split current vs deprecated subcommands into
// separate detail buckets, while suggestions still rank across both so a
// mistyped legacy alias resolves.
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
// unknownSubcommandRunE ranks suggestions across both current and deprecated
// subcommands so a mistyped legacy alias resolves; the closest match is folded
// into the hint.
func TestUnknownSubcommandRunE_SuggestsAcrossDeprecatedBucket(t *testing.T) {
svc := &cobra.Command{Use: "sheets"}
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
svc.AddCommand(
@@ -349,31 +314,26 @@ func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
)
err := unknownSubcommandRunE(svc, []string{"+reat"})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
// "+reat" is closest to the deprecated +read: the candidate must surface
// both as a machine-readable param suggestion (for agent retry) and in the
// hint, proving ranking spans the deprecated bucket.
if len(verr.Params) != 1 || verr.Params[0].Name != "+reat" {
t.Fatalf("params = %v, want one entry named +reat (the offending subcommand)", verr.Params)
}
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
t.Errorf("available = %v, want [+cells-get]", available)
}
deprecated, ok := detail["deprecated"].([]string)
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
t.Errorf("deprecated = %v, want [+read]", deprecated)
}
// suggestions rank across both buckets: "+reat" is closest to +read.
suggestions, _ := detail["suggestions"].([]string)
found := false
for _, s := range suggestions {
foundSuggestion := false
for _, s := range verr.Params[0].Suggestions {
if s == "+read" {
found = true
foundSuggestion = true
}
}
if !found {
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
if !foundSuggestion {
t.Errorf("Params[0].Suggestions should include +read, got %v", verr.Params[0].Suggestions)
}
if !strings.Contains(verr.Hint, "+read") {
t.Errorf("hint %q should suggest +read (typo target across deprecated bucket)", verr.Hint)
}
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
@@ -132,12 +133,14 @@ func updateRun(opts *UpdateOptions) error {
// 1. Fetch latest version
latest, err := fetchLatest()
if err != nil {
return reportError(opts, io, output.ExitNetwork, "network", "failed to check latest version: %s", err)
return reportError(opts, io, "network",
errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to check latest version: %s", err).WithCause(err))
}
// 2. Validate version format
if update.ParseVersion(latest) == nil {
return reportError(opts, io, output.ExitInternal, "update_error", "invalid version from registry: %s", latest)
return reportError(opts, io, "update_error",
errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid version from registry: %s", latest))
}
// 3. Compare versions
@@ -166,15 +169,18 @@ func updateRun(opts *UpdateOptions) error {
// --- Output helpers ---
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errType, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
// reportError emits the failure on the requested surface: JSON mode prints the
// {ok:false, error:{type, message}} envelope to stdout and signals the typed
// error's exit code bare; human mode returns the typed error for the
// dispatcher to render.
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, errType string, typedErr errs.TypedError) error {
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{"type": errType, "message": msg},
"ok": false, "error": map[string]interface{}{"type": errType, "message": typedErr.ProblemDetail().Message},
})
return output.ErrBare(exitCode)
return output.ErrBare(output.ExitCodeOf(typedErr))
}
return output.Errorf(exitCode, errType, "%s", msg)
return typedErr
}
func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
@@ -228,7 +234,8 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
restore, err := updater.PrepareSelfReplace()
if err != nil {
return reportError(opts, io, output.ExitAPI, "update_error", "failed to prepare update: %s", err)
return reportError(opts, io, "update_error",
errs.NewAPIError(errs.SubtypeUnknown, "failed to prepare update: %s", err).WithCause(err))
}
if !opts.JSON {

View File

@@ -14,6 +14,7 @@ import (
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
@@ -334,13 +335,88 @@ func TestUpdateFetchError_Human(t *testing.T) {
if err == nil {
t.Fatal("expected non-nil error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Fatalf("expected *errs.NetworkError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, exitErr.Code)
if netErr.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("subtype = %q, want %q", netErr.Subtype, errs.SubtypeNetworkTransport)
}
if got := output.ExitCodeOf(err); got != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, got)
}
}
// TestUpdateInvalidVersion_Human verifies a malformed registry version surfaces
// as a typed internal error in human mode, keeping the legacy exit code 5.
func TestUpdateInvalidVersion_Human(t *testing.T) {
f, _, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "not-a-version", nil }
defer func() { fetchLatest = origFetch }()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
if err == nil {
t.Fatal("expected non-nil error, got nil")
}
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
if intErr.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("expected ExitInternal (%d), got %d", output.ExitInternal, got)
}
}
// TestReportError pins reportError's two surfaces after the typed migration:
// human mode returns the typed error unchanged; JSON mode prints the legacy
// {ok:false, error:{type, message}} envelope and exits bare with the typed
// error's exit code (parity with the legacy explicit exit-code argument).
func TestReportError(t *testing.T) {
t.Run("human mode returns the typed error", func(t *testing.T) {
f, _, _ := newTestFactory(t)
typed := errs.NewAPIError(errs.SubtypeUnknown, "failed to prepare update: disk full")
err := reportError(&UpdateOptions{JSON: false}, f.IOStreams, "update_error", typed)
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *errs.APIError, got %T: %v", err, err)
}
if apiErr != typed {
t.Errorf("reportError must return the typed error unchanged")
}
if got := output.ExitCodeOf(err); got != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI, legacy parity)", got, output.ExitAPI)
}
})
t.Run("json mode prints envelope and exits bare with typed code", func(t *testing.T) {
f, stdout, _ := newTestFactory(t)
typed := errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to check latest version: timeout")
err := reportError(&UpdateOptions{JSON: true}, f.IOStreams, "network", typed)
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected bare *output.BareError, got %T: %v", err, err)
}
if bareErr.Code != output.ExitNetwork {
t.Errorf("bare exit code = %d, want %d", bareErr.Code, output.ExitNetwork)
}
out := stdout.String()
if !strings.Contains(out, `"type": "network"`) && !strings.Contains(out, `"type":"network"`) {
t.Errorf("JSON envelope missing type, got: %s", out)
}
if !strings.Contains(out, "failed to check latest version: timeout") {
t.Errorf("JSON envelope missing message, got: %s", out)
}
})
}
func TestUpdateInvalidVersion_JSON(t *testing.T) {
@@ -503,12 +579,12 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
if err == nil {
t.Fatal("expected verification failure")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, exitErr.Code)
if bareErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, bareErr.Code)
}
out := stdout.String()

41
content_embed.go Normal file
View File

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

View File

@@ -6,25 +6,16 @@ envelope on stderr; **protocol adapters** mapping CLI errors into MCP /
OAuth shapes; and **framework + business code** producing errors. This file
is the single source of truth for all three.
This document describes the **typed authoring target**. The refactor lands
in stages; some boundaries (e.g. `client.WrapDoAPIError`) still operate on
legacy shapes today — see **Migration** for what is live in each stage.
Migrating an `*output.ExitError` call site? See **Migration**. Something off
in production? See **Troubleshooting**.
Something off in production? See **Troubleshooting**.
## Invariants
1. Every error belongs to exactly one **Category**. The set is closed
(`errs/category.go`); adding a member requires deliberate review.
2. Every **newly constructed** typed error has a **Subtype** — a stable
2. Every typed error has a **Subtype** — a stable
lowercase-with-underscores identifier declared in `errs/subtypes*.go`.
Undeclared subtypes fail CI. The constraint applies only to typed
`*errs.*` literals; stage-1 legacy `*core.ConfigError` flows via the
dispatcher's `asExitError` → legacy envelope path (not the typed
taxonomy) and is unaffected. `errcompat.PromoteConfigError` is a
stage-1 passthrough; its stage-2+ typed migration will subject the
promoted typed error to this Subtype constraint at that time.
Undeclared subtypes fail CI. Every error path constructs a typed
`*errs.*` error at its origin, so the constraint applies uniformly.
3. **`Category` + `Subtype`** are wire-stable identifiers consumers may
branch on. Renaming either is a breaking change.
4. `Code` is the upstream numeric code when known (e.g. Lark API code).
@@ -35,11 +26,10 @@ in production? See **Troubleshooting**.
unchanged across the `errors.As` / `errors.Unwrap` chain.
7. For the typed-envelope path, exit codes derive from `Category` only
via `output.ExitCodeForCategory` — including `SecurityPolicyError`,
which exits `6` via `CategoryPolicy`. Unmigrated `*output.ExitError`
producers still carry a hand-set `Code` until they finish migrating.
`output.ErrBare(code)` is the lone exception: a deliberate
predicate-command signal that bypasses the envelope (see
**Predicate commands** below).
which exits `6` via `CategoryPolicy`. `output.ErrBare(code)` is the
exception: it constructs an `*output.BareError`, a deliberate
silent-exit signal (stdout already carries the answer) that bypasses
the envelope (see **Predicate commands** below).
## Wire format
@@ -73,13 +63,14 @@ Typed errors render to **stderr** as one JSON object per process exit:
| `error.hint` | informational | actionable recovery guidance |
| `error.log_id` | informational | upstream request id (server-side trace) |
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
| `error.param` | per-Subtype-stable | single offending parameter (`ValidationError`); see **Validation parameters** |
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
`SecurityPolicyError` renders through the same typed envelope as every
other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via
`CategoryPolicy`. The legacy `auth_error` envelope at exit `1` has been
retired.
`CategoryPolicy`.
## Categories
@@ -119,20 +110,21 @@ Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`.
cmd/root.go handleRootError dispatches:
├─ output.ErrBare(code) → no envelope (stdout already written); exit = code
├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err)
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6)
├─ *core.ConfigError → promoted to typed via errcompat ↑
├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code
untyped / Cobra error → plain "Error: <msg>" (no envelope); exit 1
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6;
│ *errs.ConfigError, constructed typed at origin)
├─ *output.PartialFailureError → no stderr envelope (ok:false result already on stdout); exit = code
*output.BareError → no envelope (stdout already written); exit = code
└─ Cobra usage error → typed validation envelope (invalid_argument); exit 2
```
Only the typed and `*output.ExitError` branches emit a JSON envelope on
stderr. Untyped errors (including Cobra's "required flag missing" / unknown
subcommand messages) print plain text and exit `1` — consumers must
tolerate that fallback.
The dispatcher emits a JSON envelope on stderr for both the typed branch and
residual Cobra usage errors (missing required flag, unknown command,
argument validation): the latter are classified into a typed validation
envelope (`invalid_argument`) and exit `2`, matching the explicit flag and
subcommand guards.
### Predicate commands (`output.ErrBare`)
### Predicate commands (`output.BareError`)
A small class of commands is **predicates**: they answer a yes/no
question and signal the answer through the shell exit code so callers
@@ -142,19 +134,27 @@ example — its `README` contract is `exit 0 = ok, 1 = missing`.
These commands deliberately:
1. write a structured JSON answer to **stdout** themselves, and
2. return `output.ErrBare(exitCode)` to communicate the exit code to
the dispatcher without producing a `stderr` envelope.
2. return `output.ErrBare(exitCode)` — an `*output.BareError` to
communicate the exit code to the dispatcher without producing a
`stderr` envelope.
`output.ErrBare` is **not** an error in the typed-envelope sense — it
carries no category, subtype, or message. It is a one-bit output-
control signal that lives outside the contract for the same reason
`grep -q` / `diff` / `systemctl is-active` set non-zero exit codes
without printing anything to stderr: pollution of stderr by a
`*output.BareError` is **not** an error in the typed-envelope sense — it
carries no category, subtype, or message, only an exit code. It is a
one-bit output-control signal that lives outside the contract for the
same reason `grep -q` / `diff` / `systemctl is-active` set non-zero exit
codes without printing anything to stderr: pollution of stderr by a
predicate's negative answer would break `2>/dev/null` log hygiene in
caller scripts.
New code should not reach for `ErrBare` unless the command is
genuinely a predicate. Anything carrying recoverable error content
A second class also uses `ErrBare`: a command that emits its own complete
structured result envelope on **stdout** under `--json` (e.g. `update`, whose
`{ok:false, error:{type, message}}` is its established output shape) and needs
only the exit code conveyed, with no `stderr` envelope. Like a predicate, its
answer is already on stdout; `ErrBare` carries the exit code alone.
New code should not reach for `ErrBare` unless the command's full answer is
already on stdout — a predicate's yes/no, or a self-contained result envelope
as above. Anything whose error content must reach the caller on `stderr`
belongs in a typed `*errs.XxxError` — or, for a batch result, in the
partial-failure outcome below.
@@ -214,7 +214,7 @@ exitCode := output.ExitCodeOf(err) // ExitInternal for non-typed errors
out=$(lark-cli ... 2>&1)
code=$?
# Untyped / Cobra errors print plain text — guard before jq.
# Defensive guard: tolerate any non-JSON output before parsing with jq.
if ! jq -e . >/dev/null 2>&1 <<<"$out"; then
printf '%s\n' "$out" >&2
exit "$code"
@@ -303,9 +303,10 @@ Do not pick exit codes by hand in new typed producers — `ExitCodeForCategory`
maps `Category` to the shell code. A new exit-code requirement means a
new `Category`, not a one-off override at the call site.
(Legacy `*output.ExitError` retains hand-set codes until removal;
`SecurityPolicyError` retains a hand-set code on main until the framework
migration PR retires the carve-out — see **Migration**.)
(The only exits not derived from `Category` are the
`*output.BareError` and the `*output.PartialFailureError` signals, which
carry their own code by design and sit outside the typed-envelope contract —
see **Predicate commands**.)
#### Split `Message`, `Hint`, and `Cause`
@@ -340,15 +341,54 @@ Message: fmt.Sprintf("request failed: %v — retry later", ioErr)
// conflates what + what-to-do + cause into one string
```
#### `ValidationError.Param` uses the `--flag` form
#### Validation parameters: `Param` and `Params`
When a `*ValidationError` originates from a flag value, `Param` holds the
flag name with leading dashes (`"--priority"`, not `"priority"`). AI
agents grep this field literally to surface "the bad flag was `--X`".
`ValidationError` carries two additive parameter fields. Both are
optional; a producer sets whichever fits the failure.
For positional arguments, use the canonical name without dashes
**`Param string` (wire `param`)** — the single offending parameter. When a
`*ValidationError` originates from a flag value, `Param` holds the flag
name with leading dashes (`"--priority"`, not `"priority"`). AI agents
grep this field literally to surface "the bad flag was `--X`". For
positional arguments, use the canonical name without dashes
(`"target_user_id"`).
**`Params []InvalidParam` (wire `params`)** — per-parameter validation
detail, for failures that need to report *which* parameters failed and
*why*, one entry each. Each `errs.InvalidParam` is
`{Name, Reason string, Suggestions []string}`: `Name` identifies the
parameter, `Reason` states why it failed, and the optional `Suggestions`
(wire `suggestions`, omitted when empty) carries ranked candidate
corrections an agent can retry with — the did-you-mean candidates for an
unknown flag or subcommand — without parsing the human-facing `hint`. This
is the CLI's rendering of the RFC 7807 `invalid-params` extension member
(RFC 7807 §3.1). The wire key is `params`, not `invalid_params`: the
enclosing envelope already carries `type:"validation"`, so the `invalid_`
qualifier would be redundant on the wire.
`Param` and `Params` are independent additive fields, not alternates of a
single representation. Use `Param` for the common single-parameter error;
use `Params` when one failure spans several parameters or needs a
per-parameter reason. Set with `.WithParam("--flag")` / `.WithParams(...)`.
A `params` wire example (multiple parameters each carrying a reason):
```json
{
"ok": false,
"identity": "user",
"error": {
"type": "validation",
"subtype": "invalid_argument",
"message": "2 parameters failed validation",
"params": [
{ "name": "--start", "reason": "expected RFC3339, got \"yesterday\"" },
{ "name": "--end", "reason": "must be after --start" }
]
}
}
```
### Constructing typed errors
Prefer the **builder API**. The constructor pins `Category` + `Subtype` +
@@ -378,44 +418,11 @@ them on the dynamic dispatch path where a `Problem` value is composed
once and wrapped per Category branch. Outside that pattern, new code
should reach for the builder.
Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`)
remain callable during migration but are `// Deprecated:` — new code goes
through the builder.
#### Shortcut `Execute` walkthrough
Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy
form is `output.ErrValidation("--duration-minutes must be between 1 and
1440")`. The typed migration target (builder form):
```go
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
duration := runtime.Int("duration-minutes")
if duration < 1 || duration > 1440 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--duration-minutes must be between 1 and 1440, got %d", duration).
WithHint("pass a value in [1, 1440]").
WithParam("--duration-minutes")
}
_, err := runtime.DoAPI(req, opts)
if err != nil {
return err // already typed by the framework boundary; propagate
}
return nil
}
```
Two patterns visible: a producer site (the typed `*errs.ValidationError`
above) and a propagation site (the `return err` after `runtime.DoAPI`,
applying [Propagate typed errors unchanged](#propagate-typed-errors-unchanged)).
When the validation logic outgrows a single range check — multiple
flags, format parsing, conditional rules — extract it into a helper that
also returns the typed `*errs.ValidationError`. The helper, not
`Execute`, sets `Param` (a helper bound to one shortcut is normal in
this codebase; see `parseTimeRange` in
`shortcuts/calendar/calendar_agenda.go:144`).
When the validation logic outgrows a single range check — multiple flags,
format parsing, conditional rules — extract it into a helper that also returns
the typed `*errs.ValidationError`; the helper, not `Execute`, sets `Param` (a
helper bound to one shortcut is normal in this codebase; see `parseTimeRange`
in `shortcuts/calendar/calendar_agenda.go`).
### Wrapping upstream errors
@@ -479,7 +486,7 @@ Rare; the existing structs cover the 9 Categories with room. If you must:
1. In `errs/types.go`, add a new section with: the struct embedding `errs.Problem`, a nil-receiver-safe `Unwrap()` if it carries `Cause`, a `NewXxxError(subtype, format, args...)` constructor, and one chained `WithX` setter per extension field.
2. Add an `IsXxx` predicate in `errs/predicates.go`.
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_builder_test.go`.
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_test.go`.
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
top-level wire fields are forbidden — per-Subtype data goes into the
@@ -488,19 +495,33 @@ top level.
## CI guards
| Check | Enforces | Where |
|-------|----------|-------|
| forbidigo | business path (`shortcuts/**`, `cmd/service/**`) must not call legacy `output.*` error constructors — route through the typed classifier | `.golangci.yml` |
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` | `lint/errscontract/` AST |
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code | `lint/errscontract/` AST |
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes labeled for promotion (warn) | `lint/errscontract/` AST |
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant or `ad_hoc_*` | `lint/errscontract/` AST |
| `CheckTypedErrorCompleteness` | every `*errs.<X>Error{Problem: errs.Problem{...}}` literal must set `Category`, `Subtype`, and `Message` | `lint/errscontract/` AST |
Two golangci-lint rules and the custom `errscontract` AST module enforce the
contract; CI runs all three on every PR.
CI runs `lint/` on every PR. Locally: `go run -C lint . ..`. The
lintcheck CLI lives in its own Go module so its `golang.org/x/tools`
dependency stays out of the shipped `lark-cli` binary's module graph;
see `lint/README.md` for how to add a new lint domain.
**golangci-lint** — scopes are defined in `.golangci.yml` (not duplicated here,
so this spec cannot drift from the lint config):
| Rule | Enforces |
|------|----------|
| forbidigo `errs-no-bare-wrap` | a command / wire-boundary final error must be typed (`errs.NewXxxError`), never a bare `fmt.Errorf` / `errors.New`; a genuine intermediate wrap opts out with `//nolint:forbidigo` + a reason |
| errorlint | every error wrap uses `%w` and every comparison uses `errors.Is` / `errors.As` — interior wraps stay legal but cannot break the `errors.Unwrap` chain the typed boundary relies on |
**errscontract** (`lint/errscontract/`, a separate Go module so its
`golang.org/x/tools` dependency stays out of the shipped binary; run locally
with `go run -C lint . ..`):
| Check | Enforces |
|-------|----------|
| `CheckNoLegacyEnvelopeLiteral` / `CheckNoLegacyCommonHelperCall` / `CheckNoLegacyRuntimeAPICall` | the removed `output.*` legacy error surface cannot be reintroduced anywhere |
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` |
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant (or `ad_hoc_*`) |
| `CheckTypedErrorCompleteness` | every typed-error struct literal sets `Category`, `Subtype`, and `Message` |
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes flagged for promotion (warning) |
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code |
`errscontract` also carries framework-internal invariants (nil-safe `Unwrap`,
builder immutability, unwrap symmetry); see `lint/errscontract/` for the full
set and `lint/README.md` for adding a new lint domain.
## Stability
@@ -510,67 +531,13 @@ see `lint/README.md` for how to add a new lint domain.
| Additive | new Category, new declared Subtype, new extension field on an existing struct | minor release; consumers ignore unknown fields by contract |
| Experimental | `ad_hoc_*` Subtypes; fields documented as such in `errs/types.go` | may change or be promoted/removed within one release |
The deprecated `*output.ExitError` surface is outside these tiers — it
will be removed once business migration completes.
## Migration
**Strategy shift (2026-05-26).** The original plan (`docs/design/errors-refactor/spec.md` v2.12 §9) was a centrally-driven 4-PR rollout — framework → auth domain → multi-pilot → full-repo + legacy removal. That plan is **superseded** by a hybrid model: framework owner ships framework-level hardening (including a typed `*errs.*Error` migration of `internal/**`) as one focused PR; business-domain typed migration is **self-service** via [`docs/errors-guide.md`](../docs/errors-guide.md) and the builder API, with no central sweep timeline.
Why the shift: 800+ legacy call sites split across 8+ business domains do not all share a single reviewer's bandwidth, and the contract is now expressive enough that each domain owner can migrate their own code from the guide without coordinating with framework owner.
### Current state
1. **Framework slice — ✅ shipped (PR #984).** The `errs/` typed taxonomy, classifier (`internal/errclass`), promotion stub (`internal/errcompat`, passthrough), dispatcher hook (`WriteTypedErrorEnvelope`), and the `lint/errscontract` AST guards. Wire shapes preserved byte-for-byte versus pre-PR, with **one intentional semantic fix**: config-class errors (`*core.ConfigError`) now exit `3` instead of `2`, aligning with `ExitCodeForCategory` (config errors share the auth exit slot per the taxonomy). The classifier and promote helpers are *shipped but unused* in production paths — they exist so framework migration can plug in without re-architecting.
2. **Builder API — ✅ shipped (this branch).** `errs/types.go` adds the canonical producer surface (`errs.NewXxxError(subtype, format, args...).WithX(...)`) for all 10 typed types, alongside each struct declaration. Constructor signature pins `Category` (via function name) and `Subtype` + `Message` (positional), so the producer cannot mis-specify any of the three identity fields. Optional fields chain through `.WithX(...)` setters that preserve the concrete pointer type.
### Next: framework migration PR (planned)
A single PR consolidates the work the original §9 spec split across PRs 24 — restricted to framework code, no business sweep:
- **Migrate `internal/**` typed construction to the builder API.** ~16 call sites in `internal/errclass/classify.go` (BuildAPIError fanout), `internal/auth/transport.go` (SecurityPolicy), `internal/auth/uat_client.go`, `internal/errcompat/promote*.go`, `internal/client/client.go`, `internal/client/api_errors.go`.
- **Land the framework-side semantic changes** previously scoped to spec §9 PR 2: `SecurityPolicyError` exit `1→6`, `WrapDoAPIError` typed (`*NetworkError` with subtype timeout/tls/dns/server_error/transport, `*InternalError` for JSON-decode), `WrapJSONResponseParseError` typed, `errcompat.PromoteConfigError` real Type routing, `PromoteAuthError` helper + dispatcher wiring, 10 credential Lark codes registered in codeMeta, 99991543 config classification, `resolveAccessToken` typed `*AuthenticationError`, `BuildAPIError` filling `*PermissionError.MissingScopes` / `Identity` / `ConsoleURL`, deletion of `scopeAwareChecker`.
- **Add `forbidigo` rule** banning `output.Err*` constructors in `shortcuts/**` and `cmd/**` (mirrors the contract that new business code must use the builder).
- **CHANGELOG** lists the resulting ~10 shell-exit-code shifts in one release entry (vs the spec §1 spread of 11 — the remaining one site lives in `task` business code).
### Business-domain migration (self-service, no central timeline)
Each business package migrates its own `output.Err*` call sites to the builder when convenient — typically batched within one domain. The guide at [`docs/errors-guide.md`](../docs/errors-guide.md) walks owners through the 8 typical error modes (validation / authorization / authentication / config / network / api / internal / policy) with real `file:line` examples from main. The three-layer extension model (add Subtype / add field / add Category) handles cases the existing taxonomy does not cover.
Helper assertions accept both shapes during migration (see `shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`) so domain migrations stay green incrementally.
### Legacy removal
Deferred until business migration completion approaches the asymptote. `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`, `ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and `ErrorEnvelope` are `// Deprecated:` today and stay callable. No fixed removal date.
### Before / after at a call site
```go
// before (legacy)
return output.ErrAPI(larkCode, "create event failed", resp.RawBody())
// after (typed) — cc carries Brand / AppID / Identity from the caller's context
return errclass.BuildAPIError(parsedResp, cc)
```
```go
// before (legacy validation)
return output.ErrValidation("--duration-minutes must be between 1 and 1440")
// after (builder)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--duration-minutes must be between 1 and 1440, got %d", duration).
WithParam("--duration-minutes")
```
## Troubleshooting
**Envelope shows `type=api subtype=unknown` for what should be a more
specific category.** The Lark code is unknown to `LookupCodeMeta` and fell
through to the generic bucket (`internal/errclass/classify.go`). Add the
code to `internal/errclass/codemeta_<service>.go` with the right Category
and Subtype, plus a dispatch test in `classify_test.go`.
and Subtype, plus a dispatch test in `internal/errclass/classify_test.go`.
**Envelope shows `type=internal subtype=sdk_error`.** Origin is
`client.WrapDoAPIError` taking the non-transport branch
@@ -613,8 +580,6 @@ string cannot be classified retroactively.
- *Add a new condition?* → **Add a Subtype**
- *Consume from a shell script?* → **Consumers / Shell / AI**
- *Understand or fix a CI failure?* → **CI guards**
- *Migrate a legacy `ExitError` call site?* → **Migration** + the
Deprecated note on the symbol being replaced.
- *Read source.* → `errs/doc.go``errs/category.go``errs/types.go`
`errs/predicates.go``internal/errclass/`
`cmd/root.go` `handleRootError`.

29
errs/raw.go Normal file
View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import "errors"
// rawPassthrough marks an error as raw passthrough: the dispatcher must not
// rewrite its message or hint with local enrichment. Raw is
// dispatcher-internal routing state, not a wire field. It is deliberately not
// a typed taxonomy error (no embedded Problem) — it only wraps one.
type rawPassthrough struct{ err error }
func (e *rawPassthrough) Error() string { return e.err.Error() }
func (e *rawPassthrough) Unwrap() error { return e.err }
// MarkRaw wraps err as raw passthrough. MarkRaw(nil) returns nil.
func MarkRaw(err error) error {
if err == nil {
return nil
}
return &rawPassthrough{err: err}
}
// IsRaw reports whether err or any error in its chain is marked raw.
func IsRaw(err error) bool {
var raw *rawPassthrough
return errors.As(err, &raw)
}

96
errs/raw_test.go Normal file
View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs_test
import (
"encoding/json"
"errors"
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
func TestMarkRawNilReturnsNil(t *testing.T) {
if got := errs.MarkRaw(nil); got != nil {
t.Fatalf("MarkRaw(nil) = %v, want nil", got)
}
}
func TestIsRaw(t *testing.T) {
base := fmt.Errorf("boom")
if !errs.IsRaw(errs.MarkRaw(base)) {
t.Errorf("IsRaw(MarkRaw(err)) = false, want true")
}
if errs.IsRaw(base) {
t.Errorf("IsRaw(bare err) = true, want false")
}
if errs.IsRaw(nil) {
t.Errorf("IsRaw(nil) = true, want false")
}
// Raw marking survives further wrapping above it in the chain.
wrapped := fmt.Errorf("outer: %w", errs.MarkRaw(base))
if !errs.IsRaw(wrapped) {
t.Errorf("IsRaw(wrap(MarkRaw(err))) = false, want true")
}
}
func TestMarkRawPreservesErrorMessage(t *testing.T) {
base := fmt.Errorf("boom")
if got := errs.MarkRaw(base).Error(); got != "boom" {
t.Fatalf("MarkRaw(err).Error() = %q, want %q", got, "boom")
}
}
func TestMarkRawPreservesErrorsIsChain(t *testing.T) {
sentinel := errors.New("sentinel")
wrapped := fmt.Errorf("ctx: %w", sentinel)
if !errors.Is(errs.MarkRaw(wrapped), sentinel) {
t.Fatalf("errors.Is(MarkRaw(err), sentinel) = false, want true")
}
}
func TestProblemOfPunchesThroughMarkRaw(t *testing.T) {
typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad flag")
raw := errs.MarkRaw(typed)
p, ok := errs.ProblemOf(raw)
if !ok {
t.Fatalf("ProblemOf(MarkRaw(typed)) ok = false, want true")
}
if p.Category != errs.CategoryValidation {
t.Errorf("ProblemOf(MarkRaw(typed)).Category = %v, want %v", p.Category, errs.CategoryValidation)
}
// errors.As still finds the concrete typed error through the raw wrapper.
var ve *errs.ValidationError
if !errors.As(raw, &ve) {
t.Errorf("errors.As(MarkRaw(typed), *ValidationError) = false, want true")
}
}
// TestMarkRawUnwrapsToInnerTypedError pins the envelope-serialization
// contract: UnwrapTypedError must return the inner concrete typed error,
// not the rawPassthrough wrapper. The wrapper has no exported fields, so if it
// were returned the JSON envelope would marshal to an empty "{}" error.
func TestMarkRawUnwrapsToInnerTypedError(t *testing.T) {
base := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad flag")
typed, ok := errs.UnwrapTypedError(errs.MarkRaw(base))
if !ok {
t.Fatal("UnwrapTypedError(MarkRaw(typed)) must find a typed error")
}
out, err := json.Marshal(typed)
if err != nil {
t.Fatal(err)
}
if string(out) == "{}" {
t.Fatalf("UnwrapTypedError returned the opaque rawPassthrough wrapper; envelope would be empty: %s", out)
}
if got := errs.CategoryOf(typed); got != errs.CategoryValidation {
t.Fatalf("unwrapped category = %q, want validation", got)
}
}

View File

@@ -73,6 +73,7 @@ const (
const (
SubtypeChallengeRequired Subtype = "challenge_required" // user must complete browser challenge / MFA
SubtypeAccessDenied Subtype = "access_denied" // policy denies access outright
SubtypeContentSafety Subtype = "content_safety" // content-safety scanner blocked output in block mode
)
// CategoryInternal subtypes

View File

@@ -77,6 +77,10 @@ type ValidationError struct {
type InvalidParam struct {
Name string `json:"name"`
Reason string `json:"reason"`
// Suggestions holds machine-readable, ranked candidate corrections for this
// parameter (e.g. did-you-mean flags or subcommands), so an agent can retry
// without parsing the human-facing hint. Omitted when there are none.
Suggestions []string `json:"suggestions,omitempty"`
}
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse

View File

@@ -101,9 +101,9 @@ func TestSecurityPolicyErrorUnwrap(t *testing.T) {
// interface would panic when the root dispatcher or any caller walks the
// errors.Is / errors.Unwrap chain.
//
// The doc comments on these types claim "nil-receiver safe" but until this
// test landed nothing actually pinned that claim — exactly the
// behavioral-comment-without-test footgun caught in PR #984 review.
// The doc comments on these types claim "nil-receiver safe"; this test
// pins that claim so the behavioral comment cannot silently drift from the
// implementation.
func TestTypedErrors_UnwrapNilReceiver(t *testing.T) {
t.Helper()
checks := []struct {

132
events/im/card_action.go Normal file
View File

@@ -0,0 +1,132 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/event"
)
// CardActionTriggerOutput is the flattened shape for card.action.trigger.
type CardActionTriggerOutput struct {
Type string `json:"type" desc:"Event type; always card.action.trigger"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string)" kind:"timestamp_ms"`
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id" kind:"open_id"`
MessageID string `json:"message_id,omitempty" desc:"Message ID of the card" kind:"message_id"`
ChatID string `json:"chat_id,omitempty" desc:"Chat ID" kind:"chat_id"`
Host string `json:"host,omitempty" desc:"Host type: im_message / im_top_notice"`
Token string `json:"token,omitempty" desc:"Token for delay card update (valid 30 min, max 2 updates)"`
ActionTag string `json:"action_tag,omitempty" desc:"Triggered element type: button/select_static/input/checker/etc"`
ActionValue string `json:"action_value,omitempty" desc:"Developer-defined action value as JSON string"`
ActionName string `json:"action_name,omitempty" desc:"Element name attribute"`
FormValue string `json:"form_value,omitempty" desc:"Form submission values as JSON string (only on form submit)"`
InputValue string `json:"input_value,omitempty" desc:"Input field value (only for input elements)"`
Option string `json:"option,omitempty" desc:"Selected option value (for single-select dropdown)"`
Options string `json:"options,omitempty" desc:"Selected options, comma-separated (for multi-select)"`
Checked bool `json:"checked" desc:"Checkbox state (for checkbox elements)"`
Timezone string `json:"timezone,omitempty" desc:"User timezone for date/time picker interactions"`
CardContent string `json:"card_content,omitempty" desc:"Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"`
}
func processCardAction(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 {
Operator struct {
OpenID string `json:"open_id"`
} `json:"operator"`
Token string `json:"token"`
Host string `json:"host"`
Action struct {
Tag string `json:"tag"`
Value map[string]interface{} `json:"value"`
Name string `json:"name"`
FormValue map[string]interface{} `json:"form_value"`
InputValue string `json:"input_value"`
Option string `json:"option"`
Options []string `json:"options"`
Checked bool `json:"checked"`
Timezone string `json:"timezone"`
} `json:"action"`
Context struct {
OpenMessageID string `json:"open_message_id"`
OpenChatID string `json:"open_chat_id"`
} `json:"context"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload
}
actionValue := marshalToString(envelope.Event.Action.Value)
formValue := marshalToString(envelope.Event.Action.FormValue)
options := strings.Join(envelope.Event.Action.Options, ",")
out := &CardActionTriggerOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
OperatorID: envelope.Event.Operator.OpenID,
MessageID: envelope.Event.Context.OpenMessageID,
ChatID: envelope.Event.Context.OpenChatID,
Host: envelope.Event.Host,
Token: envelope.Event.Token,
ActionTag: envelope.Event.Action.Tag,
ActionValue: actionValue,
ActionName: envelope.Event.Action.Name,
FormValue: formValue,
InputValue: envelope.Event.Action.InputValue,
Option: envelope.Event.Action.Option,
Options: options,
Checked: envelope.Event.Action.Checked,
Timezone: envelope.Event.Action.Timezone,
}
if out.MessageID != "" && rt != nil {
out.CardContent = fetchCardUserDSL(ctx, rt, out.MessageID)
}
return json.Marshal(out)
}
// fetchCardUserDSL gets the card message content via message get API.
// Returns empty string on any failure — never blocks event consumption.
func fetchCardUserDSL(ctx context.Context, rt event.APIClient, messageID string) string {
path := "/open-apis/im/v1/messages/" + messageID + "?card_msg_content_type=user_card_content"
resp, err := rt.CallAPI(ctx, "GET", path, nil)
if err != nil {
return ""
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Items []struct {
Body struct {
Content string `json:"content"`
} `json:"body"`
} `json:"items"`
} `json:"data"`
}
if json.Unmarshal(resp, &result) != nil || result.Code != 0 || len(result.Data.Items) == 0 {
return ""
}
return result.Data.Items[0].Body.Content
}
func marshalToString(m map[string]interface{}) string {
if len(m) == 0 {
return ""
}
b, _ := json.Marshal(m)
return string(b)
}

View File

@@ -0,0 +1,432 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestCardActionTriggerRegistered(t *testing.T) {
def, ok := event.Lookup("card.action.trigger")
if !ok {
t.Fatal("card.action.trigger should be registered via Keys()")
}
if def.Schema.Custom == nil {
t.Error("card.action.trigger must set Schema.Custom")
}
if def.Process == nil {
t.Error("card.action.trigger must set Process")
}
if len(def.Scopes) == 0 {
t.Error("Scopes must not be empty")
}
}
func TestProcessCardAction_Button(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_btn_001",
"event_type": "card.action.trigger",
"create_time": "1776409469273"
},
"event": {
"operator": {"open_id": "ou_operator"},
"token": "c-token-btn",
"host": "im_message",
"action": {
"tag": "button",
"value": {"key": "approve"},
"name": "approve_btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_msg_001",
"open_chat_id": "oc_chat_001"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Type != "card.action.trigger" {
t.Errorf("Type = %q, want card.action.trigger", out.Type)
}
if out.EventID != "ev_btn_001" {
t.Errorf("EventID = %q", out.EventID)
}
if out.OperatorID != "ou_operator" {
t.Errorf("OperatorID = %q", out.OperatorID)
}
if out.ActionTag != "button" {
t.Errorf("ActionTag = %q, want button", out.ActionTag)
}
if out.ActionValue != `{"key":"approve"}` {
t.Errorf("ActionValue = %q", out.ActionValue)
}
if out.ActionName != "approve_btn" {
t.Errorf("ActionName = %q", out.ActionName)
}
if out.Token != "c-token-btn" {
t.Errorf("Token = %q", out.Token)
}
if out.MessageID != "om_msg_001" {
t.Errorf("MessageID = %q", out.MessageID)
}
if out.ChatID != "oc_chat_001" {
t.Errorf("ChatID = %q", out.ChatID)
}
if out.Host != "im_message" {
t.Errorf("Host = %q", out.Host)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
}
func TestProcessCardAction_FormSubmit(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_form_001",
"event_type": "card.action.trigger",
"create_time": "1776409469274"
},
"event": {
"operator": {"open_id": "ou_form_user"},
"token": "c-token-form",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "submit_btn",
"form_value": {"name": "test-user", "reason": "testing"},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_form_001",
"open_chat_id": "oc_chat_002"
}
}
}`
out := runCardAction(t, payload, nil)
if out.FormValue != `{"name":"test-user","reason":"testing"}` {
t.Errorf("FormValue = %q", out.FormValue)
}
if out.ActionTag != "button" {
t.Errorf("ActionTag = %q, want button", out.ActionTag)
}
}
func TestProcessCardAction_MultiSelect(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_ms_001",
"event_type": "card.action.trigger",
"create_time": "1776409469275"
},
"event": {
"operator": {"open_id": "ou_ms_user"},
"token": "c-token-ms",
"host": "im_message",
"action": {
"tag": "multi_select_static",
"value": {},
"name": "multi_select",
"options": ["opt_1", "opt_3"],
"checked": false
},
"context": {
"open_message_id": "om_ms_001",
"open_chat_id": "oc_chat_003"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Options != "opt_1,opt_3" {
t.Errorf("Options = %q, want opt_1,opt_3", out.Options)
}
if out.ActionTag != "multi_select_static" {
t.Errorf("ActionTag = %q", out.ActionTag)
}
}
func TestProcessCardAction_Input(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_input_001",
"event_type": "card.action.trigger",
"create_time": "1776409469276"
},
"event": {
"operator": {"open_id": "ou_input_user"},
"token": "c-token-input",
"host": "im_message",
"action": {
"tag": "input",
"value": {},
"name": "text_input",
"input_value": "hello world",
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_input_001",
"open_chat_id": "oc_chat_004"
}
}
}`
out := runCardAction(t, payload, nil)
if out.InputValue != "hello world" {
t.Errorf("InputValue = %q", out.InputValue)
}
if out.ActionTag != "input" {
t.Errorf("ActionTag = %q", out.ActionTag)
}
}
func TestProcessCardAction_DatePicker(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_date_001",
"event_type": "card.action.trigger",
"create_time": "1776409469277"
},
"event": {
"operator": {"open_id": "ou_date_user"},
"token": "c-token-date",
"host": "im_message",
"action": {
"tag": "date_picker",
"value": {},
"name": "date_selector",
"option": "2024-04-01 +0800",
"timezone": "Asia/Shanghai",
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_date_001",
"open_chat_id": "oc_chat_005"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Option != "2024-04-01 +0800" {
t.Errorf("Option = %q", out.Option)
}
if out.Timezone != "Asia/Shanghai" {
t.Errorf("Timezone = %q", out.Timezone)
}
}
func TestProcessCardAction_MalformedPayload(t *testing.T) {
raw := &event.RawEvent{
EventID: "ev_bad",
EventType: "card.action.trigger",
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processCardAction(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 TestProcessCardAction_MessageGetSuccess(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_ok",
"event_type": "card.action.trigger",
"create_time": "1776409469278"
},
"event": {
"operator": {"open_id": "ou_mg_user"},
"token": "c-token-mg",
"host": "im_message",
"action": {
"tag": "button",
"value": {"key": "click"},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_001",
"open_chat_id": "oc_chat_mg"
}
}
}`
cardContent := `{"header":{"title":{"tag":"plain_text","content":"A card"}}}`
mock := &mockAPIClient{resp: `{
"code": 0,
"msg": "success",
"data": {
"items": [{
"body": {"content": "` + escapeJSON(cardContent) + `"}
}]
}
}`}
out := runCardAction(t, payload, mock)
if out.CardContent == "" {
t.Error("CardContent should not be empty when message get succeeds")
}
}
func TestProcessCardAction_MessageGetErrorCode(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_ec",
"event_type": "card.action.trigger",
"create_time": "1776409469279"
},
"event": {
"operator": {"open_id": "ou_mg_user2"},
"token": "c-token-mg2",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_002",
"open_chat_id": "oc_chat_mg2"
}
}
}`
mock := &mockAPIClient{resp: `{"code": 1, "msg": "error", "data": {"items": []}}`}
out := runCardAction(t, payload, mock)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when code != 0, got %q", out.CardContent)
}
}
func TestProcessCardAction_MessageGetFailure(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_fail",
"event_type": "card.action.trigger",
"create_time": "1776409469280"
},
"event": {
"operator": {"open_id": "ou_mg_user3"},
"token": "c-token-mg3",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_003",
"open_chat_id": "oc_chat_mg3"
}
}
}`
mock := &mockAPIClient{errResp: true}
out := runCardAction(t, payload, mock)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when message get fails, got %q", out.CardContent)
}
}
func TestProcessCardAction_EmptyMessageID(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_no_msg",
"event_type": "card.action.trigger",
"create_time": "1776409469281"
},
"event": {
"operator": {"open_id": "ou_no_msg"},
"token": "c-token-nm",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "",
"open_chat_id": "oc_chat_nm"
}
}
}`
out := runCardAction(t, payload, nil)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when message_id is absent, got %q", out.CardContent)
}
}
type mockAPIClient struct {
resp string
errResp bool
}
func (m *mockAPIClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
if m.errResp {
return nil, context.DeadlineExceeded
}
return json.RawMessage(m.resp), nil
}
func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionTriggerOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: "card.action.trigger",
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processCardAction(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out CardActionTriggerOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid CardActionTriggerOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}
func escapeJSON(s string) string {
b, _ := json.Marshal(s)
return string(b[1 : len(b)-1])
}

View File

@@ -23,7 +23,7 @@ type ImMessageReceiveOutput struct {
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
@@ -55,8 +55,10 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
}
msg := envelope.Event.Message
content := msg.Content
if msg.MessageType != "interactive" {
var content string
if msg.MessageType == "interactive" {
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
} else {
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
RawContent: msg.Content,
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),

View File

@@ -27,6 +27,21 @@ func Keys() []event.KeyDefinition {
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{"im.message.receive_v1"},
},
{
Key: "card.action.trigger",
DisplayName: "Card action",
Description: "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).",
EventType: "card.action.trigger",
SubscriptionType: event.SubTypeCallback,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(CardActionTriggerOutput{})},
},
Process: processCardAction,
Scopes: []string{"im:message:readonly"},
AuthTypes: []string{"bot"},
SingleConsumer: true,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
for _, rk := range nativeIMKeys {

View File

@@ -7,6 +7,7 @@ package events
import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/task"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/events/whiteboard"
"github.com/larksuite/cli/internal/event"
@@ -17,6 +18,7 @@ func init() {
all := [][]event.KeyDefinition{
im.Keys(),
minutes.Keys(),
task.Keys(),
vc.Keys(),
whiteboard.Keys(),
}

23
events/task/native.go Normal file
View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
// TaskUpdateUserAccessV2Data is the Task v2 update event payload under the
// standard Lark V2 event envelope.
type TaskUpdateUserAccessV2Data struct {
EventTypes []string `json:"event_types,omitempty" desc:"Task commit types included in this event" enum:"task_create,task_deleted,task_summary_update,task_desc_update,task_assignees_update,task_followers_update,task_reminders_update,task_start_due_update,task_completed_update"`
TaskGUID string `json:"task_guid,omitempty" desc:"Task GUID that changed" kind:"task_guid"`
}
var taskUpdateUserAccessCommitTypes = []string{
"task_create",
"task_deleted",
"task_summary_update",
"task_desc_update",
"task_assignees_update",
"task_followers_update",
"task_reminders_update",
"task_start_due_update",
"task_completed_update",
}

32
events/task/preconsume.go Normal file
View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
const taskSubscriptionPath = "/open-apis/task/v2/task_v2/task_subscription?user_id_type=open_id"
func taskSubscriptionPreConsume(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
if _, err := rt.CallAPI(ctx, "POST", taskSubscriptionPath, nil); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(
errs.SubtypeNetworkTransport,
"failed to subscribe task event",
).WithCause(err)
}
return nil, nil
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
type stubAPIClient struct {
err error
method string
path string
body interface{}
calls int
}
func (s *stubAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
s.method = method
s.path = path
s.body = body
s.calls++
if s.err != nil {
return nil, s.err
}
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
}
func TestTaskSubscriptionPreConsumeCallsSubscribeAPI(t *testing.T) {
rt := &stubAPIClient{}
cleanup, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("taskSubscriptionPreConsume error = %v", err)
}
if cleanup != nil {
t.Fatal("cleanup = non-nil, want nil because task subscription has no unsubscribe API")
}
if rt.calls != 1 {
t.Fatalf("calls = %d, want 1", rt.calls)
}
if rt.method != "POST" {
t.Errorf("method = %q, want POST", rt.method)
}
if rt.path != taskSubscriptionPath {
t.Errorf("path = %q, want %q", rt.path, taskSubscriptionPath)
}
if rt.body != nil {
t.Errorf("body = %#v, want nil", rt.body)
}
}
func TestTaskSubscriptionPreConsumeRequiresRuntime(t *testing.T) {
_, err := taskSubscriptionPreConsume(context.Background(), nil, nil)
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
if p.Subtype != errs.SubtypeUnknown {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeUnknown)
}
}
func TestTaskSubscriptionPreConsumePassesThroughAPIError(t *testing.T) {
wantErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "subscription already exists")
rt := &stubAPIClient{err: wantErr}
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err != wantErr {
t.Fatalf("err identity changed: got %T %v, want original %T %v", err, err, wantErr, wantErr)
}
if !errors.Is(err, wantErr) {
t.Fatalf("err = %v, want %v", err, wantErr)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeFailedPrecondition)
}
}
func TestTaskSubscriptionPreConsumeWrapsUntypedAPIError(t *testing.T) {
cause := errors.New("connection reset")
rt := &stubAPIClient{err: cause}
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, cause) {
t.Fatalf("err = %v, want cause %v", err, cause)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryNetwork)
}
if p.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeNetworkTransport)
}
}

33
events/task/register.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package task registers Task-domain EventKeys.
package task
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const eventTypeTaskUpdateUserAccessV2 = "task.task.update_user_access_v2"
// Keys returns all Task-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeTaskUpdateUserAccessV2,
DisplayName: "Task updated",
Description: "Triggered when tasks visible to the current user or app are created, deleted, or updated",
EventType: eventTypeTaskUpdateUserAccessV2,
Schema: event.SchemaDef{
Native: &event.SchemaSpec{Type: reflect.TypeOf(TaskUpdateUserAccessV2Data{})},
},
PreConsume: taskSubscriptionPreConsume,
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user", "bot"},
RequiredConsoleEvents: []string{eventTypeTaskUpdateUserAccessV2},
SingleConsumer: true,
},
}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"encoding/json"
"reflect"
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
func TestKeysTaskUpdateUserAccessMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 1 {
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
}
def := keys[0]
if def.Key != eventTypeTaskUpdateUserAccessV2 {
t.Errorf("Key = %q, want %q", def.Key, eventTypeTaskUpdateUserAccessV2)
}
if def.EventType != eventTypeTaskUpdateUserAccessV2 {
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeTaskUpdateUserAccessV2)
}
if def.Schema.Native == nil {
t.Fatal("Schema.Native is nil")
}
if def.Schema.Native.Type != reflect.TypeOf(TaskUpdateUserAccessV2Data{}) {
t.Errorf("native type = %v, want TaskUpdateUserAccessV2Data", def.Schema.Native.Type)
}
if def.Process != nil {
t.Fatal("Native Task EventKey must not set Process")
}
if def.PreConsume == nil {
t.Fatal("PreConsume is nil")
}
if !def.SingleConsumer {
t.Fatal("SingleConsumer = false, want true")
}
if !reflect.DeepEqual(def.Scopes, []string{"task:task:read"}) {
t.Errorf("Scopes = %#v", def.Scopes)
}
if !reflect.DeepEqual(def.AuthTypes, []string{"user", "bot"}) {
t.Errorf("AuthTypes = %#v", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeTaskUpdateUserAccessV2}) {
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
}
}
func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) {
raw := schemas.WrapV2Envelope(schemas.FromType(reflect.TypeOf(TaskUpdateUserAccessV2Data{})))
var schema map[string]interface{}
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
eventProps := schema["properties"].(map[string]interface{})["event"].(map[string]interface{})["properties"].(map[string]interface{})
taskGUID := eventProps["task_guid"].(map[string]interface{})
if got := taskGUID["format"]; got != "task_guid" {
t.Errorf("task_guid format = %v, want task_guid", got)
}
eventTypes := eventProps["event_types"].(map[string]interface{})
items := eventTypes["items"].(map[string]interface{})
rawEnum, ok := items["enum"].([]interface{})
if !ok {
t.Fatalf("event_types item enum missing: %#v", items["enum"])
}
got := make(map[string]bool, len(rawEnum))
for _, v := range rawEnum {
got[v.(string)] = true
}
for _, want := range taskUpdateUserAccessCommitTypes {
if !got[want] {
t.Errorf("event_types enum missing %q; enum=%v", want, rawEnum)
}
}
}
func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) {
const key = eventTypeTaskUpdateUserAccessV2
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
for _, def := range Keys() {
event.RegisterKey(def)
}
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,8 @@ import "fmt"
// AbortError is returned by a Wrapper that wants to short-circuit the
// command chain (instead of calling next). The framework converts it
// to an *output.ExitError with type "hook" so the JSON envelope carries
// the structured fields agents expect.
// to a typed errs.* error so the JSON envelope carries the structured
// fields agents expect.
//
// HookName is the framework-namespaced name ("secaudit.approval"); the
// Registrar adds the plugin-name prefix automatically.

View File

@@ -7,9 +7,9 @@ import "fmt"
// CommandDeniedError is the structured error returned by a denyStub. Every
// pruned-command execution path -- direct invocation, alias expansion,
// internal call -- returns this exact type. It is wire-compatible with the
// output.ExitError envelope via the Layer (== error.type) field and the
// detail map produced by ExitError().
// internal call -- returns this exact type. The dispatcher converts it to a
// typed errs.* error; the Layer field carries the denial layer for the
// envelope.
//
// Layer values:
//

12
go.mod
View File

@@ -27,8 +27,6 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
require github.com/apache/arrow/go/v17 v17.0.0
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -44,17 +42,13 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/flatbuffers v24.3.25+incompatible // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.6 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -63,16 +57,10 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/smarty/assertions v1.15.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/tools v0.22.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
)

32
go.sum
View File

@@ -2,8 +2,6 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/apache/arrow/go/v17 v17.0.0 h1:RRR2bdqKcdbss9Gxy2NS/hK8i4LDMh23L6BbkN5+F54=
github.com/apache/arrow/go/v17 v17.0.0/go.mod h1:jR7QHkODl15PfYyjM2nU+yTLScZ/qfj7OSUZmJ8putc=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -54,16 +52,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
@@ -80,16 +74,11 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -108,8 +97,6 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -146,20 +133,14 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -175,7 +156,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
@@ -189,16 +169,10 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.15.0 h1:2lYxjRbTYyxkJxlhC+LvJIx3SsANPdRybu1tGj9/OrQ=
gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package appmeta
import (
"context"
"encoding/json"
"fmt"
)
// FetchSubscribedCallbacks returns the app's currently subscribed callback names
// from application/get. On a successful fetch it always returns a non-nil slice
// (empty when callback_info is absent or lists no callbacks) so callers can
// distinguish "fetched, zero callbacks subscribed" — a definitive console state
// that must fail the precheck — from a fetch error (nil), which is a
// weak-dependency skip. Identity must be bot: the endpoint is app-level.
func FetchSubscribedCallbacks(ctx context.Context, client APIClient, appID string) ([]string, error) {
path := fmt.Sprintf("/open-apis/application/v6/applications/%s?lang=zh_cn", appID)
raw, err := client.CallAPI(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
var envelope struct {
Data struct {
App struct {
CallbackInfo *struct {
SubscribedCallbacks []string `json:"subscribed_callbacks"`
} `json:"callback_info"`
} `json:"app"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("decode application response: %w", err)
}
// callback_info also carries callback_type (e.g. "websocket"); it is
// intentionally not parsed or validated. Feishu open-platform callbacks are
// delivered over WebSocket only (confirmed), matching the CLI's WebSocket
// event source, so subscribed_callbacks alone is sufficient for the precheck.
// Revisit and validate callback_type if non-WebSocket delivery ever appears.
callbacks := []string{}
if ci := envelope.Data.App.CallbackInfo; ci != nil {
callbacks = append(callbacks, ci.SubscribedCallbacks...)
}
return callbacks, nil
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package appmeta
import (
"context"
"encoding/json"
"errors"
"testing"
)
var errFakeFetch = errors.New("fake fetch error")
type fakeCallbackClient struct {
raw string
err error
}
func (f fakeCallbackClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
if f.err != nil {
return nil, f.err
}
return json.RawMessage(f.raw), nil
}
func TestFetchSubscribedCallbacks_ParsesList(t *testing.T) {
raw := `{"code":0,"data":{"app":{"callback_info":{"callback_type":"websocket","subscribed_callbacks":["card.action.trigger","profile.view.get"]}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
want := []string{"card.action.trigger", "profile.view.get"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestFetchSubscribedCallbacks_NoCallbackInfo(t *testing.T) {
// A successful fetch with no callback_info means "zero callbacks subscribed",
// which must be a non-nil empty slice (distinct from a fetch error's nil) so
// the precheck reports a required callback as missing instead of skipping.
raw := `{"code":0,"data":{"app":{"app_id":"cli_x"}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}
func TestFetchSubscribedCallbacks_FetchError(t *testing.T) {
// A fetch error must return nil so the caller treats it as a weak-dependency skip.
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{err: errFakeFetch}, "cli_x")
if err == nil {
t.Fatal("expected error")
}
if got != nil {
t.Errorf("got %v, want nil on fetch error", got)
}
}
func TestFetchSubscribedCallbacks_CallbackInfoPresentButNull(t *testing.T) {
// callback_info present but subscribed_callbacks explicitly null → must be
// a non-nil empty slice so the precheck reports missing callbacks.
raw := `{"code":0,"data":{"app":{"callback_info":{"subscribed_callbacks":null}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice when subscribed_callbacks is null")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}
func TestFetchSubscribedCallbacks_CallbackInfoPresentButOmitted(t *testing.T) {
// callback_info present but subscribed_callbacks omitted → same as null: non-nil empty.
raw := `{"code":0,"data":{"app":{"callback_info":{"callback_type":"websocket"}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice when subscribed_callbacks is omitted")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}

View File

@@ -6,7 +6,6 @@ package auth
import (
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
@@ -22,7 +21,10 @@ var TokenRetryCodes = map[int]bool{
output.LarkErrTokenExpired: true,
}
// NeedAuthorizationError is thrown when no valid UAT exists.
// NeedAuthorizationError is the sentinel preserved in the Cause chain of the
// typed missing-UAT error so existing errors.As(&NeedAuthorizationError{})
// consumers keep matching after the construction site moved to the typed
// taxonomy. It is never surfaced on the wire on its own.
type NeedAuthorizationError struct {
UserOpenId string
}
@@ -32,24 +34,31 @@ func (e *NeedAuthorizationError) Error() string {
return fmt.Sprintf("%s (user: %s)", needUserAuthorizationMarker, e.UserOpenId)
}
// NewNeedUserAuthorizationError builds the typed *errs.AuthenticationError
// returned when no valid UAT exists for userOpenID. The Message keeps the
// need_user_authorization marker, the Hint converges on the same auth-login
// recovery vocabulary as the token-missing surface in internal/client, and the
// legacy *NeedAuthorizationError sentinel is preserved in the Cause chain for
// errors.As / errors.Is traversal.
func NewNeedUserAuthorizationError(userOpenID string) *errs.AuthenticationError {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"%s (user: %s)", needUserAuthorizationMarker, userOpenID).
WithUserOpenID(userOpenID).
WithHint("run: lark-cli auth login to re-authorize").
WithCause(&NeedAuthorizationError{UserOpenId: userOpenID})
}
// IsNeedUserAuthorizationError reports whether err represents a missing-UAT
// failure, either as the original auth error or as a wrapped ExitError.
// failure. It matches the legacy *NeedAuthorizationError sentinel, which is
// preserved in the Cause chain of the typed missing-UAT error, so errors.As
// traverses into the typed *errs.AuthenticationError as well.
func IsNeedUserAuthorizationError(err error) bool {
if err == nil {
return false
}
var needAuthErr *NeedAuthorizationError
if errors.As(err, &needAuthErr) {
return true
}
// Deprecated: legacy *output.ExitError / string-match branches; removed after typed migration.
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return strings.Contains(exitErr.Detail.Message, needUserAuthorizationMarker)
}
return strings.Contains(err.Error(), needUserAuthorizationMarker)
return errors.As(err, &needAuthErr)
}
// SecurityPolicyError is preserved as a Go type alias so existing

View File

@@ -6,7 +6,7 @@ package auth
import (
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
)
func TestIsNeedUserAuthorizationError(t *testing.T) {
@@ -22,15 +22,16 @@ func TestIsNeedUserAuthorizationError(t *testing.T) {
}
})
t.Run("wrapped exit error", func(t *testing.T) {
err := output.ErrNetwork("API call failed: %s", &NeedAuthorizationError{})
if !IsNeedUserAuthorizationError(err) {
t.Fatal("expected wrapped ExitError to match")
t.Run("typed missing-UAT error carries sentinel in cause", func(t *testing.T) {
// The typed constructor preserves the legacy sentinel in the Cause
// chain, so errors.As traverses into it.
if !IsNeedUserAuthorizationError(NewNeedUserAuthorizationError("u_1")) {
t.Fatal("expected typed missing-UAT error to match via its cause chain")
}
})
t.Run("other error", func(t *testing.T) {
err := output.ErrNetwork("API call failed: timeout")
err := errs.NewNetworkError(errs.SubtypeNetworkTransport, "API call failed: timeout")
if IsNeedUserAuthorizationError(err) {
t.Fatal("expected unrelated error not to match")
}

View File

@@ -71,7 +71,7 @@ var refreshLocks sync.Map
func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string, error) {
stored := GetStoredToken(opts.AppId, opts.UserOpenId)
if stored == nil {
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
}
status := TokenStatus(stored)
@@ -86,7 +86,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
return "", err
}
if refreshed == nil {
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
}
return refreshed.AccessToken, nil
}
@@ -99,7 +99,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
fmt.Fprintf(os.Stderr, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
}
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
}
// refreshWithLock acquires a file lock before attempting to refresh the token.

View File

@@ -131,31 +131,3 @@ func requireInTrustedDirs(effectivePath string, trustedDirs []string, label stri
}
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
}
// auditFilePermissions rejects world/group-writable modes (always) and
// world/group-readable modes (unless allowReadableByOthers is true, which
// exec commands typically need for their usual 755 mode).
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
info, err := vfs.Stat(effectivePath)
if err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
mode := info.Mode().Perm()
if mode&0o002 != 0 {
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
}
if mode&0o020 != 0 {
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
}
if allowReadableByOthers {
return nil
}
if mode&0o004 != 0 {
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
}
if mode&0o040 != 0 {
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
}
return nil
}

View File

@@ -29,3 +29,31 @@ func checkOwnerUID(path, label string) error {
}
return nil
}
// auditFilePermissions rejects world/group-writable modes (always) and
// world/group-readable modes (unless allowReadableByOthers is true, which
// exec commands typically need for their usual 755 mode).
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
info, err := vfs.Stat(effectivePath)
if err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
mode := info.Mode().Perm()
if mode&0o002 != 0 {
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
}
if mode&0o020 != 0 {
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
}
if allowReadableByOthers {
return nil
}
if mode&0o004 != 0 {
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
}
if mode&0o040 != 0 {
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
}
return nil
}

View File

@@ -5,7 +5,22 @@
package binding
import (
"fmt"
"github.com/larksuite/cli/internal/vfs"
)
// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply.
func checkOwnerUID(path, label string) error {
return nil
}
// auditFilePermissions skips POSIX permission-bit auditing on Windows because
// Go synthesizes mode bits from file attributes rather than NTFS ACLs.
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
if _, err := vfs.Stat(effectivePath); err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
return nil
}

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