Compare commits

..

107 Commits

Author SHA1 Message Date
/
5ec690117d chore: delete ppe header 2026-06-02 20:01:50 +08:00
xiongyuanwen-byted
a8b29a1cf1 chore: rename ppe x-tt-env lane to ppe_moa_canvas 2026-06-02 15:42:17 +08:00
xiongyuanwen-byted
9c7d5a4b96 Merge origin/main into feat/lark-sheets-refactor
Resolve conflicts:
- secheader.go: keep both HEAD ppe header and main AgentTrace header
- proxy_test.go: union of both test sets; align main's plugin tests to the
  2-arg WarnIfProxied(w, interactive) signature
- SKILL.md: take feat side (refactored 2.0.0 + others' value-render-option removal)
- drop 5 legacy sheets sources + cell-data.md (refactored away; main's
  #996/#1001/#984/#1073 covered by new code or no longer applicable)
2026-06-02 15:36:32 +08:00
zhengzhijiej-tech
ce9764ec2e Merge pull request #1215 from zhengzhijiej-tech/fix/delete-value-render-option
fix(sheets): drop dead --value-render-option flag from +csv-get
2026-06-02 15:00:26 +08:00
zhengzhijie
fc6e60ba5b fix(sheets): drop dead --value-render-option flag from +csv-get
+csv-get wraps get_range_as_csv, which has no value_render_option support
(absent from its input type, executor, and published tool schema — it always
returns formatted display text via getText()). The CLI passed the flag through
as a silent no-op: callers asking for raw_value/formula got formatted values.

Remove the flag from flag-defs, drop the value_render_option passthrough in
csvGetInput, and clean the stale SKILL references. The real value_render_option
capability is unchanged on +cells-get (get_cell_ranges) via --include formula.
2026-06-02 14:57:29 +08:00
xiongyuanwen-byted
5aba007f57 feat(sheets): strip UTF-8 BOM from stdin/@file flag input
resolveInputFlags now strips a leading UTF-8 BOM from content read via stdin
or @file, so it cannot corrupt the first CSV cell or break JSON parsing of
payloads like --operations / --cells downstream.

Also pulls the synced lark-sheets skill docs from sheet-skill-spec and drops
scheme-number tags from two test comments.
2026-06-02 14:26:01 +08:00
xiongyuanwen-byted
5c22f912aa feat(sheets): close P0-4 pivot gaps — enum case, clear→pivot-delete hint, placement warning
Last open P0 from the 50-trajectory analysis — the two pivot black holes:
upper-cased summarize_by, and pivots built over the source sheet that hit
#REF! and then couldn't be removed.

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

The placement_warning is structured output, not a stderr line, so it
survives non-interactive proxy-warning silencing and isn't swallowed by 2>&1.
2026-06-01 21:47:40 +08:00
xiongyuanwen-byted
7dd479df12 feat(cli): agent-friendly errors, proxy silencing, +csv-put --range
Agent-experience fixes distilled from analyzing 50 real sheets
trajectories, where the top failures were hallucinated command/flag
names, proxy warnings corrupting JSON on stdout, and --range carried
over from +csv-get to +csv-put.

- did-you-mean: unify the duplicated Levenshtein into a shared
  internal/suggest package and wire its prefix-weighted ranker into
  unknown-subcommand and unknown-flag errors; flag-parse errors now
  return a structured envelope with suggestions plus the full valid list,
  so agents recover from semantic typos (e.g. --query vs --find).
- proxy: suppress the one-time proxy warning in non-interactive
  (agent/CI/piped) runs so a 2>&1-merged stderr line cannot corrupt
  stdout JSON; interactive sessions still warn.
- sheets +csv-put: accept --range as an alias for --start-cell (parity
  with +csv-get / +cells-set) and echo the computed writes_range in
  dry-run and the success envelope, so agents see the paste footprint
  before it overwrites neighbours.
- docs(sheets): add an intent->command cheat-sheet to SKILL.md, a
  runtime-prerequisites section, and document the --range alias and
  writes_range behaviour.
2026-06-01 19:27:03 +08:00
zhengzhijiej-tech
46066de29e Merge pull request #1200 from zhengzhijiej-tech/feat/sheets-rows-get
feat(sheets): add --rows-json output flag to +csv-get
2026-06-01 18:35:55 +08:00
zhengzhijie
e58fa13716 feat(sheets): add --rows-json output flag to +csv-get
+csv-get --rows-json returns structured rows ({row_number, values:{col→cell}})
instead of the CSV string, so callers can address cells by row_number / column
letter without parsing [row=N] or RFC-4180 CSV. Same read, alternate output
shape — a flag on +csv-get (default stays CSV), not a separate shortcut, since
the two differ only in representation.

- CsvGet.Execute: --rows-json reshapes the response via assembleRowsJSON
  (parses annotated_csv into per-row records keyed by column letter; every
  logical row emitted; embedded newlines parsed into cell values)
- surfaces the under-read hint structurally as data_not_fully_read
- flag-defs.json + read-data reference synced from spec
2026-06-01 17:04:22 +08:00
zhengzhijiej-tech
f99b5bf32e Merge pull request #1160 from zhengzhijiej-tech/fix/eval-issues
docs(sheets): three SKILL-entry refinements derived from eval traces
2026-05-29 14:07:35 +08:00
xiongyuanwen-byted
74761a0e1c feat(sheets): add schema-driven JSON flag validation
Validate composite JSON flags (--properties, --cells, --options,
--border-styles, --sort-keys) against the embedded flag-schemas.json
on every standalone and +batch-update sub-op invocation, replacing
ad-hoc per-shortcut guards.

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

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

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

Removes superseded hand-written guards:
  - +pivot-create validateCreateInput / validatePivotCreateProps
  - +range-sort sort-keys per-item shape check
Test fixtures updated to be schema-conformant (chart position/size,
pivot summarize_by lowercase, cells 2D-array shape).
2026-05-29 12:46:23 +08:00
zhengzhijie
7d24e2b649 docs(sheets): tell agent to set +H for A1 references containing !
Synced from spec. The sheet-locator section now warns: when a flag value
contains `!` (--source / --range / --ranges with a cross-sheet prefix),
run `set +H` at the start of the bash session to disable history
expansion — otherwise interactive bash (e.g. inside an agent's shell
sandbox) lexes "Sheet1!A1" as a history reference and fails with
`event not found` before lark-cli ever sees the argument.

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

Also flips the previous `--range` example to `--range 'Sheet1!A1:B2'`
(shell single-quote) for consistency.
2026-05-29 11:43:00 +08:00
zhengzhijie
e3eca666fb docs(sheets): require +workbook-info before guessing sheet name
Synced from spec. SKILL.md adds a new rule under the sheet-locator
section: unless the user has explicitly named a sheet, the agent must
call +workbook-info first to fetch sheets[].sheet_id / sheets[].title
rather than guessing the default `Sheet1`. The Chinese-language tables
this CLI is typically used against rarely use that literal name —
"数据" / "Sheet" (no digit) / "工作表 1" / business-named sheets are
far more common — so guessing wastes a round-trip before the agent
ends up calling +workbook-info anyway.

The 统一调用范式 example also switches its `--sheet-name "Sheet1"`
placeholder to `<真实表名>` to remove the inadvertent suggestion that
`Sheet1` is a sensible default.
2026-05-28 20:42:45 +08:00
zhengzhijiej-tech
da65e37647 Merge pull request #1152 from zhengzhijiej-tech/fix/eval-issues
docs(sheets): sync +pivot-create summarize_by lowercase enum values from spec
2026-05-28 20:36:42 +08:00
zhengzhijie
dce617eab2 docs(sheets): strip migration-history language from pivot reference / SKILL
Synced from spec. Removes "renamed from / no longer called / not
--sheet-id" style migration-history language that snuck into the
previous sync. Reference and SKILL now describe the current flag names
directly without referencing the old names.
2026-05-28 19:23:42 +08:00
zhengzhijie
9c3d30aa00 feat(sheets): rename +pivot-create sheet selector → --target-sheet-{id,name}
+pivot-create's placement selector (where the pivot table lands) is no
longer the generic --sheet-id / --sheet-name; it is now
--target-sheet-id / --target-sheet-name. The new names mark this as the
*output* sheet, distinct from the *data-source* sheet (which lives
inside --source as `'Sheet'!Range`). The other +pivot-{list,update,delete}
shortcuts keep --sheet-id / --sheet-name (their semantics are
"sheet that hosts the existing pivot", same as every other shortcut).

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

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

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

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

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

All sheets-package tests pass.
2026-05-28 19:15:38 +08:00
zhengzhijie
690e746896 docs(sheets): drop bash histexpand section, fix write-cells table escape
Sync from spec, refining the bash-quoting deep-dive added in 0f695b6:

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

Net: 1 insertion, 40 deletions across 3 files.
2026-05-28 18:04:20 +08:00
zhengzhijie
0f695b60ec docs(sheets): wrap A1 sheet names in handwritten examples + bash histexpand guide
Synced from spec. Affects 4 reference md (chart / pivot-table / sparkline /
write-cells) and SKILL.md.

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

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

Affected files:
- skills/lark-sheets/SKILL.md (new section)
- skills/lark-sheets/references/lark-sheets-chart.md
- skills/lark-sheets/references/lark-sheets-pivot-table.md
- skills/lark-sheets/references/lark-sheets-sparkline.md
- skills/lark-sheets/references/lark-sheets-write-cells.md
2026-05-28 17:51:00 +08:00
zhengzhijie
77f86ec2fd docs(sheets): wrap sheet names in single quotes in A1 examples
Synced from spec. Affects 3 reference md (pivot-table / batch-update /
write-cells) and 2 generated flag-data JSONs.

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

Affected flags:
- +pivot-create --source
- +dropdown-update --ranges / --source-range
- +dropdown-delete --ranges
- +dropdown-set --source-range
- +cells-batch-set-style --ranges
- +cells-batch-clear --ranges
2026-05-28 17:38:15 +08:00
zhengzhijie
8e84f47d3e docs(sheets): sync +pivot-create summarize_by lowercase enum values from spec 2026-05-28 16:34:34 +08:00
zhengzhijiej-tech
5ebb1398e7 Merge pull request #1138 from zhengzhijiej-tech/feat/dim-resize-a1-range-schema
refactor(sheets): switch dim-* / rows-cols-resize to A1-string range schema
2026-05-27 19:59:24 +08:00
zhengzhijie
0476dec83c docs(sheets): sync +pivot-create placement reference from spec
Companion sync from sheet-skill-spec — the canonical reference rewrites
+pivot-create's "5 placement-related flags" rundown into a clearer
"4 placement-related flags" form (--target-sheet-id was already removed
in #1130, this updates the prose accordingly), and clarifies that
--sheet-id / --sheet-name on +pivot-create are the *placement* sheet
(not the source-data sheet), with omit-both as the strongly-recommended
default.

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

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

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

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

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

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

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

Tests: TestSheetStructureShortcuts_DryRun / TestDimMove_DryRun /
TestDimMove_Column / TestDimMove_MismatchedDimension /
TestDimRange_Validation / TestParseA1Range / TestResize_TypeAndSizeGuards
/ TestRangeOperationsShortcuts_DryRun all rewritten against the new
schema. Batch contract trio (BodyMatchesStandalone /
ErrorEquivalence / RejectsBadSubOpInput) and
TestBatchOp_DispatchCoversReportedBugs likewise. Full
`go test ./shortcuts/sheets/` passes.
2026-05-27 19:36:07 +08:00
xiongyuanwen-byted
ce5878e3c4 docs(sheets): clarify filter/filter-view rules update is whole-set PUT
Synced from upstream tools-schema. The rules field on manage_filter_object and manage_filter_view_object now documents update as whole-set PUT semantics: submitted rules become the complete rule set, all existing columns' rules are cleared first, columns not listed lose their old rules (no merge), and [] clears everything. Description-only change, no structural/field change.
2026-05-27 19:16:04 +08:00
zhengzhijiej-tech
e85afd68d2 Merge pull request #1130 from zhengzhijiej-tech/fix/pivot-create-schema-alignment
fix(sheets): allow +pivot-create to omit both sheet selectors
2026-05-27 17:01:46 +08:00
zhengzhijie
a09593a0fe fix(sheets): allow +pivot-create to omit both sheet selectors
manage_pivot_table_object treats sheet_id / sheet_name as the placement
target — when both are absent, handleCreate() auto-creates a new sub-sheet
to host the pivot table. The CLI's flag schema didn't reflect this:

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

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

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

- helpers.go: new optionalSheetSelector (both empty allowed; both set
  still rejected; control-char validation unchanged). requireSheetSelector
  is untouched — every existing caller keeps the exactly-one contract.
- lark_sheet_object_crud.go: objectCRUDSpec gains
  allowEmptySheetSelectorOnCreate; objectCreateInput dispatches to
  optionalSheetSelector when it's set. Only pivotSpec opts in;
  chart / cond-format / sparkline / filter-view / float-image keep
  the existing require semantics. DryRun and Execute switch to direct
  flag extraction (same pattern Validate already used) so the XOR
  check happens in exactly one place (the builder).
- pivotSpec: drop the enhanceCreateInput branch that read the now-removed
  --target-sheet-id flag.
- Tests: TestPivotCreate_SheetSelectorSemantics covers both-empty /
  both-set / single-set; TestObjectCreate_RequiresSheetSelector
  regresses chart / cond-format / sparkline / filter-view to lock the
  scope of the relaxation.
2026-05-27 16:11:07 +08:00
xiongyuanwen-byted
60c61d8157 docs(sheets): sync lark-sheets skill from spec
Mirror the canonical-spec reference fixes into the consumer skill:
- search_replace output contract: `matches[]` with `address` (+ `has_more`/`next_offset`)
- workbook sheet fields: `sheet_name`/`is_hidden`/`*_count`, no `frozen_*`
- `+range-fill` example uses a non-overlapping target (A3:A100)
- drop the unimplemented `envelope.meta.verification` auto-readback claim; advise
  manual list/get verification instead
2026-05-27 11:56:42 +08:00
xiongyuanwen-byted
08e8b5c870 fix(sheets): allow +float-image-update to omit the image source
The image source (--image-token / --image-uri) is the only optional part
of an update: omit all of them to keep the current image. image_name,
position and size stay required — the manage_float_image tool rejects an
update without them, and +float-image-list does not return image_name to
backfill. Previously the shortcut forced an image source even when only
position/size changed, so those updates were rejected CLI-side before any
API call (reported as a Fail case in the sheets e2e rerun).

- floatImageProperties: gate the image-source requirement on create only;
  keep image_name/position/size required on both; emit image_uri only when set
- sync flag-defs.json + lark-sheets-float-image.md from sheet-skill-spec
  (image-name/position/size now required on +float-image-update)
- tests: cover the image-source-optional dry-run; the single-required checks
  move to the +batch-update sub-op path (cobra owns the standalone path)
2026-05-27 11:10:32 +08:00
zhengzhijiej-tech
338cdaa6db Merge pull request #1107 from zhengzhijiej-tech/sheets/float-image-reference-examples
docs(sheets): fix non-runnable float-image reference examples
2026-05-26 21:36:15 +08:00
zhengzhijie
55ccbc5f6a feat: 同步 tools-schema.json 改动 2026-05-26 21:34:04 +08:00
zhengzhijie
bea4c746ae docs(sheets): sync float-image reference from spec — fix non-runnable examples
Two examples in skills/lark-sheets/references/lark-sheets-float-image.md
didn't actually run against PPE; sync brings them in line with CLI behavior:

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

Upstream change: sheet-skill-spec MR fix/float-image-reference-examples.
2026-05-26 20:31:18 +08:00
zhengzhijiej-tech
4b16fe9ce0 Merge pull request #1098 from zhengzhijiej-tech/fix/batch-ranges-prefix-clarity
fix(sheets): +dropdown source-range warns when highlight + cells > 2000
2026-05-26 16:58:55 +08:00
zhengzhijie
f53e55ce65 fix(sheets): align +cells-search/+cells-replace option keys with server schema
The CLI emitted `options.regex` and `options.include_formulas`, but the
server-side `search_data` / `replace_data` tool schemas declare and
consume `use_regex` and `match_formulas`. Result: passing `--regex` or
`--include-formulas` always failed with `unexpected property ... is not
defined in schema`.

Keep the user-facing flag names (`--regex`, `--include-formulas`) — only
the JSON keys sent to the server change. Updates the dry-run test that
locked the wrong contract.
2026-05-26 16:13:53 +08:00
zhengzhijie
71eae77f65 docs(sheets): sync lark-sheets skill from spec — +filter-view-update --properties desc
去掉 +filter-view-update --properties 描述里"pass at least one of
--properties.rules / --range / --view-name"的误导承诺。--properties
实际是硬必填(MarkFlagRequired),且 update 走 PUT 整组覆盖语义。
2026-05-26 16:08:14 +08:00
zhengzhijie
08d025945e revert(sheets): drop tools-schema drift mirror from previous spec sync
930c9c7 顺带 sync 了 spec 的 tools-schema bundling — 跟那条 commit 一起
误带进来 chart float block required 和 data_validation.items 描述微调,
这两处其实是上游 sheet-ai-skills 还在 pending 的 revert。

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

dropdown 文案改动(flag-defs.json 4 处 desc + dropdown 段的 reference
渲染)不在本 commit 范围,保持 930c9c7 的状态。
2026-05-26 15:31:40 +08:00
zhengzhijie
930c9c77a8 docs(sheets): sync lark-sheets skill from spec — dropdown flag descs reflect server reality
Pulls sheet-skill-spec canonical-spec → generated → consumers chain for
dropdown flag desc corrections committed upstream (Shortcut-flags base
table rows for +dropdown-set / +dropdown-update --options and
--source-range).

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

Also picks up an unrelated upstream tools-schema.json drift (chart float
block schema + data_validation.items description tweak) that surfaced
via npm run check:tool-schemas; bundling keeps the spec sync gate green.
2026-05-26 15:16:27 +08:00
zhengzhijie
bb7ccaedf9 fix(sheets): warn when +dropdown source-range exceeds 2000 cells with highlight on
byted-sheet's ListFromRangeValidation.checkOptionsValid() sets
isOptionError=true when shouldHighlightValidData is on and the source
range exceeds LIST_WITH_COLOR_MAX_COUNT (2000 cells) — the highlight +
large source combo is unsupported. CLI previously had no signal for
this, so users only learned by seeing the dropdown render as
option-error in the workbook.

Add a Validate-phase stderr warning in +dropdown-set and +dropdown-update
when --source-range covers >2000 cells unless --highlight=false. Soft
warning, never blocks the request. Inline --options is not subject to
this limit — server enforces no count or per-item length cap on inline
lists, so no warning fires there.
2026-05-26 14:52:58 +08:00
zhengzhijie
a0d6472e9f feat: 同步 tools-schema.json 改动 2026-05-26 14:48:12 +08:00
xiongyuanwen-byted
6b3c0b5556 docs(sheets): sync lark-sheets skill docs from upstream spec
- SKILL.md: clarify --url only resolves /sheets/ and /spreadsheets/ links; /wiki/ links must be resolved via wiki +node-get first (confirm obj_type=sheet, use obj_token)
- formula-translation: document IMPORTRANGE cross-workbook limits (max 5-level nesting, 100 refs per sheet)
- write-cells: document rich_text cells for hyperlinks, @mentions and @docs
2026-05-26 12:50:52 +08:00
zhengzhijiej-tech
f4bcb85d2e Merge pull request #1089 from zhengzhijiej-tech/fix/batch-ranges-prefix-clarity
fix(sheets): clarify batch --ranges prefix must be sheet display name
2026-05-26 11:02:11 +08:00
zhengzhijie
5880d070e2 fix(sheets): clarify batch --ranges prefix must be sheet display name
E2E test cases repeatedly trip on this:

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

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

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

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

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

No Go changes; these files are regenerated from the upstream Lark Base
Shortcut-flags table via the sheet-skill-spec sync chain.
2026-05-25 22:17:33 +08:00
zhengzhijiej-tech
2082095f18 Merge pull request #1090 from zhengzhijiej-tech/fix/dropdown-get-sheet-id-prefix
feat(cmdutil): route requests via PPE test lane via secheader
2026-05-25 22:11:25 +08:00
zhengzhijie
6cadbe807a feat: 把环境变量提交上去 2026-05-25 21:56:25 +08:00
zhengzhijiej-tech
927a73faa2 Merge pull request #1083 from zhengzhijiej-tech/fix/dropdown-get-sheet-id-prefix
fix(sheets): +dropdown-get accepts --sheet-id/--sheet-name + bare --range
2026-05-25 21:28:30 +08:00
zhengzhijie
9c447e735b fix(sheets): drop Sheet1! prefix from +cells-get / +csv-get / +csv-put flag examples
Server tools-schema.json for get_cell_ranges, get_range_as_csv and set_range_from_csv
does not accept a sheet prefix on --range / --start-cell; the sheet is selected via
--sheet-id / --sheet-name. +csv-put --start-cell also now states it must be a single
cell (no range notation).

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

Flag schema + skill reference regenerated from the upstream Lark Base
Shortcut-flags table.
2026-05-25 21:08:58 +08:00
xiongyuanwen-byted
5327e9390d feat(sheets): support local --image in +float-image-create
+float-image-create now accepts a local file via --image (XOR with
--image-token / --image-uri): the CLI uploads it as a sheet_image and
embeds the returned file_token, removing the previous "upload elsewhere
to get a token first" workaround. Path safety is checked in Validate,
--dry-run previews the extra upload step, and +batch-update rejects
--image (no upload phase). +float-image-update is unchanged (it does not
register --image).

Also syncs the lark-sheets skill docs/flag-defs from sheet-skill-spec:
the new --image flag, partial-merge / border-per-side / bare sheet-prefix
clarifications, and refreshed dropdown --colors/--highlight descriptions
(already pending in the source Base table).
2026-05-25 19:20:50 +08:00
xiongyuanwen-byted
dece428487 fix(sheets): validate +cells-set-image --image path in Validate
The unsafe-path check only ran at Execute (via FileIO.Stat), so --dry-run
printed a misleading success preview for an absolute / out-of-cwd --image
path that a real run would then reject. Move the path-safety check into
Validate (validate.SafeLocalFlagPath), so --dry-run and Execute fail
identically and both name the real --image flag. File existence stays
deferred to Execute, so legitimate relative paths still preview cleanly.

Add TestCellsSetImage_DryRunRejectsUnsafePath.
2026-05-25 19:20:33 +08:00
zhengzhijiej-tech
ff493d4534 Merge pull request #1075 from zhengzhijiej-tech/fix/sheets-e2e-fixes-batch1
fix(sheets): align +dropdown / +workbook-create / +range-sort with server + tri-state --highlight
2026-05-25 19:13:24 +08:00
zhengzhijie
ff78ff40d8 chore(sheets): sync +dropdown flag desc + write-cells narrative from spec
Mirror sheet-skill-spec generated/ into shortcuts/sheets/data/ and
skills/lark-sheets/ for the +dropdown-set / +dropdown-update path. No
hand edits in this repo.

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

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

Source upstream: sheet-skill-spec MR !8.
2026-05-25 17:48:06 +08:00
zhengzhijie
1a2d2d04be feat(sheets): +dropdown --highlight tri-state via Changed() for opt-out
The server-side default for data_validation.enable_highlight flipped from
false to true (aligning with the UI behavior). With the previous code path

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

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

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

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

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

Test coverage:
  - TestDropdownSet_HighlightTriState pins all four shapes (omit / presence
    form / explicit true / explicit false) and asserts the enable_highlight
    key's presence/value
  - TestBatchOp_BodyMatchesStandalone adds a --highlight=false sub-op case
    so the batch sub-op path produces a body byte-identical to the
    standalone +dropdown-set --highlight=false body
2026-05-25 17:40:17 +08:00
zhengzhijiej-tech
5eaa70b74a Merge pull request #1030 from zhengzhijiej-tech/fix/sheets-e2e-fixes-batch1
fix(sheets): align +workbook-create, +dropdown-*, +dim-move, +range-sort with server schema (restore --colors/--highlight)
2026-05-25 14:43:15 +08:00
zhengzhijie
f0dea38aeb fix(sheets): post-rebase test fixups after dropping superseded fix #1
Two test fallouts from rebasing onto upstream 4be06c8 (which independently
re-fixed +workbook-create and +dim-move with a more thorough approach):

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

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

go build ./... + go test ./shortcuts/sheets/... -count=1 both green.
2026-05-25 14:41:59 +08:00
zhengzhijie
fa503fa47f chore(sheets): restore --colors in parseJSONFlag docstring example list
The earlier commit 49104ec swapped --colors out of parseJSONFlag's "Used
by" example list when it deleted the flag (item #2 there removed --colors
/ --highlight from +dropdown-set/-update). Subsequent commits 8672d8e /
538eb2e / fb90c8b reinstated --colors (and added --source-range) but did
not roll back this docstring tweak — leaving an orphan reference to
--properties where --colors used to be.

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

Pure docstring change — function behavior unaffected, no test movement.
2026-05-25 14:39:36 +08:00
zhengzhijie
38ef6ad51e docs(sheets): sync agent-voice rewrite of Dropdown 选项+配色 from spec
Mirrors sheet-skill-spec MR !7 60df610 — narrative now describes how the
flags interact (XOR, colors length rule, highlight gating, sheet-prefix
read-back gotcha) without exposing the underlying data_validation field
names or server-side normalization details that agents don't act on.

No Go-side change, no shortcut behavior change.
2026-05-25 14:39:36 +08:00
zhengzhijie
f0d218f7ea feat(sheets): +dropdown-set/-update --source-range for listFromRange mode
Previously +dropdown-set / +dropdown-update only emitted
data_validation.type=list — agents wanting listFromRange (dropdown options
sourced from existing cells, kept in sync with that range) had to drop down
to +cells-set and hand-build a data_validation map. The flag now exposes it
natively as --source-range, paired with --options under XOR.

CLI changes:

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

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

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

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

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

No Go-side change — earlier commit 538eb2e already loosened the
buildDropdownValidation length check to "must not exceed"; this PR step
just makes the docs / `--help` text catch up.
2026-05-25 14:39:36 +08:00
zhengzhijie
2ee2a59dff chore(sheets): re-sync from spec + loosen --colors length check
Catches up to sheet-skill-spec's 2026-05-25 base sync (MR !7) after
rebasing onto upstream feat/lark-sheets-refactor (12 new upstream commits
including the lark-sheets skill refactor + tools-schema migration).

Spec changes flowing in:

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

Go-side adjustment:

- buildDropdownValidation / validateDropdownOptionsColors: change the
  --colors length check from strict-equal to "must not exceed --options"
  to match the relaxed schema.
- TestDropdownSet_ColorsLengthMismatch -> TestDropdownSet_ColorsLongerThanOptions
  (now hits the overflow path with 3 colors vs 2 options).
- New TestDropdownSet_ColorsShorterAccepted: 2 colors vs 4 options is
  legal and forwarded as-is.
2026-05-25 14:39:36 +08:00
zhengzhijie
96c338735a fix(sheets): restore +dropdown --colors / --highlight, map to canonical fields
Reverses the --colors / --highlight removal from 7932ab2 (item #2 of the
batch-1 schema-alignment commit). That commit dropped both flags after the
test report flagged data_validation.colors / highlight_options as "unexpected
property" — at the time the canonical set_cell_range.data_validation schema
listed only help_text / items / operator / range / support_multiple_values /
type / values, so the flags had no server-side target and the removal was
correct.

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

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

So the flags are back, but rewired:

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

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

Changes:

- buildDropdownValidation: re-add --colors / --highlight handling against
  the new field names; --colors length check stays inline (so dropdownSetInput
  Validate path catches it via validateViaInput, no separate guard needed).
- validateDropdownOptions -> validateDropdownOptionsColors: restore the
  Validate-time --colors length check on +dropdown-update / +dropdown-delete
  (called from lark_sheet_batch_update.go).
- TestDropdownSet_CellsShape: extend to assert highlight_colors /
  enable_highlight emitted; assert legacy `colors` / `highlight_options`
  absent.
- TestDropdownSet_ColorsLengthMismatch: new — covers the early Validate
  error path.
- TestDropdownUpdate_BatchPayload: extend to cover dropdownBatchInput
  propagation of --colors / --highlight through batch_update.
- skills/lark-sheets/references/lark-sheets-{write-cells,batch-update}.md,
  shortcuts/sheets/data/flag-defs.json, flag-schemas.json: synced from
  sheet-skill-spec generate output (MR !7).
2026-05-25 14:39:36 +08:00
zhengzhijie
101c572d64 docs(sheets): sync from sheet-skill-spec — remove dropdown --colors / --highlight
Upstream sheet-skill-spec base table deleted the --colors and --highlight
flags on +dropdown-set / +dropdown-update (the corresponding wire fields
data_validation.colors / .highlight_options were never accepted by the
server schema; see prior fix in this branch). Re-running the sync from
canonical-spec brings the CLI flag-defs and skill reference docs back in
line with the Go-side handling that already drops these fields.

Generated by `npm run sync:cli` in sheet-skill-spec @ ac7acef.
2026-05-25 14:39:36 +08:00
zhengzhijie
9d06652aa9 revert(sheets): drop direct edits to skills/lark-sheets/references/
These md files are sync targets generated from sheet-skill-spec; editing
them here gets clobbered on the next sync, same as data/flag-defs.json.
The --colors / --highlight row removals belong on the upstream base
table → canonical-spec sync, not here.

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

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

Functional fixes (#1 workbook-create, #3 dropdown-get, #4 dim-move,
#5 range-sort) and dropdown wire-shape rename (#2) are unaffected.
2026-05-25 14:39:36 +08:00
zhengzhijie
5926e89ce3 revert(sheets): drop direct flag-defs.json edits — generated from spec
data/flag-defs.json is regenerated from the upstream sheet-skill-spec
canonical-spec; editing it here gets clobbered on the next sync. The
schema realignment for +dropdown-set / -update --colors / --highlight
removal needs to land on the base table first, then flow back through
sheet-skill-spec → larksuite-cli sync, not via a direct CLI-side edit.

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

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

Functional fixes (#1 workbook-create, #3 dropdown-get, #4 dim-move,
#5 range-sort) and dropdown wire-shape rename (#2) are unaffected.
2026-05-25 14:39:36 +08:00
zhengzhijie
556b2292c7 fix(sheets): align +workbook-create, +dropdown-*, +dim-move, +range-sort with server schema
Five separate E2E failures in shortcuts/sheets/ that all trace back to a
CLI ↔ server contract mismatch. Each is independently scoped; bundling
them because they share the test-report citation and the same one-line
fix shape in most cases.

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

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

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

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

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

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

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

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

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

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

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

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

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

Server-schema citations and test-report case mapping in this branch's
plan file.
2026-05-25 14:39:35 +08:00
xiongyuanwen-byted
4be06c85f6 fix(sheets): correct +workbook-create initial fill and +dim-move endpoint
+workbook-create: the v3 create response does not echo the default sheet's id, so the initial-fill set_cell_range was sent with an empty sheet_id and rejected ("sheet_id or sheet_name is required"). Resolve the workbook's first sheet via get_workbook_structure before filling.

+dim-move: the move request was POSTed to the v2 dimension_range endpoint (the add/update/delete surface, which requires a `dimension` object) and rejected with "[9499] Missing required parameter: Dimension". Switch to the native v3 move_dimension endpoint (sheet_id in path; snake_case source.{major_dimension,start_index,end_index} + destination_index). CLI --end and v3 end_index are both 0-based inclusive, so they pass through unchanged.
2026-05-25 14:15:48 +08:00
xiongyuanwen-byted
868beaf004 docs(sheets): sync lark-sheets skill spec from upstream
Refine reference docs from upstream sheet-skill-spec (core-operations,
formula-translation, visual-standards, SKILL guidance). Description-only;
no flag or CLI behavior change.
2026-05-25 11:54:43 +08:00
xiongyuanwen-byted
81bb61359d docs(sheets): sync lark-sheets skill spec from upstream
Trim and refine reference docs from upstream sheet-skill-spec
(condense core-operations workflow, tidy write-cells / range-operations /
float-image / SKILL guidance). Description-only; no flag or CLI behavior
change.
2026-05-24 14:44:17 +08:00
xiongyuanwen-byted
370137e1c3 docs(sheets): sync lark-sheets skill spec from upstream
Refine reference docs and flag-defs descriptions from upstream
sheet-skill-spec: clarify +workbook-export sheet flag scope, +filter-*
--properties optionality (omitted => empty filter on --range; rules must
be non-empty when provided), float-image reference_id wording, and
assorted reference cleanups. Description-only; existing CLI behavior
(filter passthrough, properties optional) already matches.
2026-05-24 09:55:47 +08:00
xiongyuanwen-byted
48e6072342 docs(sheets): sync chart properties schema (position/size required)
Regenerate flag-schemas.json from upstream sheet-skill-spec: the chart
properties schema now marks position and size as required, and the chart
reference doc reflects the same. flag-schemas.json is print-schema-only
(no client-side validation), so this is a generated-artifact + doc sync
with no CLI behavior change.
2026-05-23 19:09:02 +08:00
xiongyuanwen-byted
b85311c873 docs(sheets): sync lark-sheets skill spec from upstream
Refine reference docs and flag-defs descriptions from upstream
sheet-skill-spec (--depth wording for +dim-group / +dim-ungroup,
plus assorted reference clarifications). Description-only; no CLI
behavior or flag surface change.
2026-05-23 13:45:44 +08:00
xiongyuanwen-byted
2f5c625ac7 docs(sheets): sync lark-sheets read docs — --max-chars as single read cap
Sync skills/lark-sheets references from spec: drop --cell-limit / --max-rows
guidance; 大表分批读 switches to --range row windows + --max-chars auto cap + has_more.
Mirrors sheet-skill-spec 58e7456 and handler change 2befc49.
2026-05-23 09:05:55 +08:00
xiongyuanwen-byted
e0c22d6ee0 fix(sheets): make --max-chars the single read cap for +cells-get / +csv-get
Drop --cell-limit (+cells-get) and --max-rows (+csv-get) from the CLI surface
and pin the underlying tool's cell_limit / max_rows to a very large sentinel so
the tool's own defaults never truncate before --max-chars. --max-chars stays the
only knob (default 200000, unchanged).

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

Mirrors sheet-skill-spec (Base flag records removed).
go build ./... + go test ./shortcuts/sheets/ green.
2026-05-23 08:59:11 +08:00
xiongyuanwen-byted
efcc55460b docs(sheets): sync lark-sheets skill spec (chart/pivot wire mappings, --end semantics)
Sync skill references and flag-defs descriptions from upstream
sheet-skill-spec: clarify +chart-create properties structure
(snapshot.data), +pivot-create --target-position / --range wire-field
mappings, add a cross-command --end endpoint-semantics table
(insert/delete/hide/group exclusive vs move/resize inclusive), note
--group-state default, and rename reference identifiers to lark-sheets-*.

Description-only refinement; the existing CLI implementation already
matches the clarified wire mappings and --end semantics.
2026-05-23 01:18:25 +08:00
xiongyuanwen-byted
e003d4aa01 feat(sheets): add +cells-set --copy-to-range and sync skill spec
Sync lark-sheets skill references and flag schemas from upstream
sheet-skill-spec, and wire the newly-specced --copy-to-range flag into
+cells-set: it passes copy_to_range to the set_cell_range tool so a
template block written via --cells fans out across a larger range with
auto-shifted formula refs.
2026-05-22 18:10:45 +08:00
zhengzhijiej-tech
d91341bca3 Merge pull request #1023 from zhengzhijiej-tech/fix/sheets-audit-20260521
fix(sheets): apply lark-sheets audit 20260521 + sync spec docs
2026-05-21 22:10:47 +08:00
zhengzhijie
5a42fb5788 docs(sheets): sync lark-sheets skill from spec (audit 20260521)
Pull latest spec from sheet-skill-spec (PR ee/sheet-skill-spec!6 + earlier
develop commits) into skills/lark-sheets/ and shortcuts/sheets/data/.

Audit findings now reflected in CLI docs:

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

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

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

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

`go test ./shortcuts/sheets/...` passes.
2026-05-21 20:55:29 +08:00
zhengzhijie
d914c851ac fix(sheets): require sparkline_id on +sparkline-update items (#6)
manage_sparkline_object uses two layers of IDs: --group-id picks the
sparkline group, and properties.sparklines[i].sparkline_id picks each
item inside the group. The server contract requires sparkline_id on
every update item (server maps each entry back to an existing
sparkline by this id). Agents that called +sparkline-update without
the per-item ids hit an opaque server-side rejection that didn't
mention sparkline_id at all, then got stuck in a try-fail-list-retry
loop.

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

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

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

Tests:

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

Docs synced from sheet-skill-spec (canonical change committed there):
skills/lark-sheets/references/lark-sheets-sparkline.md documents the
two-layer id model, the three "+sparkline-list first" cases, and both
delete modes.
2026-05-21 20:55:29 +08:00
xiongyuanwen-byted
300a5e8906 docs(sheets): sync stdin guidance and sparkline reference
- skills/lark-shared/SKILL.md: drop the generic "prefer stdin" section
- skills/lark-sheets/SKILL.md: add expanded stdin guidance (use stdin over @file abs paths; don't cd or write into the project dir)
- skills/lark-sheets/references/lark-sheets-sparkline.md: document the group_id / sparkline_id two-tier model with worked examples
2026-05-21 20:31:58 +08:00
xiongyuanwen-byted
5f3e8c6385 feat(sheets): add +cells-batch-clear fan-out over batch_update
Clear content/formats across many sheet-prefixed ranges in a single atomic
batch_update (one clear_cell_range op per range), mirroring the existing
+cells-batch-set-style / +dropdown-{update,delete} fan-out wrappers. The
--scope to clear_type normalization is shared with standalone +cells-clear
(normalizeClearType) so the two stay in lockstep.

high-risk-write (requires --yes); rejected as a batch sub-op like the other
fan-out wrappers. flag-defs/flag-schemas and skill docs updated to match.
2026-05-21 19:17:33 +08:00
zhengzhijiej-tech
8e8a5110ee Merge pull request #1018 from zhengzhijiej-tech/fix/sheets-validation-pushdown
fix(sheets): +batch-update sub-op CLI-side validation + cond-format / filter schema alignment
2026-05-21 17:20:15 +08:00
zhengzhijie
4e44e668f7 fix(sheets): align +cond-format / +filter with server schema (#4 + #5)
Two latent bugs in the object_crud translator surfaced during BOE smoke
testing of +batch-update. Both are schema-alignment fixes against
manage_conditional_format_object / manage_filter_object as declared in
sheet-skill-spec/canonical-spec/tool-schemas/mcp-tools.json.

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

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

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

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

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

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

- #2: lark-sheets-sheet-structure.md — +dim-{hide,unhide,group,
  ungroup} --start/--end desc changed from "(0-based, inclusive)" to
  "(0-based)" / "(exclusive)" to match the half-open range semantics
  the code has always implemented (requireDimRange: end > start;
  dimRange uses end - 1 for column end letters).
- #3: lark-sheets-workbook.md — +sheet-move section gains a note
  about the batch-internal requirement to pass --sheet-id AND
  --source-index explicitly (sheetMoveBatchInput's constraint).
- #8: lark-sheets-pivot-table.md — +pivot-create --properties
  example drops the stale data_range field (the actual server
  schema uses --source as a hoisted flag; properties only carries
  rows / columns / values / filters / show_*_grand_total).
2026-05-21 16:37:15 +08:00
zhengzhijie
8d0fefd9e0 fix(sheets): push +batch-update sub-op validation down into xxxInput builders
Sub-ops that omit --sheet-id (or any other required flag) used to slip
past CLI validation — Validate ran only against the standalone shortcut
path, and batchOpDispatch's translators built bodies from whatever
flagView returned, so a structurally broken sub-op surfaced as an opaque
server "sheet undefined not found" after a network round-trip.

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

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

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

Tests:

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

sheetMoveBatchInput (xiongyuanwen's batch-only explicit-source-index
requirement) is preserved — it's an orthogonal batch-specific constraint
not affected by this push-down.
2026-05-21 16:37:00 +08:00
xiongyuanwen-byted
1e05e7b3ad fix(sheets): align +cells-get/+csv-get range flags with synced spec
sheet-skill-spec now declares +cells-get --range as a single string
(was string_array) and +csv-get --range as required. Match the
flag→body translators:

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

Update read-data dry-run tests to single-range form and add a guard
test for the empty --range path.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
0ea7c14e4a fix(sheets): make +batch-update sub-ops reuse standalone flag→body translators
Sub-ops previously near-passed-through their input, so any shortcut whose
standalone translator renames fields broke inside a batch: +range-copy lost
range/destination_range (transform_range errored "range missing") and
+rows-resize lost range/resize_height ("No resize operation specified").

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

Also fix single-row/column resize: start==end now formats as "23:23" / "C:C"
(resize_range rejects a bare "23"); dimRangeFull keeps both sides while
dimRange's collapse stays for modify_sheet_structure consumers.
2026-05-21 12:42:47 +08:00
zhengzhijie
0c2e5f5e5c feat(sheets): translate +batch-update sub-ops {shortcut,input} → MCP shape
Users now hand +batch-update --operations a CLI-shape array
([{shortcut, input}, ...]) and the binary translates each sub-op to the
underlying MCP batch_update shape ({tool_name, input(+operation)}) via
a new dispatch table in shortcuts/sheets/batch_op_dispatch.go.

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

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

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

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

Sync'd from sheet-skill-spec: skills/lark-sheets/references/
lark-sheets-batch-update.md + shortcuts/sheets/data/flag-schemas.json
pick up the corrected enum (+cells-set-style / +dropdown-set added,
+dim-move removed).
2026-05-21 12:42:47 +08:00
zhengzhijie
9048c7097f docs(sheets): sync +batch-update CLI override schema (shortcut/input form)
Pulled from sheet-skill-spec:
- skills/lark-sheets/references/lark-sheets-batch-update.md: --operations
  now documents the {shortcut, input} form; tool_name references gone
- shortcuts/sheets/data/flag-schemas.json: --operations resolves to the
  CLI-side array<{shortcut(enum), input}> schema, sourced from spec's
  canonical-spec/tool-schemas/cli-schemas.json (cli: prefix). +dropdown
  --options also drilled one level deeper

NOTE: the binary still raw-passes --operations to MCP batch_update which
expects {tool_name, input}. A follow-up will add a shortcut→tool_name
translation layer (with per-shortcut operation field) before the docs
become actionable.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
3d3e2c7f10 refactor(sheets): build shortcut flags generically from flag-defs.json
Replace flag-descriptions.en.json with the richer flag-defs.json (full
flag definitions: type / default / enum / input / hidden / required /
kind) synced from sheet-skill-spec. Add flagsFor(command) to materialize
each shortcut's []common.Flag straight from the JSON, skipping
system-kind flags the framework injects.

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

Align Go with the new spec where they diverged: +cells-get --ranges →
--range, font-size int → float64, +filter-view-create --range now
required, +sheet-create row/col-count defaults 200/20.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
ce852e26d8 feat(shortcuts): support int64 and float64 flag types
Flag.Type previously could not express non-integer numbers. Add int64
and float64 cases to flag registration plus Int64/Float64 runtime
accessors.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
460c794f28 feat(sheets): add flag-descriptions.en.json and wire applyFlagDescs into Shortcuts()
Embed data/flag-descriptions.en.json (synced from upstream spec) and
apply it at shortcut assembly time so every Flag.Desc is sourced from
the canonical JSON rather than hardcoded Go strings. Existing hardcoded
Desc values serve as fallback for flags not yet in the JSON.

Also sync reference doc updates from upstream.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
54914e6082 docs(sheets): remove sandbox references and normalize tool names to CLI shortcuts
Replace export_sheet_to_sandbox / import_sandbox_to_sheet / doubao_code_interpreter
with local-script + batch csv-get/csv-put workflows; unify legacy MCP tool names
(set_cell_range, get_range_as_csv, etc.) to CLI shortcut format (+cells-set, +csv-get).
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
50190e8638 feat(sheets): add --print-schema runtime introspection for composite JSON flags
Composite JSON flags (--cells / --properties / --operations /
--border-styles / --sort-keys / --options) carry non-trivial structured
payloads. Reference docs cover top-level fields but agents writing
those flags often need the full JSON Schema to build a valid payload.

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

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

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

Framework changes (shortcuts/common):

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

Sheets surface (shortcuts/sheets):

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

Skill docs (skills/lark-sheets):

- SKILL.md: system-flag table gains --print-schema / --flag-name and
  an "Agent 使用提示" note steering agents to prefer --print-schema
  over guessing JSON shape from the cheatsheet.
- references/*.md: regenerated by upstream sync (Schemas-section
  boilerplate updated, plus accumulated upstream prose refinements).
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
184949ff0c refactor(sheets)!: split +dim-resize into +rows-resize and +cols-resize
Sync to upstream spec change that splits the legacy +dim-resize shortcut
into +rows-resize and +cols-resize. Reasoning is that row vs column
resize has divergent semantics (only rows support auto-fit) and the
shared --dimension flag was hiding that.

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

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

Also pulls in synced reference docs for 5 skills (batch-update,
core-operations, range-operations, sheet-structure, visual-standards)
that update prose mentions of +dim-resize.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
347d80361d fix(sheets): repair cells-set-image rich_text embed payload
The server rejected set_cell_range calls from +cells-set-image with three
distinct errors: missing "text" property, missing image_width/image_height,
and unknown attachment_token field. Realign the rich_text element to the
embed-image schema (text/image_token/image_width/image_height) and decode
PNG/JPEG/GIF dimensions from the local file before the write.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
be31975f7e refactor(sheets): align batch_update + cells-set with synced reference docs
Sync to upstream reference doc updates for 9 skills:

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

All other doc changes (float-image flat flags, cond-format
--rule-type/--ranges, pivot create-only --source/--range, filter /
filter-view extra flags, chart --properties) were already aligned in
commit ce33315.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
2acff2b17f refactor(sheets): sync shortcut flags with sheet-skill-spec v0.5.0
Upstream hoisted a batch of high-frequency scalar fields out of --data
into independent flags and renamed several composite-JSON flags to
match their semantic content. CLI catches up.

Renames (drop-in, same payload semantics):

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

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

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

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

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

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

Tests track every flag renamed and add explicit cases for the new flag
combos. go test -race -cover stays at 60.3 %.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
b9a1752095 refactor(sheets): inline cli-only shortcuts into their canonical skill files
Two naming cleanups:

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

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

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

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

Behavior unchanged. go test -race -cover stays at 60.5 %.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
96f5742511 test(sheets): cover all 70 shortcuts with dry-run + execute-path tests
Twelve _test.go files alongside the implementation, mirroring the legacy
package's coverage style:

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

Test coverage on the sheets package is 60.5 % of statements with -race
clean, meeting the dev manual's ≥ 60 % patch-coverage gate.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
9898024392 feat(sheets): implement cli-only shortcuts (B8) — 70/70 complete
Land the four cli-only shortcuts that can't route through the One-OpenAPI
dispatcher (their backing capabilities aren't in mcp-tools.json):

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

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

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

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

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

This closes the migration: 70 shortcuts × 17 canonical skills × matching
the sheet-skill-spec v0.5.0 tool-shortcut-map.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
705844f312 feat(sheets): implement lark_sheet_batch_update shortcuts (B7)
Land 4 shortcuts that all funnel through the batch_update tool's atomic
operations array:

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

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

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

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

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

Reinstates validateDropdownRanges + splitSheetPrefixedRange that were
removed during B3 → B7 relocation.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
1cbc049700 feat(sheets): implement object CRUD shortcuts (B6)
Land 21 shortcuts — three (create / update / delete) per object skill —
backed by the manage_<obj>_object tools dispatched on the operation
enum. Five standard objects (chart / cond-format / sparkline /
float-image / filter-view) share an objectCRUDSpec factory; pivot and
filter are special-cased.

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

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

All delete shortcuts are high-risk-write and require --yes.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
ee4096f141 feat(sheets): implement object-list shortcuts (B5)
Land 7 read shortcuts, one per object skill — chart / pivot table /
conditional format / filter / filter view / sparkline / float image. All
share the same shape (public sheet selector + optional <obj>-id filter)
so they're declared via newObjectListShortcut + an objectListSpec.

Notes:
  - +cond-format-list exposes --rule-id, which is renamed to
    conditional_format_id on the wire (the tool's full field name).
  - +sparkline-list exposes --group-id (the higher-level handle); the
    tool also accepts sparkline_id, intentionally not surfaced.
  - +filter-list takes no id filter — at most one sheet-level filter
    per sheet, so the listing is already unique.
  - +filter-view-list is `cli_status: cli-only` but get_filter_view_objects
    is in mcp-tools.json and dispatches through the same One-OpenAPI
    endpoint; no special path required.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
b3e99de06c feat(sheets): implement lark_sheet_range_operations shortcuts (B4)
Land 8 shortcuts across four canonical tools:

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

Three CLI↔tool vocabulary bridges live in this file:

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

+cells-clear is the second high-risk-write shortcut in the package;
the framework enforces --yes with exit code 10 as usual.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
0d351179e4 refactor(sheets): move +dropdown-update / +dropdown-delete to lark_sheet_batch_update
Follow-up to B3 after the spec re-mapped these two shortcuts to the
batch_update tool (atomic multi-range CRUD) instead of fan-out via
set_cell_range. Drop their Go implementations + helper validateDropdownRanges
+ splitSheetPrefixedRange from lark_sheet_write_cells.go and remove the
registrations from Shortcuts(); the shortcuts will reappear under
lark_sheet_batch_update during B7.

Also pull in the re-rendered reference docs:
  - skills/lark-sheets/references/lark-sheets-write-cells.md
  - skills/lark-sheets/references/lark-sheets-batch-update.md
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
8494534c8f feat(sheets): implement read_data / search_replace / write_cells shortcuts (B3)
Land 11 shortcuts across three canonical skills:

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

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

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

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

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

+cells-set-image stays deferred to the cli-only batch (needs the
shared local-file upload helper alongside +workbook-create / +dim-move
/ +workbook-export).
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
ae728fe7ec feat(sheets): implement lark_sheet_sheet_structure shortcuts (B2)
Add 8 shortcuts under the lark_sheet_sheet_structure canonical skill:
+sheet-info (get_sheet_structure) plus +dim-insert / +dim-delete /
+dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup
(modify_sheet_structure, dispatched by operation enum).

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

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

+dim-delete is high-risk-write (irreversible row/column removal).
+dim-freeze --count 0 auto-dispatches to operation=unfreeze. +dim-group
accepts --depth for forward-compat with a future server-side nested
group endpoint but does not pass it through today.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
b33a06c1e4 feat(sheets): implement lark_sheet_workbook shortcuts (B1)
Land the 8 modify_workbook_structure shortcuts that round out the
lark_sheet_workbook canonical skill alongside the existing +workbook-info:
+sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy
/ +sheet-hide / +sheet-unhide / +sheet-set-tab-color. All eight call
modify_workbook_structure via the One-OpenAPI invoke_write endpoint,
dispatched by the `operation` enum.

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

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

+sheet-move's tool requires source_index in addition to target_index. The
CLI accepts an optional --source-index override and falls back to a
single get_workbook_structure read to derive it (and to resolve sheet_id
from --sheet-name). DryRun stays network-free by rendering <resolve>
placeholders for any field that would need that read.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
17a5f29306 refactor(sheets): rebuild lark-sheets on sheet-skill-spec canonical + One-OpenAPI
Restart lark-sheets as a spec-driven downstream. Skill content (SKILL.md
and 16 references covering 13 operations skills + 3 workflow skills,
including the standalone filter-view skill) is mirrored from the
sheet-skill-spec canonical-spec; do not hand-edit, change upstream and
rerun npm run sync:consumers.

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

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

The remaining 55 shortcuts will be designed and landed separately,
canonical skill by canonical skill.
2026-05-21 12:42:46 +08:00
878 changed files with 16078 additions and 113083 deletions

View File

@@ -57,14 +57,6 @@ linters:
- path: internal/vfs/
linters:
- forbidigo
# internal/gen build-time generators (standalone `package main` run via
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
# impossible here: a structured error return needs os.Exit (also banned),
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
- path: shortcuts/.*/internal/gen/
linters:
- forbidigo
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer.
- path-except: shortcuts/
@@ -73,23 +65,10 @@ 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/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/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go)
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/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/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
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/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/|cmd/event/|events/|shortcuts/event/)
text: errs-no-legacy-helper
linters:
- forbidigo
settings:
depguard:
@@ -115,21 +94,6 @@ linters:
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: >-
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
wrap a cause with .WithCause(err). Genuine intermediate wraps:
//nolint:forbidigo with a reason.
# ── http: shortcuts must not construct raw HTTP requests ──
# Bans request / client construction; constants (http.MethodPost,
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are

View File

@@ -75,31 +75,7 @@ The one rule to internalize: **every error message you write will be parsed by a
### Structured errors in commands
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
Picking a constructor:
| Failure | Constructor |
|---------|-------------|
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
Signatures that are easy to guess wrong:
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }``ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
### stdout is data, stderr is everything else

View File

@@ -2,148 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.51] - 2026-06-10
### Features
- **apps**: Support multi dev modes (#1175)
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
- **base**: Configure initial base table schema (#1377)
- **vc**: Add recording event support (#1369)
- **minutes**: Replace words for transcript (#1372)
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
- **slides**: Emit typed error envelopes across the slides domain (#1349)
### Documentation
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
## [v1.0.50] - 2026-06-09
### Features
- **doc**: Emit typed error envelopes across the doc domain (#1346)
- **event**: Emit typed error envelopes across the event domain (#1289)
- **contact**: Emit typed error envelopes across the contact domain (#1287)
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
- **cli**: Adjust agent timeout hint output conditions (#1328)
### Bug Fixes
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
- **slides**: Build create URL locally instead of drive metas call (#1329)
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
### Documentation
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
- **doc**: Document `<folder-manager>` resource block (#1168)
- **drive**: Add drive comment location guidance (#1258)
## [v1.0.49] - 2026-06-08
### Features
- **events**: Add whiteboard event domain with per-board subscription (#1265)
- **im**: Support feed group (#1102)
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
- **im**: Format feed group error handling (#1308)
- **im**: Return typed error envelopes across the im domain (#1230)
- **base**: Emit typed error envelopes across the base domain (#1248)
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
- **task**: Emit typed error envelopes across the task domain (#1231)
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
- **markdown**: Harden create upload failures (#1325)
- **drive**: Harden inspect shortcut failures (#1324)
- **slides**: Add IconPark lookup for Lark slides (#1123)
- **doc**: Remove docs v1 API (#1291)
- **cli**: Add `skills` command to read embedded skill content (#1318)
- **cli**: Fetch official skills index (#1301)
- **shared**: Document relative-path-only file arguments (#1319)
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
### Bug Fixes
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
- **drive**: Use docs secure label read scope (#1281)
### Documentation
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
- **skills**: Tighten drive and markdown guardrails (#1326)
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
- **markdown**: Add markdown domain template (#1293)
- **markdown**: Improve lark-markdown skill guidance (#1279)
- **doc**: Improve lark-doc skill guidance (#1283)
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
## [v1.0.48] - 2026-06-04
### Features
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
- **contact**: Add contact skill domain guidance (#1144)
### Bug Fixes
- **skills**: Use JSON skills list during update (#1251)
### Documentation
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
- **vc-agent**: Require explicit leave request (#1260)
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
## [v1.0.47] - 2026-06-03
### Features
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
- **base**: Add base block shortcuts (#1044)
- **im**: Complete card message format (#1198)
- **im**: Improve markdown guidance for messages (#1237)
- **vc**: Forward invite call-id on meeting join (#1243)
- **drive**: Emit typed error envelopes across the drive domain (#1205)
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
- **wiki**: Support `appid` member type (#1235)
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
- **config**: Validate credentials after `config init` (#1151)
### Bug Fixes
- **skills**: Recover empty fallback for skills to update (#1233)
## [v1.0.46] - 2026-06-02
### Features
- **im**: Add card message format support (#1218)
- **im**: Resolve markdown blank-line formatting inconsistency in post messages (#1216)
- **vc**: Inline transcript from artifacts API and add keywords (#1206)
- **transport**: Add proxy plugin mode for CLI HTTP transport (#1181)
- **agent**: Increase agent trace max length to 1024 (#1211)
- **shortcuts**: Unconditionally inject `--format` flag for all shortcuts (#1156)
### Bug Fixes
- **cli**: Remove FLAGS section from root `--help` (#1226)
- **cli**: Stop root `--help` listing per-command flags as global (#1223)
### Refactor
- **transport**: Own all HTTP transport in `internal/transport`, fix util layering inversion (#1213)
### Documentation
- **base**: Optimize base skill references (#1171)
- **drive**: Add Lark Drive knowledge organization workflow (#1028)
## [v1.0.45] - 2026-06-01
### Features
@@ -1106,12 +964,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43

View File

@@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
## Installation & Quick Start

View File

@@ -41,7 +41,7 @@
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
| 🔗 应用 | 创建妙搭Spark/Miaoda应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
## 安装与快速开始

View File

@@ -90,7 +90,6 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")

View File

@@ -718,23 +718,3 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
}
}
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("--json should be accepted without error, got: %v", err)
}
if gotOpts.Method != "GET" {
t.Errorf("expected method GET, got %s", gotOpts.Method)
}
}

View File

@@ -296,11 +296,10 @@ func authLoginRun(opts *LoginOptions) error {
}
// Step 2: Show user code and verification URL.
// JSON mode embeds AgentTimeoutHint as a structured field so agents that
// capture stdout into a JSON parser see it without stream-mixing surprises.
// Text mode prints the hint to stderr only when running under a non-TTY
// (i.e. piped / agent harness), since humans reading a terminal don't need
// the agent-oriented instructions.
// Both branches surface AgentTimeoutHint, but on different channels:
// JSON mode embeds it as a structured field (so an agent that captures
// stdout into a JSON parser sees it without stream-mixing surprises),
// text mode prints to stderr (alongside the URL prompt).
if opts.JSON {
data := map[string]interface{}{
"event": "device_authorization",
@@ -318,9 +317,7 @@ func authLoginRun(opts *LoginOptions) error {
} else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
// Step 3: Poll for token
@@ -407,11 +404,10 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
}
}
// Skip the stderr hint in JSON mode (the --no-wait call that issued
// the device_code already surfaced it as a JSON field), and also skip it
// when running on an interactive terminal — the agent-oriented
// instructions only matter for piped / harness environments.
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
// Skip the stderr hint in JSON mode the --no-wait call that issued the
// device_code already returned the hint as a JSON field, and writing
// text to stderr would pollute consumers that combine streams via 2>&1.
if !opts.JSON {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
log(msg.WaitingAuth)

View File

@@ -6,7 +6,6 @@ package cmd
import (
"context"
"io"
"io/fs"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
@@ -17,7 +16,6 @@ import (
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/build"
@@ -53,18 +51,6 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
}
}
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
// at build time. It is registered by the repo-root package main's init via
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
// breaking the single-file preview build (see skills_embed.go). nil in builds
// that embed no skills; the `skills` commands then return a typed internal error.
var embeddedSkillContent fs.FS
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
// repo-root package main's init; a wrapper main can call it before Execute to
// supply its own skill content.
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
// HideProfile sets the visibility policy for the root-level --profile flag.
// When hide is true the flag stays registered (so existing invocations still
// parse) but is omitted from help and shell completion. Typically called as
@@ -117,7 +103,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
f.SkillContent = embeddedSkillContent
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
@@ -155,7 +140,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f))
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)

View File

@@ -1,160 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"sort"
"strings"
)
// universalFlags are accepted by every command (cobra auto-injects help; the
// root injects version). They are never reported as unknown.
var universalFlags = map[string]bool{"--help": true, "-h": true, "--version": true}
// catalog is the source-of-truth command catalog: command path -> accepted flag
// tokens. A path is the command words WITHOUT the "lark-cli" root prefix, e.g.
// "contact +search-user". The root command is the empty path "".
type catalog struct {
flagsByPath map[string]map[string]bool
group map[string]bool // paths that are parent groups (have subcommands)
sorted []string // cached sorted paths for suggestCommand; invalidated on addCommand
}
func newCatalog() *catalog {
return &catalog{
flagsByPath: map[string]map[string]bool{},
group: map[string]bool{},
}
}
// setGroup records whether path is a parent group (has subcommands). Leftover
// words after a group node are unknown subcommands; after a leaf they are
// positionals (e.g. "api GET /path").
func (c *catalog) setGroup(path string, isGroup bool) {
if isGroup {
c.group[path] = true
}
}
func (c *catalog) isGroup(path string) bool { return c.group[path] }
// addCommand registers a command path and the flags it accepts. Repeated calls
// for the same path union the flag sets. flags are full tokens ("--query", "-q").
func (c *catalog) addCommand(path string, flags []string) {
set := c.flagsByPath[path]
if set == nil {
set = map[string]bool{}
c.flagsByPath[path] = set
}
for _, f := range flags {
set[f] = true
}
c.sorted = nil // invalidate cached suggestion list
}
func (c *catalog) hasCommand(path string) bool {
_, ok := c.flagsByPath[path]
return ok
}
// hasFlag reports whether flag is accepted by command path (universal flags
// always pass).
func (c *catalog) hasFlag(path, flag string) bool {
if universalFlags[flag] {
return true
}
set := c.flagsByPath[path]
return set[flag]
}
// longestPrefix returns the longest known command path that is a prefix of
// words, plus how many words it consumed. This separates real subcommands from
// trailing positionals (e.g. "api GET /path" resolves to "api"). When words is
// empty it falls back to the root command. ok=false means not even the first
// word names a command.
func (c *catalog) longestPrefix(words []string) (path string, n int, ok bool) {
if len(words) == 0 {
if c.hasCommand("") {
return "", 0, true
}
return "", 0, false
}
for i := len(words); i >= 1; i-- {
cand := strings.Join(words[:i], " ")
if c.hasCommand(cand) {
return cand, i, true
}
}
return "", 0, false
}
// paths returns all known command paths, sorted.
func (c *catalog) paths() []string {
out := make([]string, 0, len(c.flagsByPath))
for p := range c.flagsByPath {
out = append(out, p)
}
sort.Strings(out)
return out
}
// suggestCommand returns the known command path closest to want (small edit
// distance), for error hints. Returns "" when nothing is reasonably close.
func (c *catalog) suggestCommand(want string) string {
if c.sorted == nil {
c.sorted = c.paths() // built once after the catalog is fully populated
}
return closest(want, c.sorted)
}
// suggestFlag returns the flag of path closest to flag, for error hints.
func (c *catalog) suggestFlag(path, flag string) string {
set := c.flagsByPath[path]
cands := make([]string, 0, len(set))
for f := range set {
cands = append(cands, f)
}
sort.Strings(cands)
return closest(flag, cands)
}
// closest returns the candidate with the smallest Levenshtein distance to want,
// but only if that distance is within a tolerance scaled to want's length
// (avoids absurd suggestions).
func closest(want string, cands []string) string {
best := ""
bestD := 1 << 30
for _, cand := range cands {
d := levenshtein(want, cand)
if d < bestD {
bestD, best = d, cand
}
}
tol := len(want)/2 + 1
if bestD > tol {
return ""
}
return best
}
func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
prev := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
cur := make([]int, len(rb)+1)
cur[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
}
prev = cur
}
return prev[len(rb)]
}

View File

@@ -1,60 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import "strings"
// Finding kinds.
const (
unknownCommand = "unknown_command"
unknownFlag = "unknown_flag"
)
// finding is a single mismatch between an example command reference and the
// catalog.
type finding struct {
line int
raw string
kind string // unknownCommand | unknownFlag
path string // resolved command path (unknownFlag) or attempted path (unknownCommand)
flag string // offending flag (unknownFlag only)
suggest string // nearest known command/flag, "" if none close
}
// checkRefs validates refs against cat and returns all mismatches in order.
func checkRefs(cat *catalog, refs []ref) []finding {
var out []finding
for _, r := range refs {
path, n, ok := cat.longestPrefix(r.words)
if !ok {
attempted := strings.Join(r.words, " ")
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownCommand,
path: attempted, suggest: cat.suggestCommand(attempted),
})
continue
}
// Leftover words after a group node are an unknown subcommand (e.g. a
// mistyped method like "batch_modify_message"). After a leaf they are
// positionals (e.g. "api GET /path"), so only groups trigger this.
if n < len(r.words) && cat.isGroup(path) {
attempted := strings.Join(r.words, " ")
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownCommand,
path: attempted, suggest: cat.suggestCommand(attempted),
})
continue
}
for _, f := range r.flags {
if cat.hasFlag(path, f) {
continue
}
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownFlag,
path: path, flag: f, suggest: cat.suggestFlag(path, f),
})
}
}
return out
}

View File

@@ -1,222 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"regexp"
"strings"
)
// ref is one lark-cli command reference extracted from a shortcut example.
type ref struct {
line int // 1-based line number (the line where the command starts)
raw string // reconstructed command text, for error display
words []string // command words before the first flag (subcommand candidates)
flags []string // flag tokens used, e.g. "--query", "-q"
}
const cliToken = "lark-cli"
// subcommandStart guards against false positives from prose: a real command's
// first word is ASCII (a service name or a +shortcut). A token starting with
// CJK / punctuation is treated as narration, not a command.
var subcommandStart = regexp.MustCompile(`^[A-Za-z+]`)
// shellStops are standalone tokens that terminate a command (pipes, redirects,
// separators). Separators glued to a token (`get;`, `foo|`) are handled inline.
var shellStops = map[string]bool{
"|": true, "||": true, "&&": true, "&": true, ";": true,
">": true, ">>": true, "<": true, "2>": true, "2>&1": true,
}
// wordTrailPunct is sentence / CJK punctuation that can cling to a command word
// in prose ("auth login." / "auth login"); stripped so the word still resolves
// instead of being dropped as an unknown command or non-ASCII narration.
const wordTrailPunct = `.,;:!?"')]},。、;:!?)】」』`
// parseRefs extracts every lark-cli command reference from text (a shortcut's
// Tips line, which may embed an "Example: lark-cli ..." command). It is
// deliberately format-agnostic: it keys on the "lark-cli" token whether it sits
// in a ```bash fence, an inline `code` span, or bare prose. Backslash
// line-continuations are joined first so a multi-line invocation is parsed as
// one command; inline-code backticks and trailing # comments terminate it.
func parseRefs(content string) []ref {
var refs []ref
lines := strings.Split(content, "\n")
for i := 0; i < len(lines); i++ {
lineNo := i + 1
logical := lines[i]
// Shell line continuation: a trailing backslash joins the next physical
// line. Without this, flags on the continuation lines of a multi-line
// `lark-cli ... \` example are never seen by the checker.
for endsWithBackslash(logical) && i+1 < len(lines) {
logical = strings.TrimRight(logical, " \t")
logical = logical[:len(logical)-1] // drop the trailing backslash
i++
logical += " " + lines[i]
}
refs = append(refs, parseLine(logical, lineNo)...)
}
return refs
}
func endsWithBackslash(s string) bool {
return strings.HasSuffix(strings.TrimRight(s, " \t"), `\`)
}
func parseLine(line string, lineNo int) []ref {
var refs []ref
rest := line
for {
idx := strings.Index(rest, cliToken)
if idx < 0 {
break
}
after := rest[idx+len(cliToken):]
beforeOK := idx == 0 || isBoundary(rest[idx-1])
afterOK := after == "" || isBoundary(after[0])
if beforeOK && afterOK {
if words, flags, raw, ok := parseCmd(after); ok {
refs = append(refs, ref{line: lineNo, raw: cliToken + raw, words: words, flags: flags})
}
}
rest = after
}
return refs
}
// parseCmd tokenizes the text following "lark-cli" into leading command words
// (the subcommand path, up to the first flag) and flag tokens. It stops at a
// shell separator (standalone or glued), an inline-code backtick, a comment, or
// a placeholder/prose word. ok=false filters out non-commands.
func parseCmd(after string) (words, flags []string, raw string, ok bool) {
// An inline code span ends at the next backtick; a command never spans one.
if i := strings.IndexByte(after, '`'); i >= 0 {
after = after[:i]
}
// Drop $(...) command substitutions so flags belonging to the inner command
// (e.g. `--data "$(jq -n --arg x ...)"`) are not mistaken for lark-cli flags.
after = stripCmdSubst(after)
var kept []string
inFlags := false
for _, orig := range strings.Fields(after) {
tok := orig
if shellStops[tok] || strings.HasPrefix(tok, "#") {
break
}
// A shell separator glued to a token ends the command mid-token
// ("get;", "foo|next"): keep the part before it, handle it, then stop.
stop := false
if i := strings.IndexAny(tok, ";|"); i >= 0 {
tok, stop = tok[:i], true
}
switch {
case tok == "" || tok == "-":
// empty (after a glued separator) or a bare stdin marker — skip
case strings.HasPrefix(tok, "-"):
if f := normalizeFlag(tok); f != "" {
inFlags = true
flags = append(flags, f)
kept = append(kept, tok)
}
case inFlags:
// positional / flag value after the first flag — not a command word
kept = append(kept, tok)
default:
// Command-path word. ASCII placeholder markers (<x>, [x], {x|y},
// +<verb>, ...) end the command — checked on the RAW token so the
// trailing-punct stripping below cannot erase a "..." ellipsis
// ("base +..." must stay a placeholder, not become "+").
if strings.ContainsAny(tok, "<>[]{}|") || strings.Contains(tok, "...") {
stop = true
break
}
// Strip trailing sentence/CJK punctuation so "login." / "login"
// resolve to "login"; non-ASCII narration ends the command.
w := strings.TrimRight(tok, wordTrailPunct)
if w == "" || hasNonASCII(w) {
stop = true
break
}
words = append(words, w)
kept = append(kept, tok)
}
if stop {
break
}
}
if len(kept) > 0 {
raw = " " + strings.Join(kept, " ")
}
// Keep root-only refs ("lark-cli --help") and refs whose first word looks
// like a subcommand; drop prose ("lark-cli 就能搞定 ...").
if len(words) == 0 {
return words, flags, raw, len(flags) > 0
}
if !subcommandStart.MatchString(words[0]) {
return nil, nil, "", false
}
return words, flags, raw, true
}
// stripCmdSubst removes $(...) command substitutions (including nested ones)
// from s, leaving the surrounding text intact. Backtick substitutions are
// already handled upstream (a command never spans a backtick).
func stripCmdSubst(s string) string {
var b strings.Builder
depth := 0
for i := 0; i < len(s); i++ {
if depth == 0 && i+1 < len(s) && s[i] == '$' && s[i+1] == '(' {
depth = 1
i++ // skip '('
continue
}
if depth > 0 {
switch s[i] {
case '(':
depth++
case ')':
depth--
}
continue
}
b.WriteByte(s[i])
}
return b.String()
}
// isPlaceholderOrProse reports whether a command word is a doc placeholder
// (<resource>, [flags], {a|b}, +<verb>, ...) or narration (CJK / other
// non-ASCII), rather than a literal command token.
func isPlaceholderOrProse(w string) bool {
if hasNonASCII(w) {
return true
}
return strings.ContainsAny(w, "<>[]{}|") || strings.Contains(w, "...")
}
func hasNonASCII(s string) bool {
return strings.IndexFunc(s, func(r rune) bool { return r > 127 }) >= 0
}
// flagShape matches the leading flag token, stripping any trailing junk such as
// a "=value" suffix or punctuation that bled in from the surrounding markdown
// ("--help\"", "--help;", "--params={}"). The underscore is allowed because
// real flags use it ("--input_format", "--output_as"). Returns "" for non-flags.
var flagShape = regexp.MustCompile(`^--?[A-Za-z][A-Za-z0-9_-]*`)
// normalizeFlag extracts the canonical flag token from tok, or "" if tok is not
// a real flag (e.g. a shell-string fragment like "-草稿'").
func normalizeFlag(tok string) string {
return flagShape.FindString(tok)
}
func isBoundary(b byte) bool {
switch b {
case ' ', '\t', '`', '(', ')', '\'', '"', '*':
return true
}
return false
}

View File

@@ -1,113 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// This file and its cmdexample_*_test.go siblings implement a test-only check:
// the example commands embedded in shortcut definitions (the "Example: lark-cli
// ..." lines in each shortcut's Tips, shown in --help) must match the real
// command tree. It lives entirely in _test.go files (package cmd_test) so it
// ships in no binary and is not importable by product code; the truth source is
// cmd.Build, the same tree the binary uses, so the check cannot drift.
//
// It runs in the standard unit-test CI job (go test ./cmd/...). A mismatch — an
// example using a renamed command or an unaccepted flag — fails that job.
package cmd_test
import (
"context"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// TestShortcutExampleCommands checks the example commands embedded in every
// shortcut's Tips against the live command tree. A shortcut that defines no
// example is simply skipped.
//
// Because the examples and the command definitions live in the same Go code,
// this is a self-consistency check: any mismatch (an example using a renamed
// command or a flag the command doesn't accept) is a bug to fix at the source.
// It runs over all shortcuts — no baseline, no diff — since a wrong example is
// always a defect, never acceptable "pre-existing drift".
func TestShortcutExampleCommands(t *testing.T) {
// Reproducibility: use the embedded API metadata (not a developer's stale
// ~/.lark-cli remote cache, which can miss commands) and an empty config
// dir so local strict mode / plugins / policy cannot reshape the tree.
// t.Setenv auto-restores after the test, so other cmd tests are unaffected.
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cat := buildCmdExampleCatalog()
type located struct {
shortcut string
f finding
}
var findings []located
for _, sc := range shortcuts.AllShortcuts() {
var refs []ref
for _, tip := range sc.Tips {
refs = append(refs, parseRefs(tip)...)
}
label := strings.TrimSpace(sc.Service + " " + sc.Command)
for _, f := range checkRefs(cat, refs) {
findings = append(findings, located{shortcut: label, f: f})
}
}
if len(findings) == 0 {
return
}
sort.Slice(findings, func(i, j int) bool { return findings[i].shortcut < findings[j].shortcut })
for _, lf := range findings {
hint := ""
if lf.f.suggest != "" {
hint = " (did you mean " + lf.f.suggest + "?)"
}
if lf.f.kind == unknownFlag {
t.Errorf("shortcut %q example uses unknown flag %s on %q%s\n %s",
lf.shortcut, lf.f.flag, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
} else {
t.Errorf("shortcut %q example uses unknown command %q%s\n %s",
lf.shortcut, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
}
}
t.Fatalf("%d shortcut example command(s) don't match the real CLI — "+
"fix the Example in the shortcut definition.", len(findings))
}
// buildCmdExampleCatalog walks the live cobra command tree and records every
// command path (minus the "lark-cli" root prefix) with its accepted flags and
// whether it is a parent group. This is the same Build() the binary uses, so
// the catalog can never drift from the real commands.
func buildCmdExampleCatalog() *catalog {
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
cat := newCatalog()
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
var flags []string
add := func(fl *pflag.Flag) {
flags = append(flags, "--"+fl.Name)
if fl.Shorthand != "" {
flags = append(flags, "-"+fl.Shorthand)
}
}
c.Flags().VisitAll(add)
c.InheritedFlags().VisitAll(add)
c.PersistentFlags().VisitAll(add) // root's own persistent flags (e.g. --profile)
cat.addCommand(path, flags)
cat.setGroup(path, c.HasSubCommands())
for _, sub := range c.Commands() {
walk(sub)
}
}
walk(root)
return cat
}

View File

@@ -1,233 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"strings"
"testing"
)
func testCatalog() *catalog {
c := newCatalog()
c.addCommand("", []string{"--profile"}) // root
c.setGroup("", true)
c.addCommand("contact", []string{"--profile"})
c.setGroup("contact", true)
c.addCommand("contact +search-user", []string{"--query", "--as", "--format", "-q"})
c.addCommand("api", []string{"--params", "--data", "--as"}) // leaf (no subcommands)
c.addCommand("mail", nil)
c.setGroup("mail", true)
c.addCommand("mail user_mailbox.messages", []string{"--profile"})
c.setGroup("mail user_mailbox.messages", true)
c.addCommand("mail user_mailbox.messages batch_modify", []string{"--params", "--data"})
return c
}
func TestCmdExampleCatalogHasCommandAndFlag(t *testing.T) {
c := testCatalog()
if !c.hasCommand("contact +search-user") {
t.Fatal("expected contact +search-user to exist")
}
if c.hasCommand("contact +nope") {
t.Fatal("did not expect contact +nope")
}
if !c.hasFlag("contact +search-user", "--query") {
t.Fatal("--query should be valid")
}
if c.hasFlag("contact +search-user", "--nope") {
t.Fatal("--nope should be invalid")
}
// universal flags pass on any command
for _, f := range []string{"--help", "-h", "--version"} {
if !c.hasFlag("contact +search-user", f) {
t.Fatalf("universal flag %s should pass", f)
}
}
}
func TestCmdExampleLongestPrefix(t *testing.T) {
c := testCatalog()
tests := []struct {
words []string
want string
wantN int
wantOK bool
}{
{[]string{"contact", "+search-user"}, "contact +search-user", 2, true},
{[]string{"api", "GET", "/open-apis/x"}, "api", 1, true}, // trailing positionals
{[]string{"nope"}, "", 0, false},
{nil, "", 0, true}, // empty -> root
}
for _, tt := range tests {
got, n, ok := c.longestPrefix(tt.words)
if got != tt.want || n != tt.wantN || ok != tt.wantOK {
t.Errorf("longestPrefix(%v) = (%q,%d,%v), want (%q,%d,%v)",
tt.words, got, n, ok, tt.want, tt.wantN, tt.wantOK)
}
}
}
func refWordsOf(refs []ref) [][]string {
var out [][]string
for _, r := range refs {
out = append(out, r.words)
}
return out
}
func TestCmdExampleParseRefsExtractsCommands(t *testing.T) {
content := strings.Join([]string{
"运行 `lark-cli contact +search-user --query 张三` 搜索", // inline code
"```bash",
"lark-cli api GET /open-apis/x --params '{}'", // bash block
"```",
"用 lark-cli mail user_mailbox.messages batch_modify 即可", // bare prose command
"npx foo | lark-cli api GET /y", // after a pipe
}, "\n")
refs := parseRefs(content)
if len(refs) != 4 {
t.Fatalf("expected 4 refs, got %d: %v", len(refs), refWordsOf(refs))
}
if got := refs[0]; strings.Join(got.words, " ") != "contact +search-user" ||
len(got.flags) != 1 || got.flags[0] != "--query" {
t.Errorf("ref0 = %+v", got)
}
if got := refs[1]; strings.Join(got.words, " ") != "api GET /open-apis/x" {
t.Errorf("ref1 words = %v", got.words)
}
}
func TestCmdExampleParseRefsFiltersPlaceholdersAndProse(t *testing.T) {
// A line whose first word is prose yields no command at all.
if refs := parseRefs("lark-cli 就能搞定这件事"); len(refs) != 0 {
t.Errorf("prose-first line should yield 0 refs, got %v", refWordsOf(refs))
}
// Syntax templates / trailing prose may leave a real leading word ("mail"),
// but no placeholder or CJK token may leak into the command words — that is
// what prevents false positives like an "<resource>" unknown-command report.
for _, line := range []string{
"lark-cli mail <resource> <method> [flags]",
"lark-cli apps +<verb> [flags]",
"lark-cli base +...",
"lark-cli mail 写信场景下的格式说明",
} {
for _, r := range parseRefs(line) {
for _, w := range r.words {
if isPlaceholderOrProse(w) {
t.Errorf("%q: placeholder/prose token %q leaked into words %v", line, w, r.words)
}
}
}
}
}
func TestCmdExampleParseRefsStripsTrailingJunk(t *testing.T) {
// frontmatter-style quoted value: the trailing quote must not bleed into the flag
refs := parseRefs(`cliHelp: "lark-cli contact --help"`)
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
if len(refs[0].flags) != 1 || refs[0].flags[0] != "--help" {
t.Errorf("expected flag --help, got %v", refs[0].flags)
}
// bare "-" (stdin marker) and "=value" suffix
refs = parseRefs("lark-cli api GET /x --params={} --data -")
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
flags := strings.Join(refs[0].flags, " ")
if flags != "--params --data" {
t.Errorf("expected '--params --data', got %q", flags)
}
}
func TestCmdExampleCheck(t *testing.T) {
c := testCatalog()
tests := []struct {
name string
r ref
wantKind string // "" = no finding
wantPath string
}{
{"valid shortcut", ref{words: []string{"contact", "+search-user"}, flags: []string{"--query"}}, "", ""},
{"valid leaf positional", ref{words: []string{"api", "GET", "/x"}}, "", ""},
{"unknown top command", ref{words: []string{"nope"}}, unknownCommand, "nope"},
{"group leftover = unknown subcommand",
ref{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}},
unknownCommand, "mail user_mailbox.messages batch_modify_message"},
{"unknown flag", ref{words: []string{"contact", "+search-user"}, flags: []string{"--nope"}}, unknownFlag, "contact +search-user"},
{"universal flag ok", ref{words: []string{"contact", "+search-user"}, flags: []string{"--help"}}, "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := checkRefs(c, []ref{tt.r})
if tt.wantKind == "" {
if len(fs) != 0 {
t.Fatalf("expected no finding, got %+v", fs)
}
return
}
if len(fs) != 1 {
t.Fatalf("expected 1 finding, got %d: %+v", len(fs), fs)
}
if fs[0].kind != tt.wantKind || fs[0].path != tt.wantPath {
t.Errorf("got kind=%s path=%q, want kind=%s path=%q", fs[0].kind, fs[0].path, tt.wantKind, tt.wantPath)
}
})
}
}
func TestCmdExampleCheckSuggestsNearest(t *testing.T) {
c := testCatalog()
fs := checkRefs(c, []ref{{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}}})
if len(fs) != 1 || fs[0].suggest != "mail user_mailbox.messages batch_modify" {
t.Fatalf("expected suggestion 'mail user_mailbox.messages batch_modify', got %+v", fs)
}
}
// TestCmdExampleParseRefsRobustness covers the parser edge cases hardened after
// review: backslash continuation, underscore flags, $(...) substitution, glued
// separators, trailing punctuation, and the "..." placeholder.
func TestCmdExampleParseRefsRobustness(t *testing.T) {
cases := []struct {
name, content, wantWords, wantFlags string
wantRefs int
}{
{"backslash continuation joins flags",
"lark-cli contact +search-user \\\n --query foo \\\n --as user",
"contact +search-user", "--query --as", 1},
{"underscore flag not truncated",
"lark-cli whiteboard +update --input_format mermaid",
"whiteboard +update", "--input_format", 1},
{"command-substitution flags ignored",
`lark-cli slides x create --data "$(jq -n --arg c '{}')" --as user`,
"slides x create", "--data --as", 1},
{"glued separator truncates",
"lark-cli auth login; echo done",
"auth login", "", 1},
{"trailing CJK punctuation stripped",
"用 lark-cli auth login。",
"auth login", "", 1},
{"ellipsis placeholder stays placeholder",
"lark-cli base +...",
"base", "", 1},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
refs := parseRefs(tt.content)
if len(refs) != tt.wantRefs {
t.Fatalf("refs=%d want %d: %v", len(refs), tt.wantRefs, refWordsOf(refs))
}
if tt.wantRefs == 0 {
return
}
if got := strings.Join(refs[0].words, " "); got != tt.wantWords {
t.Errorf("words=%q want %q", got, tt.wantWords)
}
if got := strings.Join(refs[0].flags, " "); got != tt.wantFlags {
t.Errorf("flags=%q want %q", got, tt.wantFlags)
}
})
}
}

View File

@@ -341,9 +341,6 @@ func configInitRun(opts *ConfigInitOptions) error {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
return err
}
return nil
}
@@ -383,9 +380,6 @@ func configInitRun(opts *ConfigInitOptions) error {
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
return nil
}
@@ -425,11 +419,6 @@ func configInitRun(opts *ConfigInitOptions) error {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
}
printLangPreferenceConfirmation(opts)
if result.AppSecret != "" {
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
}
return nil
}
@@ -518,10 +507,5 @@ func configInitRun(opts *ConfigInitOptions) error {
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
if appSecretInput != "" {
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
return err
}
}
return nil
}

View File

@@ -16,7 +16,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/util"
)
// configInitResult holds the result of the interactive config init flow.
@@ -179,7 +179,7 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
// Step 1: Request app registration (begin)
// Use the shared proxy-plugin-aware transport so registration traffic is not
// a bypass of proxy plugin mode.
httpClient := transport.NewHTTPClient(0)
httpClient := util.NewHTTPClient(0)
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)

View File

@@ -1,91 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
// probeTimeout is the total wall-clock budget for the credential probe step
// (covering both TAT acquisition and the subsequent probe request).
const probeTimeout = 3 * time.Second
// runProbe runs a best-effort credential validation after config init has
// persisted the App ID and App Secret. It returns a non-nil error only for a
// deterministic credential-rejection signal; every other outcome returns nil
// so that valid configurations and transient/upstream noise never block the
// command.
//
// The function performs up to two HTTP calls in series, bounded by
// probeTimeout:
//
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
// returns a typed errs.* error (via the shared classifyTATResponseCode)
// only when the server deterministically rejected the credentials — a
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
// so the root dispatcher renders the canonical envelope and `config init`
// exits non-zero — identical to how every other token-resolving command
// reports the same bad credentials. Ambiguous failures (transport errors,
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
// errors and are swallowed (return nil), so valid configurations are never
// disturbed by upstream noise. errs.IsTyped is the discriminator.
//
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
// that call (success, server error, timeout, parse failure) is always
// ignored — return nil regardless.
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
if factory == nil {
return nil
}
httpClient, err := factory.HttpClient()
if err != nil {
return nil
}
ctx, cancel := context.WithTimeout(parent, probeTimeout)
defer cancel()
token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret)
if err != nil {
// A typed error from FetchTAT is a deterministic credential rejection
// (classifyTATResponseCode). Propagate it so config init exits with the
// same envelope the rest of the CLI uses for bad credentials. Untyped
// errors are ambiguous (transport / HTTP / parse / timeout) — stay
// silent and let the command succeed.
if errs.IsTyped(err) {
return err
}
return nil
}
// TAT succeeded — fire the probe call. Any outcome is ignored.
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}

View File

@@ -1,288 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// fakeRT routes requests to per-path handlers and records what it saw.
type fakeRT struct {
tatHandler func(req *http.Request) (*http.Response, error)
probeHandler func(req *http.Request) (*http.Response, error)
tatCalls int
probeCalls int
probeReq *http.Request
probeBody string
}
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
switch {
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
f.tatCalls++
if f.tatHandler == nil {
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
}
return f.tatHandler(req)
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
f.probeCalls++
f.probeReq = req
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
f.probeBody = string(b)
}
if f.probeHandler == nil {
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
}
return f.probeHandler(req)
}
return nil, errors.New("unexpected URL: " + req.URL.String())
}
func jsonResp(code int, body string) *http.Response {
return &http.Response{
StatusCode: code,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}
// fakeFactory builds a test Factory whose HttpClient is overridden to use
// the caller-supplied RoundTripper.
//
// Wired through cmdutil.TestFactory(t, nil) so the canonical IOStreams,
// Credential, Keychain and FileIO wiring is in place (per repo test-factory
// guidance). The HttpClient is then swapped to our stub so we can drive
// exact HTTP responses for the probe. Config-dir isolation is set up via
// t.Setenv(LARKSUITE_CLI_CONFIG_DIR, t.TempDir()) so any incidental config
// touch lands in a temp dir rather than the developer's real config.
//
// The returned buffer is the Factory's stderr. runProbe never writes to
// stderr (it propagates a typed error or stays silent), so every test asserts
// this buffer stays empty as an invariant.
func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.Buffer) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
f.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
}
return f, errBuf
}
// assertConfigRejection asserts runProbe propagated a deterministic credential
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
// the expected upstream code. This is the same typed error every other
// token-resolving command returns for the same bad credentials, and nothing is
// written to stderr (the root dispatcher renders the envelope).
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
t.Helper()
if err == nil {
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
}
if cfgErr.Category != errs.CategoryConfig {
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != wantCode {
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
}
if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
}
}
// assertSilent asserts runProbe stayed quiet: no propagated error and nothing
// written to stderr. Used for every ambiguous (non-credential) outcome.
func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
t.Helper()
if err != nil {
t.Errorf("expected nil (silent), got error: %v", err)
}
if errBuf.Len() != 0 {
t.Errorf("expected no stderr output, got: %q", errBuf.String())
}
}
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.probeCalls != 0 {
t.Error("probe endpoint must not be called when TAT fails")
}
assertConfigRejection(t, err, errBuf, 10003)
}
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
// the most common real-world rejection, propagated.
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
}
// Any non-zero body code is a deterministic rejection and propagates (typed).
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
// typed, so the probe still surfaces it rather than swallowing.
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if err == nil || !errs.IsTyped(err) {
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
}
if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
}
}
// Non-200 HTTP at the TAT endpoint is ambiguous (not a payload credential
// rejection) → silent, exit 0.
func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
for _, code := range []int{401, 403, 500} {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(code, `nope`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
}
func TestRunProbe_TATTransportError_Silent(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return nil, errors.New("network down")
},
}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
rt := &fakeRT{
probeHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(500, `server error`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.probeCalls != 1 {
t.Errorf("probe should be called once, got %d", rt.probeCalls)
}
assertSilent(t, err, errBuf)
}
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
rt := &fakeRT{}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.tatCalls != 1 || rt.probeCalls != 1 {
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
}
assertSilent(t, err, errBuf)
}
func TestRunProbe_ProbeRequestShape(t *testing.T) {
rt := &fakeRT{}
f, _ := fakeFactory(t, rt)
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rt.probeReq == nil {
t.Fatal("probe request not captured")
}
if rt.probeReq.Method != http.MethodPost {
t.Errorf("probe method = %s, want POST", rt.probeReq.Method)
}
if got := rt.probeReq.URL.String(); got != "https://open.feishu.cn/open-apis/application/v6/larksuite_cli_app/probe" {
t.Errorf("probe URL = %s", got)
}
if got := rt.probeReq.Header.Get("Authorization"); got != "Bearer t-ok" {
t.Errorf("Authorization = %q, want Bearer t-ok", got)
}
if !strings.Contains(rt.probeBody, `"from":"lark-cli/`+build.Version+`"`) {
t.Errorf("probe body missing from field: %s", rt.probeBody)
}
}
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
rt := &fakeRT{}
f, _ := fakeFactory(t, rt)
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rt.probeReq == nil {
t.Fatal("probe request not captured")
}
if !strings.Contains(rt.probeReq.URL.Host, "larksuite.com") {
t.Errorf("probe host = %s, want larksuite.com", rt.probeReq.URL.Host)
}
}
func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
f.HttpClient = func() (*http.Client, error) {
return nil, errors.New("client init failed")
}
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
func TestRunProbe_TimeoutHonored(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
<-req.Context().Done()
return nil, req.Context().Err()
},
}
f, errBuf := fakeFactory(t, rt)
start := time.Now()
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
elapsed := time.Since(start)
if elapsed > 4*time.Second {
t.Errorf("runProbe took %v, expected <= ~3s", elapsed)
}
// A timeout is an ambiguous failure (context deadline → untyped), so it
// must stay silent and not block.
assertSilent(t, err, errBuf)
}

View File

@@ -19,8 +19,8 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/update"
"github.com/larksuite/cli/internal/util"
)
// DoctorOptions holds inputs for the doctor command.
@@ -155,7 +155,7 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
// the real egress path (and are blocked when proxy plugin fails closed).
httpClient := transport.NewHTTPClient(0)
httpClient := util.NewHTTPClient(0)
mcpURL := ep.MCP + "/mcp"
type probeResult struct {

View File

@@ -12,7 +12,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event"
@@ -39,8 +38,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
logger, err := bus.SetupBusLogger(eventsDir)
if err != nil {
return errs.NewInternalError(errs.SubtypeFileIO,
"set up bus logger: %s", err).WithCause(err)
return err
}
tr := transport.New()
@@ -60,14 +58,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
}
}()
if err := b.Run(ctx); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"event bus daemon exited: %s", err).WithCause(err)
}
return nil
return b.Run(ctx)
},
}

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// The hidden `event _bus` daemon command must exit with a typed file_io error
// when its log directory cannot be created (the error is only visible in the
// forked process's captured stderr / bus.log).
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Block the events/ root with a regular file so MkdirAll fails.
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
cmd := NewCmdBus(f)
cmd.SetArgs([]string{})
err := cmd.Execute()
if err == nil {
t.Fatal("expected logger setup error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryInternal, errs.SubtypeFileIO)
}
}

View File

@@ -16,7 +16,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
@@ -65,8 +64,8 @@ Use 'event schema <EventKey>' for parameter details.`,
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
@@ -102,10 +101,11 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
if o.jqExpr != "" {
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
WithParam("--jq").
WithCause(err).
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey)
return output.ErrWithHint(
output.ExitValidation, "validation",
err.Error(),
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
)
}
}
@@ -184,9 +184,8 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
errOut = io.Discard
}
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers.
// Bounded runs already have --max-events/--timeout as their lifecycle control.
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
if !f.IOStreams.IsTerminal {
watchStdinEOF(os.Stdin, cancel, errOut)
}
@@ -261,12 +260,12 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
if len(missing) == 0 {
return nil
}
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scopes for EventKey %s (as %s): %s",
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
WithIdentity(string(pf.identity)).
WithMissingScopes(missing...).
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
return output.ErrWithHint(
output.ExitAuth, "auth",
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
)
}
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
@@ -301,27 +300,23 @@ func preflightEventTypes(pf *preflightCtx) error {
if len(missing) == 0 {
return nil
}
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))
return output.ErrWithHint(
output.ExitValidation, "validation",
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
pf.keyDef.Key, strings.Join(missing, ", ")),
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
consoleEventSubscriptionURL(pf.brand, pf.appID)),
)
}
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
func sanitizeOutputDir(dir string) (string, error) {
if strings.HasPrefix(dir, "~") {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s; use a relative path like ./output instead", errOutputDirTilde).
WithParam("--output-dir").
WithCause(errOutputDirTilde)
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
}
safe, err := validate.SafeOutputPath(dir)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q: %s", errOutputDirUnsafe, dir, err).
WithParam("--output-dir").
WithCause(errOutputDirUnsafe)
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
}
return safe, nil
}
@@ -333,21 +328,18 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
}
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return "", err
}
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"resolve tenant access token: %s", err).WithCause(err)
return "", output.ErrAuth("resolve tenant access token: %s", err)
}
if result == nil || result.Token == "" {
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"no tenant access token available for app %s", appID).
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
return "", output.ErrWithHint(
output.ExitAuth, "auth",
fmt.Sprintf("no tenant access token available for app %s", appID),
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
)
}
return result.Token, nil
}
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
var (
errInvalidParamFormat = errors.New("invalid --param format")
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
@@ -359,10 +351,7 @@ func parseParams(raw []string) (map[string]string, error) {
for _, kv := range raw {
k, v, ok := strings.Cut(kv, "=")
if !ok || k == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q: expected key=value", errInvalidParamFormat, kv).
WithParam("--param").
WithCause(errInvalidParamFormat)
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
}
m[k] = v
}
@@ -381,8 +370,3 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
cancel()
}()
}
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
return !isTerminal && maxEvents <= 0 && timeout <= 0
}

View File

@@ -61,70 +61,3 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
}
}
func TestShouldWatchStdinEOF(t *testing.T) {
tests := []struct {
name string
isTerminal bool
maxEvents int
timeout time.Duration
want bool
}{
{
name: "terminal",
isTerminal: true,
want: false,
},
{
name: "non terminal unbounded",
want: true,
},
{
name: "non terminal negative max events is unbounded",
maxEvents: -1,
want: true,
},
{
name: "non terminal negative timeout is unbounded",
timeout: -1 * time.Second,
want: true,
},
{
name: "non terminal max events bounded",
maxEvents: 1,
want: false,
},
{
name: "non terminal timeout bounded",
timeout: 10 * time.Minute,
want: false,
},
{
name: "non terminal both bounds positive",
maxEvents: 1,
timeout: 10 * time.Minute,
want: false,
},
{
name: "non terminal bounded max events with negative timeout",
maxEvents: 1,
timeout: -1 * time.Second,
want: false,
},
{
name: "non terminal bounded timeout with negative max events",
maxEvents: -1,
timeout: 10 * time.Minute,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
if got != tt.want {
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -4,14 +4,9 @@
package event
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
)
func TestParseParams(t *testing.T) {
@@ -78,7 +73,6 @@ func TestParseParams(t *testing.T) {
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
}
assertInvalidArgumentParam(t, err, "--param")
return
}
if err != nil {
@@ -96,77 +90,6 @@ func TestParseParams(t *testing.T) {
}
}
// emptyTokenResolver resolves to a result that carries no token.
type emptyTokenResolver struct{}
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{}, nil
}
// failingTokenResolver fails outright with an untyped error.
type failingTokenResolver struct{}
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return nil, errors.New("backend unavailable")
}
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
}
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
}
var malformed *credential.MalformedTokenResultError
if !errors.As(err, &malformed) {
t.Error("empty-token failure should preserve the credential-layer cause")
}
}
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
}
if errors.Unwrap(err) == nil {
t.Error("resolver failure should preserve its cause")
}
}
// assertInvalidArgumentParam verifies err is a typed validation error with
// subtype invalid_argument naming the given flag in its param field.
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != param {
t.Errorf("param = %q, want %q", ve.Param, param)
}
}
func TestSanitizeOutputDir(t *testing.T) {
cases := []struct {
name string
@@ -207,7 +130,6 @@ func TestSanitizeOutputDir(t *testing.T) {
if !errors.Is(err, tc.wantSentry) {
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
}
assertInvalidArgumentParam(t, err, "--output-dir")
return
}
if err != nil {

View File

@@ -8,10 +8,10 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
)
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
@@ -89,17 +89,19 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
t.Errorf("error should name the missing event type, got: %v", err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
var exit *output.ExitError
if !errors.As(err, &exit) {
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
if exit.Code != output.ExitValidation {
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
}
if exit.Detail == nil {
t.Fatal("expected Detail with hint")
}
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
if !strings.Contains(p.Hint, wantURL) {
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
if !strings.Contains(exit.Detail.Hint, wantURL) {
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
}
}
@@ -143,19 +145,17 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
t.Errorf("error should name missing scope, got: %v", err)
}
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
var exit *output.ExitError
if !errors.As(err, &exit) {
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
}
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope {
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype,
errs.CategoryAuthorization, errs.SubtypeMissingScope)
if exit.Code != output.ExitAuth {
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
}
wantMissing := []string{"im:message.group_at_msg"}
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] {
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
if exit.Detail == nil {
t.Fatal("expected Detail with hint, got nil Detail")
}
hint := permErr.Hint
hint := exit.Detail.Hint
wantSubstrings := []string{
"https://open.feishu.cn/app/cli_x/auth?q=",
"im:message.group_at_msg",

View File

@@ -6,8 +6,8 @@ package event
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)
@@ -26,11 +26,7 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
As: r.accessIdentity,
})
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
"api %s %s: %s", method, path, err).WithCause(err)
return nil, err
}
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
ct := resp.Header.Get("Content-Type")
@@ -40,20 +36,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
if len(body) > maxBodyEcho {
body = body[:maxBodyEcho] + "…(truncated)"
}
if resp.StatusCode >= 500 {
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
}
result, err := client.ParseJSONResponse(resp)
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"api %s %s: %s", method, path, err).WithCause(err)
return nil, err
}
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
return json.RawMessage(resp.RawBody), apiErr

View File

@@ -1,147 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
// staticTokenResolver always returns a fixed token without any HTTP calls.
type staticTokenResolver struct{}
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: "test-token"}, nil
}
// stubRoundTripper intercepts every outgoing request with a canned response.
type stubRoundTripper struct {
respond func(*http.Request) (*http.Response, error)
}
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
sdk := lark.NewClient("test-app", "test-secret",
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(&http.Client{Transport: rt}),
)
return &consumeRuntime{
client: &client.APIClient{
SDK: sdk,
ErrOut: io.Discard,
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
},
accessIdentity: core.AsBot,
}
}
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
return func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": []string{contentType}},
Body: io.NopCloser(strings.NewReader(body)),
Request: r,
}, nil
}
}
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype {
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
}
}
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
if !strings.Contains(err.Error(), "returned 404") {
t.Errorf("error should echo the HTTP status, got: %v", err)
}
}
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
long := strings.Repeat("x", 300)
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
p, _ := errs.ProblemOf(err)
if !p.Retryable {
t.Fatal("5xx non-JSON response should be marked retryable")
}
if !strings.Contains(err.Error(), "…(truncated)") {
t.Errorf("long body should be truncated in the message, got: %v", err)
}
}
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
}
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
return nil, errors.New("connection refused")
}})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork {
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
}
}
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
`{"code":99991663,"msg":"app not found"}`)})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
}
}
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
`{"code":0,"data":{"ok":true}}`)})
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(string(raw), `"code":0`) {
t.Errorf("raw body should pass through, got: %s", raw)
}
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
@@ -40,14 +39,12 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
if len(def.Schema.FieldOverrides) > 0 {
var parsed map[string]interface{}
if err := json.Unmarshal(base, &parsed); err != nil {
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
"parse base schema for field overrides: %s", err).WithCause(err)
return nil, nil, err
}
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
out, err := json.Marshal(parsed)
if err != nil {
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
"serialize schema with field overrides: %s", err).WithCause(err)
return nil, nil, err
}
return out, orphans, nil
}
@@ -76,7 +73,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
copy(buf, s.Raw)
return buf, nil
}
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
}
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
@@ -168,7 +165,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return err
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
}
if resolved != nil {
fmt.Fprintf(out, "\nOutput Schema:\n")

View File

@@ -10,7 +10,6 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
@@ -130,38 +129,3 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
t.Errorf("overlay format = %v, want open_id", got)
}
}
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
_, err := renderSpec(&eventlib.SchemaSpec{})
if err == nil {
t.Fatal("expected error for spec with neither Type nor Raw")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
}
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "synthetic.invalid.base",
Schema: eventlib.SchemaDef{
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
},
}
_, _, err := resolveSchemaJSON(def)
if err == nil {
t.Fatal("expected error for unparsable base schema")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
}

View File

@@ -8,8 +8,8 @@ import (
"sort"
"strings"
"github.com/larksuite/cli/errs"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/suggest"
)
@@ -64,6 +64,9 @@ func unknownEventKeyErr(key string) error {
if guesses := suggestEventKeys(key); len(guesses) > 0 {
msg += " — did you mean " + formatSuggestions(guesses) + "?"
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
WithHint("Run 'lark-cli event list' to see available keys.")
return output.ErrWithHint(
output.ExitValidation, "validation",
msg,
"Run 'lark-cli event list' to see available keys.",
)
}

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/deprecation"
)
// composePendingNotice must surface a deprecated-command alias under the
// "deprecated_command" key, with the migration target and a skill-update hint,
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
// without ever reading --help.
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(&deprecation.Notice{
Command: "+read",
Replacement: "+cells-get",
Skill: "lark-sheets",
})
got := composePendingNotice()
if got == nil {
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
}
entry, ok := got["deprecated_command"].(map[string]interface{})
if !ok {
t.Fatalf("missing deprecated_command key: %#v", got)
}
if entry["command"] != "+read" {
t.Errorf("command = %v, want +read", entry["command"])
}
if entry["replacement"] != "+cells-get" {
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
}
if entry["skill"] != "lark-sheets" {
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
}
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
t.Errorf("message missing skill-update hint: %q", msg)
}
}
// With nothing pending, the provider returns nil so no "_notice" field is
// emitted on a clean run.
func TestComposePendingNoticeEmpty(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
if got := composePendingNotice(); got != nil {
// update/skills pending are process-global; only assert the absence of
// our own key to stay robust against unrelated pending state.
if _, ok := got["deprecated_command"]; ok {
t.Fatalf("deprecated_command present after clear: %#v", got)
}
}
}

View File

@@ -18,7 +18,6 @@ import (
"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"
@@ -51,6 +50,20 @@ EXAMPLES:
# Generic API call
lark-cli api GET /open-apis/calendar/v4/calendars
FLAGS:
--params <json> URL/query parameters JSON
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
--as <type> identity type: user | bot
--format <fmt> output format: json (default) | ndjson | table | csv | pretty
--page-all automatically paginate through all pages
--page-size <N> page size (0 = use API default)
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
-o, --output <path> output file path for binary responses
--jq <expr> jq expression to filter JSON output
-q <expr> shorthand for --jq
--dry-run print request without executing
AI AGENT SKILLS:
lark-cli pairs with AI agent skills (Claude Code, etc.) that
teach the agent Lark API patterns, best practices, and workflows.
@@ -72,15 +85,7 @@ COMMUNITY:
More help: lark-cli <command> --help`
// Execute runs the root command and returns the process exit code.
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
// before they reach a group's RunE, so unknownSubcommandRunE re-derives them
// from here. It stays nil in unit tests that invoke a RunE directly with
// explicit args — correct, since those don't exercise the whitelist path.
var rawInvocationArgs []string
func Execute() int {
rawInvocationArgs = os.Args[1:]
inv, err := BootstrapInvocationContext(os.Args[1:])
if err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
@@ -144,49 +149,29 @@ func setupNotices() {
skillscheck.Init(build.Version)
// Composed notice provider — emits keys only when each pending is set.
output.PendingNotice = composePendingNotice
}
// composePendingNotice merges all process-level pending notices (available
// update, skills/binary drift, deprecated-command alias) into the map surfaced
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
// Extracted from Execute so the composition is unit-testable.
func composePendingNotice() map[string]interface{} {
notice := map[string]interface{}{}
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
output.PendingNotice = func() map[string]interface{} {
notice := map[string]interface{}{}
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
}
}
if stale := skillscheck.GetPending(); stale != nil {
notice["skills"] = map[string]interface{}{
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if len(notice) == 0 {
return nil
}
return notice
}
if stale := skillscheck.GetPending(); stale != nil {
notice["skills"] = map[string]interface{}{
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if dep := deprecation.GetPending(); dep != nil {
entry := map[string]interface{}{
"command": dep.Command,
"message": dep.Message(),
"action": "lark-cli update",
}
if dep.Replacement != "" {
entry["replacement"] = dep.Replacement
}
if dep.Skill != "" {
entry["skill"] = dep.Skill
}
notice["deprecated_command"] = entry
}
if len(notice) == 0 {
return nil
}
return notice
}
// isCompletionCommand returns true if args indicate a shell completion request.
@@ -272,13 +257,6 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return typedExit
}
// Partial-failure (batch / multi-status): the ok:false result envelope is
// already on stdout; set the exit code and write nothing to stderr.
var pfErr *output.PartialFailureError
if errors.As(err, &pfErr) {
return pfErr.Code
}
if exitErr := asExitError(err); exitErr != nil {
if !exitErr.Raw {
// Raw errors (e.g. from `api` command via output.MarkRaw)
@@ -291,19 +269,6 @@ func handleRootError(f *cmdutil.Factory, err error) int {
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
}
@@ -370,204 +335,35 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
// they have moved to the typed surface.
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
// like the global --profile, legitimately prints help. But a flag that
// belongs to a (missing) subcommand is a user error: the guard's
// FParseErrWhitelist swallows such flags and leaves args empty, so without
// the checks below they would silently fall through to help + exit 0 —
// letting an agent mistake a malformed call (`im --format json`,
// `sheets --badflag`) for success. Recover the swallowed tokens from the
// raw invocation and fail structured instead.
flags := flagTokensInArgs(rawInvocationArgs)
if len(flags) == 0 {
return cmd.Help()
}
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
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{},
},
},
}
}
// The remaining flags are all defined somewhere in the tree. Those valid
// on the group itself or inherited (e.g. the global --profile) do not
// require a subcommand, so a bare group carrying only those still prints
// help. Anything left belongs to a subcommand that was omitted
// (e.g. `im --format json`): distinct from unknown_flag — the flags are
// real, the subcommand is what's missing.
misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs)
if len(misplaced) == 0 {
return cmd.Help()
}
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{},
},
},
}
return cmd.Help()
}
unknown := args[0]
available, deprecated := availableSubcommandNames(cmd)
// Rank suggestions across both current and deprecated names so a mistyped
// legacy command (e.g. +raed → +read) still resolves; the alias stays
// runnable and self-flags via the _notice on execution.
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
available := availableSubcommandNames(cmd)
suggestions := suggest.Closest(unknown, available, 6)
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
if len(suggestions) > 0 {
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,
Detail: map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available,
},
},
}
}
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
// rawArgs, stopping at the "--" positional terminator. Whether a flag is
// defined is not considered (see unknownFlagTokens for that). A pure group
// with any flag token but no subcommand is a user error — a pure group
// consumes no flags of its own, so the flag must belong to a subcommand — so
// the caller fails structured instead of falling through to help.
func flagTokensInArgs(rawArgs []string) []string {
var toks []string
for _, a := range rawArgs {
if a == "--" {
break // everything after -- is positional
}
if len(a) < 2 || a[0] != '-' {
continue
}
toks = append(toks, a)
}
return toks
}
// unknownFlagTokens returns the flag tokens in rawArgs that cmd does not define
// (on itself, inherited, or any direct subcommand). installUnknownSubcommandGuard
// whitelists unknown flags on pure groups so a mistyped subcommand still reaches
// the suggestion path; the side effect is that flags before a subcommand are
// swallowed. This recovers the genuinely-unknown ones so the caller can name
// them in a "did you mean" envelope.
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
var unknown []string
for _, a := range flagTokensInArgs(rawArgs) {
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
if name != "" && !flagDefinedInTree(cmd, name) {
unknown = append(unknown, a)
}
}
return unknown
}
// flagKnownOnGroup reports whether name is a flag defined on cmd itself or
// inherited (a global persistent flag like --profile) — i.e. valid on the bare
// group and therefore not requiring a subcommand.
func flagKnownOnGroup(cmd *cobra.Command, name string) bool {
short := len(name) == 1
lookup := func(fs *pflag.FlagSet) bool {
if short {
return fs.ShorthandLookup(name) != nil
}
return fs.Lookup(name) != nil
}
return lookup(cmd.Flags()) || lookup(cmd.InheritedFlags())
}
// subcommandOnlyFlagTokens returns the flag tokens in rawArgs that are valid on
// a subcommand of cmd but not on cmd itself/inherited — flags supplied while
// omitting the subcommand they belong to (`im --format json`). Global flags
// valid on the bare group (e.g. --profile) are excluded so
// `lark-cli --profile p im` still prints help rather than erroring.
func subcommandOnlyFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
var misplaced []string
for _, a := range flagTokensInArgs(rawArgs) {
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
if name == "" || flagKnownOnGroup(cmd, name) {
continue
}
if flagDefinedInTree(cmd, name) {
misplaced = append(misplaced, a)
}
}
return misplaced
}
// flagDefinedInTree reports whether name is defined on cmd, its inherited
// (persistent) flags, or any direct subcommand. The subcommand case covers a
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
// --format is injected on every leaf shortcut, not on the group — so only a
// genuinely unknown flag like `sheets --badflag` is reported.
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
short := len(name) == 1
known := func(c *cobra.Command, inherited bool) bool {
fs := c.Flags()
if inherited {
fs = c.InheritedFlags()
}
if short {
return fs.ShorthandLookup(name) != nil
}
return fs.Lookup(name) != nil
}
if known(cmd, false) || known(cmd, true) {
return true
}
for _, c := range cmd.Commands() {
if known(c, false) {
return true
}
}
return false
}
// availableSubcommandNames returns the invokable subcommand names of cmd, split
// into current commands and backward-compatibility aliases (those tagged into
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
// sorted; hidden commands plus help/completion are omitted.
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
func availableSubcommandNames(cmd *cobra.Command) []string {
subs := make([]string, 0, len(cmd.Commands()))
for _, c := range cmd.Commands() {
if c.Hidden || !c.IsAvailableCommand() {
continue
@@ -576,15 +372,10 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
if name == "help" || name == "completion" {
continue
}
if cmdutil.IsDeprecatedCommand(c) {
deprecated = append(deprecated, name)
} else {
available = append(available, name)
}
subs = append(subs, name)
}
sort.Strings(available)
sort.Strings(deprecated)
return available, deprecated
sort.Strings(subs)
return subs
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
@@ -635,11 +426,6 @@ func flagDidYouMean(c *cobra.Command, ferr error) error {
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
// those structured but generic — hallucinated flags are essentially always long.
//
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
// silently fails and unknown flags degrade to a generic flag_error — re-verify
// this prefix when bumping cobra.
func unknownFlagName(err error) (string, bool) {
const p = "unknown flag: --"
msg := err.Error()

View File

@@ -377,9 +377,9 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api",
Type: "api_error",
Code: 230002,
Message: "Bot/User can NOT be out of the chat.",
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
},
})
}

View File

@@ -21,7 +21,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/deprecation"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
)
@@ -269,54 +268,6 @@ 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.
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
deprecation.SetPending(&deprecation.Notice{
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
})
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
// nor an *output.ExitError, so it reaches the legacy fallback.
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out)
}
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
}
}
// 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) {
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
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())
}
}
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
// stderr write fails mid-envelope, handleRootError still returns the typed
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the

View File

@@ -180,7 +180,6 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
if risk == "high-risk-write" {

View File

@@ -765,22 +765,3 @@ func TestDetectFileFields(t *testing.T) {
})
}
}
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
})
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--json should be accepted without error, got: %v", err)
}
if captured == nil {
t.Fatal("expected runF to be called")
}
}

View File

@@ -1,183 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package skill implements the `lark-cli skills` command group, which serves
// binary-embedded skill content to AI agents. The package is "skill"; the
// user-facing verb is "skills".
package skill
import (
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillcontent"
"github.com/spf13/cobra"
)
func newReader(f *cmdutil.Factory) (*skillcontent.Reader, error) {
if f.SkillContent == nil {
return nil, errs.NewInternalError(errs.SubtypeFileIO,
"skill content not embedded in this build")
}
return skillcontent.New(f.SkillContent), nil
}
type readEnvelope struct {
Skill string `json:"skill"`
Path string `json:"path"`
Content string `json:"content"`
Guidance string `json:"guidance,omitempty"`
}
type listEnvelope struct {
OK bool `json:"ok"`
Skills []skillcontent.SkillInfo `json:"skills"`
Count int `json:"count"`
}
type listPathEnvelope struct {
OK bool `json:"ok"`
Path string `json:"path"`
Entries []skillcontent.DirEntry `json:"entries"`
Count int `json:"count"`
}
func NewCmdSkill(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "skills",
Short: "Read embedded skill content (list / read)",
Long: "Read agent-readable skill content (SKILL.md and reference files) embedded in " +
"the CLI binary at build time, so it stays in sync with the CLI version. " +
"Machine resources such as assets/ and scripts/ are not embedded.",
}
// Risk is set on each leaf (GetRisk does not walk parents); the group has none.
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(newListCmd(f), newReadCmd(f))
return cmd
}
func newListCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "list [name[/path]]",
Short: "List skills, or list one layer under a skill path (like ls)",
Example: ` lark-cli skills list # all skills: name, description, version
lark-cli skills list lark-doc # one layer under a skill (like ls)
lark-cli skills list lark-doc/references # one layer under a subdirectory`,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 1 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"list takes at most 1 argument: [name[/path]]").
WithHint("run 'lark-cli skills list --help'")
}
r, err := newReader(f)
if err != nil {
return err
}
if len(args) == 0 {
skills, err := r.List()
if err != nil {
return err
}
output.PrintJson(f.IOStreams.Out, listEnvelope{OK: true, Skills: skills, Count: len(skills)})
return nil
}
entries, listed, err := r.ListPath(args[0])
if err != nil {
return err
}
output.PrintJson(f.IOStreams.Out, listPathEnvelope{OK: true, Path: listed, Entries: entries, Count: len(entries)})
return nil
},
}
// --json is a no-op (list is always JSON), accepted only to stay symmetric with read.
cmd.Flags().Bool("json", false, "no-op (list output is always JSON)")
cmdutil.SetRisk(cmd, "read")
cmdutil.DisableAuthCheck(cmd)
return cmd
}
func newReadCmd(f *cmdutil.Factory) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "read <name>[/<path>] [path]",
Short: "Print a skill's SKILL.md, or a file under the skill (raw markdown by default)",
Example: ` lark-cli skills read lark-doc # the skill's SKILL.md
lark-cli skills read lark-doc references/lark-doc-fetch.md # a file under the skill
lark-cli skills read lark-doc/references/lark-doc-fetch.md # same, slash form
lark-cli skills read lark-doc --json # JSON envelope`,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
name, relpath, err := parseReadTarget(args)
if err != nil {
return err
}
r, err := newReader(f)
if err != nil {
return err
}
var content []byte
var pathOut string
if relpath == "" {
content, err = r.ReadSkill(name)
pathOut = "SKILL.md"
} else {
content, pathOut, err = r.ReadReference(name, relpath)
}
if err != nil {
return err
}
isMain := pathOut == "SKILL.md"
if asJSON {
env := readEnvelope{Skill: name, Path: pathOut, Content: string(content)}
if isMain {
env.Guidance = readGuidance(name)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// Raw stdout stays byte-identical to the file; guidance goes to stderr.
if _, err := f.IOStreams.Out.Write(content); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "failed to write output: %v", err)
}
if isMain {
fmt.Fprintln(f.IOStreams.ErrOut, readGuidance(name))
}
return nil
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "output as a JSON envelope instead of raw markdown")
cmdutil.SetRisk(cmd, "read")
cmdutil.DisableAuthCheck(cmd)
return cmd
}
// parseReadTarget maps 1-or-2 positional args to (name, relpath); a lone
// "<a>/<b>" splits on the first '/', and relpath "" reads the main SKILL.md.
func parseReadTarget(args []string) (name, relpath string, err error) {
switch len(args) {
case 1:
name, relpath = skillcontent.SplitArg(args[0])
return name, relpath, nil
case 2:
return args[0], args[1], nil
default:
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"read requires 1 or 2 arguments: <name>[/<path>] [path]").
WithHint("run 'lark-cli skills read --help'")
}
}
// readGuidance routes cross-skill "../lark-foo/..." references back through
// `skills read lark-foo/...`: the path guard rejects a literal "../", so the
// relative form must be rewritten.
func readGuidance(name string) string {
return fmt.Sprintf("> Tip: read this skill's own files (e.g. `references/...`) with "+
"`lark-cli skills read %s <relative-path>` to keep them in sync with this CLI version. "+
"A reference to another skill (`../lark-foo/...`) uses the same command with the "+
"leading `../` removed: `lark-cli skills read lark-foo/...`.", name)
}

View File

@@ -1,306 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skill
import (
"encoding/json"
"io"
"io/fs"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/cmdutil"
)
// calFS is the default single-skill content tree for these tests. The embedded
// FS is now injected through the Factory (no package global), so tests pass it
// explicitly to run() — nothing is shared, so they are safe under -parallel.
func calFS() fstest.MapFS {
return fstest.MapFS{
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Cal\"\nmetadata:\n cliHelp: \"lark-cli calendar --help\"\n---\nbody")},
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
}
}
// run executes the skills command tree against the given content FS (may be nil
// to exercise the not-embedded path) and returns stdout/stderr/err.
func run(t *testing.T, fsys fs.FS, args ...string) (stdout, stderr string, err error) {
t.Helper()
// Isolate CLI config state so tests never read/write the real config dir
// (repo convention).
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, out, errOut, _ := cmdutil.TestFactory(t, nil)
f.SkillContent = fsys
cmd := NewCmdSkill(f)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
err = cmd.Execute()
return out.String(), errOut.String(), err
}
func TestSkillList(t *testing.T) {
stdout, _, err := run(t, calFS(), "list")
if err != nil {
t.Fatalf("list error: %v", err)
}
var got struct {
OK bool `json:"ok"`
Skills []map[string]any `json:"skills"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
// "ok" is an explicit success marker (the list envelope is a typed struct;
// no automatic _notice attaches).
if !got.OK {
t.Error("expected ok=true in list envelope")
}
if got.Count != 1 || len(got.Skills) != 1 {
t.Fatalf("count: got %d", got.Count)
}
if got.Skills[0]["name"] != "lark-calendar" {
t.Errorf("name: got %v", got.Skills[0]["name"])
}
// Top-level list carries version + metadata, not a references list.
if _, ok := got.Skills[0]["references"]; ok {
t.Error("top-level list must not include references")
}
if got.Skills[0]["version"] != "1.0.0" {
t.Errorf("version: got %v, want 1.0.0", got.Skills[0]["version"])
}
if _, ok := got.Skills[0]["metadata"]; !ok {
t.Error("expected metadata in list entry")
}
}
func TestSkillListJSONFlagAccepted(t *testing.T) {
// `list --json` must be accepted (no-op), not rejected as an unknown flag,
// so it stays symmetric with read --json.
stdout, _, err := run(t, calFS(), "list", "--json")
if err != nil {
t.Fatalf("list --json error: %v", err)
}
var got struct {
OK bool `json:"ok"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if !got.OK || got.Count != 1 {
t.Errorf("envelope: %+v", got)
}
}
func TestSkillListPath(t *testing.T) {
stdout, _, err := run(t, calFS(), "list", "lark-calendar")
if err != nil {
t.Fatalf("list <name> error: %v", err)
}
var got struct {
OK bool `json:"ok"`
Path string `json:"path"`
Entries []struct {
Path string `json:"path"`
IsDir bool `json:"is_dir"`
} `json:"entries"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if !got.OK || got.Path != "lark-calendar" {
t.Errorf("envelope: %+v", got)
}
// One layer under the skill root: SKILL.md (file) + references (dir).
if got.Count != 2 || len(got.Entries) != 2 {
t.Fatalf("entries: got %+v", got.Entries)
}
if got.Entries[0].Path != "lark-calendar/SKILL.md" || got.Entries[0].IsDir {
t.Errorf("entry[0]: got %+v", got.Entries[0])
}
if got.Entries[1].Path != "lark-calendar/references" || !got.Entries[1].IsDir {
t.Errorf("entry[1]: got %+v", got.Entries[1])
}
}
func TestSkillListPathUnknown(t *testing.T) {
_, _, err := run(t, calFS(), "list", "no-such-skill")
if err == nil || !strings.Contains(err.Error(), "unknown skill") {
t.Fatalf("expected 'unknown skill' error, got %v", err)
}
}
func TestSkillListPathTraversal(t *testing.T) {
stdout, _, err := run(t, calFS(), "list", "lark-calendar/../../etc")
if err == nil || !strings.Contains(err.Error(), "invalid path") {
t.Fatalf("expected 'invalid path' error, got %v", err)
}
if stdout != "" {
t.Errorf("stdout must be empty on rejection, got %q", stdout)
}
}
func TestSkillListTooManyArgs(t *testing.T) {
_, _, err := run(t, calFS(), "list", "a", "b")
if err == nil || !strings.Contains(err.Error(), "at most 1 argument") {
t.Fatalf("expected 'at most 1 argument' error, got %v", err)
}
}
// TestSkillListSkipsDirWithoutSKILLmd proves a top-level dir lacking SKILL.md is
// omitted from the catalog (no blank entry).
func TestSkillListSkipsDirWithoutSKILLmd(t *testing.T) {
fsys := fstest.MapFS{
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\ndescription: \"Cal\"\n---\nb")},
"not-a-skill/readme.txt": {Data: []byte("junk")}, // dir without SKILL.md
}
stdout, _, err := run(t, fsys, "list")
if err != nil {
t.Fatalf("list error: %v", err)
}
var got struct {
Skills []map[string]any `json:"skills"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if got.Count != 1 || got.Skills[0]["name"] != "lark-calendar" {
t.Fatalf("expected only lark-calendar, got %+v", got.Skills)
}
}
func TestSkillReadRaw(t *testing.T) {
stdout, stderr, err := run(t, calFS(), "read", "lark-calendar")
if err != nil {
t.Fatalf("read error: %v", err)
}
if !strings.HasPrefix(stdout, "---\nname: lark-calendar") {
t.Errorf("raw output: got %q", stdout)
}
// Raw stdout is byte-pure SKILL.md — the guidance tip must NOT be appended.
if strings.Contains(stdout, "Tip:") {
t.Errorf("raw stdout must not carry the guidance tip: got %q", stdout)
}
// Guidance goes to stderr: own files via `skills read <name> ...`, and
// cross-skill refs routed to `skills read <other-skill> ...` (version-
// consistent), not "read directly".
if !strings.Contains(stderr, "lark-cli skills read lark-calendar <relative-path>") {
t.Errorf("expected own-files guidance on stderr: got %q", stderr)
}
if !strings.Contains(stderr, "lark-cli skills read lark-foo/...") {
t.Errorf("expected cross-skill refs routed to skills read: got %q", stderr)
}
if strings.Contains(stderr, "instead of opening them directly") ||
strings.Contains(stderr, "read those directly") {
t.Errorf("guidance must not steer cross-skill refs to direct reads: got %q", stderr)
}
}
func TestSkillReadJSON(t *testing.T) {
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "--json")
if err != nil {
t.Fatalf("read --json error: %v", err)
}
var got struct {
Skill, Path, Content, Guidance string
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v", e)
}
if got.Skill != "lark-calendar" || got.Path != "SKILL.md" || got.Content == "" {
t.Errorf("envelope: %+v", got)
}
// Guidance is a separate field, not merged into content.
if got.Guidance == "" {
t.Error("expected guidance field for main SKILL.md")
}
if strings.Contains(got.Content, "Tip:") {
t.Error("guidance must not be merged into content")
}
}
func TestSkillReadFile(t *testing.T) {
// Both the 2-arg and slash forms read the same file, with no guidance tip.
for _, args := range [][]string{
{"read", "lark-calendar", "references/agenda.md"},
{"read", "lark-calendar/references/agenda.md"},
} {
stdout, stderr, err := run(t, calFS(), args...)
if err != nil {
t.Fatalf("read %v error: %v", args, err)
}
if stdout != "# Agenda" {
t.Errorf("read %v output: got %q", args, stdout)
}
// Reference reads carry no guidance on either stream.
if strings.Contains(stderr, "Tip:") {
t.Errorf("read %v must not emit guidance on stderr: got %q", args, stderr)
}
}
}
func TestSkillReadFileJSON(t *testing.T) {
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "references/agenda.md", "--json")
if err != nil {
t.Fatalf("read file --json error: %v", err)
}
var got struct {
Skill, Path, Content, Guidance string
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if got.Skill != "lark-calendar" || got.Path != "references/agenda.md" || got.Content != "# Agenda" {
t.Errorf("envelope: %+v", got)
}
// Reference reads do not carry the guidance tip.
if got.Guidance != "" {
t.Errorf("reference read must not include guidance, got %q", got.Guidance)
}
}
func TestSkillReadUnknown(t *testing.T) {
_, _, err := run(t, calFS(), "read", "no-such")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "unknown skill") {
t.Errorf("err: %v", err)
}
}
func TestSkillReadMissingArg(t *testing.T) {
_, _, err := run(t, calFS(), "read")
if err == nil || !strings.Contains(err.Error(), "requires 1 or 2 arguments") {
t.Fatalf("expected arg error, got %v", err)
}
}
func TestSkillReadTraversal(t *testing.T) {
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "../../etc/passwd")
if err == nil {
t.Fatal("expected rejection")
}
if !strings.Contains(err.Error(), "invalid path") {
t.Errorf("err: %v", err)
}
if stdout != "" {
t.Errorf("stdout must be empty on rejection, got %q", stdout)
}
}
func TestSkillNilContentFS(t *testing.T) {
_, _, err := run(t, nil, "list")
if err == nil {
t.Fatal("expected error when SkillContent is nil")
}
if !strings.Contains(err.Error(), "not embedded") {
t.Errorf("err: %v", err)
}
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -73,149 +72,6 @@ func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
}
}
func TestUnknownFlagTokens(t *testing.T) {
_, drive, _ := newGroupTree()
// Give a subcommand a flag so a misplaced-but-known flag (the user omitted
// the subcommand) is distinguished from a genuinely unknown one.
for _, c := range drive.Commands() {
if c.Name() == "+search" {
c.Flags().String("query", "", "")
}
}
cases := []struct {
name string
rawArgs []string
want []string
}{
{"genuinely unknown long flag", []string{"drive", "--badflag"}, []string{"--badflag"}},
{"flag known on a subcommand (misplaced)", []string{"drive", "--query", "x"}, nil},
{"no flags at all", []string{"drive"}, nil},
{"tokens after -- are positional", []string{"drive", "--", "--badflag"}, nil},
{"unknown shorthand", []string{"drive", "-Z"}, []string{"-Z"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := unknownFlagTokens(drive, tc.rawArgs)
if len(got) != len(tc.want) {
t.Fatalf("unknownFlagTokens(%v) = %v, want %v", tc.rawArgs, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("token[%d] = %q, want %q", i, got[i], tc.want[i])
}
}
})
}
}
func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
// Simulate `lark-cli drive --badflag`: the UnknownFlags whitelist swallows
// --badflag, so RunE sees no args; the guard must recover it from
// rawInvocationArgs and fail structured rather than print help + exit 0.
rawInvocationArgs = []string{"drive", "--badflag"}
t.Cleanup(func() { rawInvocationArgs = nil })
err := drive.RunE(drive, nil)
if err == nil {
t.Fatal("expected a structured unknown_flag error, got nil (help fallthrough)")
}
if !strings.Contains(err.Error(), "unknown flag") {
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
}
// 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)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
}
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 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)
}
}
}
func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing.T) {
_, drive, _ := newGroupTree()
// --query is defined on the +search subcommand, so it is a *valid* flag that
// was placed before the (omitted) subcommand. Unlike an unknown flag, this
// must still fail structured (missing_subcommand) rather than fall through to
// help + exit 0 — `drive --query x` is a malformed call, not a help request.
for _, c := range drive.Commands() {
if c.Name() == "+search" {
c.Flags().String("query", "", "")
}
}
installUnknownSubcommandGuard(drive.Root())
rawInvocationArgs = []string{"drive", "--query", "x"}
t.Cleanup(func() { rawInvocationArgs = nil })
err := drive.RunE(drive, nil)
if err == nil {
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
}
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"])
}
}
// A bare group carrying only a group-valid global flag (e.g. the inherited
// --profile) is not missing a subcommand — those flags do not belong to a
// subcommand — so it must print help, not fail with missing_subcommand.
func TestUnknownSubcommandRunE_GroupValidGlobalFlagShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree()
drive.Root().PersistentFlags().String("profile", "", "") // global, inherited by drive
installUnknownSubcommandGuard(drive.Root())
rawInvocationArgs = []string{"--profile", "p", "drive"}
t.Cleanup(func() { rawInvocationArgs = nil })
var buf bytes.Buffer
drive.SetOut(&buf)
drive.SetErr(&buf)
if err := drive.RunE(drive, nil); err != nil {
t.Fatalf("bare group with only a global flag should print help, got error: %v", err)
}
if !strings.Contains(buf.String(), "drive ops") {
t.Errorf("expected help output, got:\n%s", buf.String())
}
}
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
@@ -308,7 +164,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
)
got, _ := availableSubcommandNames(root)
got := availableSubcommandNames(root)
want := []string{"alpha", "gamma"}
if len(got) != len(want) {
t.Fatalf("expected %v, got %v", want, got)
@@ -319,61 +175,3 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
}
}
}
func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
root.AddCommand(
&cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
)
available, deprecated := availableSubcommandNames(root)
if len(available) != 1 || available[0] != "+new-cmd" {
t.Errorf("available = %v, want [+new-cmd]", available)
}
if len(deprecated) != 1 || deprecated[0] != "+old-cmd" {
t.Errorf("deprecated = %v, want [+old-cmd]", deprecated)
}
}
// unknownSubcommandRunE 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) {
svc := &cobra.Command{Use: "sheets"}
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
svc.AddCommand(
&cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
)
err := unknownSubcommandRunE(svc, []string{"+reat"})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
}
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 {
if s == "+read" {
found = true
}
}
if !found {
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
}
}

View File

@@ -49,29 +49,18 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.NpmInstallOverride = npmFn
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
return func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(`{"skills":[{"name":"lark-calendar"},{"name":"lark-mail"}]}`)
return r
}
}
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
return func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
switch strings.Join(args, " ") {
case "-y skills add https://open.feishu.cn --list":
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
case "-y skills ls -g --json":
r.Stdout.WriteString(`[{"name":"lark-calendar","path":"/tmp/lark-calendar","scope":"global","agents":["Codex"]},{"name":"custom-skill","path":"/tmp/custom-skill","scope":"global","agents":["Codex"]}]`)
case "-y skills ls -g":
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
default:
@@ -487,10 +476,6 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
u.RestoreAvailableOverride = func() bool { return false }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
t.Fatal("skills sync should not run when binary verification fails")
return nil
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
t.Fatal("skills sync should not run when binary verification fails")
return nil
@@ -823,11 +808,6 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("index unavailable")
return r
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
@@ -880,11 +860,6 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("index unavailable")
return r
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
@@ -1029,7 +1004,6 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
@@ -1068,7 +1042,6 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{
Method: selfupdate.InstallManual,
@@ -1113,7 +1086,6 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{
Method: selfupdate.InstallNpm, NpmAvailable: true,
@@ -1173,10 +1145,6 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
},
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsIndexFetch()()
},
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
@@ -1226,10 +1194,6 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsIndexFetch()()
},
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)

View File

@@ -155,30 +155,7 @@ caller scripts.
New code should not reach for `ErrBare` unless the command is
genuinely a predicate. Anything carrying recoverable error content
belongs in a typed `*errs.XxxError` — or, for a batch result, in the
partial-failure outcome below.
### Partial failure (batch / multi-status)
A batch command (e.g. `drive +push` / `+pull` / `+sync`) that processes
many items can finish in a third state, neither full success nor a single
error: some items succeeded and some failed. Its primary output is the
per-item result, so it does **not** belong in a `stderr` error envelope.
Such a command returns `runtime.OutPartialFailure(data, meta)`, which:
1. writes the full result to **stdout** as an `ok:false` envelope — the
summary and every per-item outcome (succeeded *and* failed) stay
machine-readable, exactly as a successful `Out(...)` would carry them,
but with `ok` honestly reporting failure; and
2. returns `*output.PartialFailureError`, a typed exit signal the
dispatcher maps to a non-zero exit code while writing nothing further
to `stderr`.
This is distinct from `ErrBare` (a predicate's one-bit answer) and from a
typed `*errs.XxxError` (a `stderr` error envelope): a partial failure is a
*result*, reported on stdout, that also failed. Consumers branch on
`ok == false` and then read `data.summary` / `data.items[]`.
belongs in a typed `*errs.XxxError`.
## Consumers

View File

@@ -12,8 +12,7 @@ const (
// CategoryValidation subtypes
const (
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
)
// CategoryAuthentication subtypes

View File

@@ -61,22 +61,8 @@ type TypedError interface {
// it is intentionally not serialized.
type ValidationError struct {
Problem
Param string `json:"param,omitempty"`
Params []InvalidParam `json:"params,omitempty"`
Cause error `json:"-"`
}
// InvalidParam is one structured validation diagnostic: the parameter that
// failed (Name) and why (Reason). It mirrors an RFC 7807 "invalid-params"
// item (RFC 7807 §3.1 extension members).
//
// The wire key on ValidationError is "params" rather than "invalid_params"
// because the enclosing envelope already carries type:"validation", so the
// "invalid" qualifier would be redundant on the wire. The Go type keeps the
// InvalidParam prefix because, at package level, the name must self-describe.
type InvalidParam struct {
Name string `json:"name"`
Reason string `json:"reason"`
Param string `json:"param,omitempty"`
Cause error `json:"-"`
}
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
@@ -136,11 +122,6 @@ func (e *ValidationError) WithParam(param string) *ValidationError {
return e
}
func (e *ValidationError) WithParams(params ...InvalidParam) *ValidationError {
e.Params = append(e.Params, params...)
return e
}
func (e *ValidationError) WithCause(cause error) *ValidationError {
e.Cause = cause
return e

View File

@@ -558,71 +558,6 @@ func TestTypedError_UnwrapSymmetry(t *testing.T) {
})
}
// TestValidationError_WithParams covers the structured-validation extension:
// WithParams appends InvalidParam items, the scalar Param setter is unaffected,
// and the wire shape nests {name, reason} under "params" (omitted when empty).
func TestValidationError_WithParams(t *testing.T) {
t.Run("appends and exposes fields", func(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
if len(e.Params) != 1 {
t.Fatalf("len(Params) = %d, want 1", len(e.Params))
}
if e.Params[0].Name != "a.md" {
t.Errorf("Params[0].Name = %q, want %q", e.Params[0].Name, "a.md")
}
if e.Params[0].Reason != "duplicate" {
t.Errorf("Params[0].Reason = %q, want %q", e.Params[0].Reason, "duplicate")
}
})
t.Run("appends across multiple calls and returns receiver", func(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
returned := e.WithParams(errs.InvalidParam{Name: "a.md", Reason: "dup"})
if returned != e {
t.Errorf("WithParams returned different pointer; want same as receiver")
}
e.WithParams(
errs.InvalidParam{Name: "b.md", Reason: "dup"},
errs.InvalidParam{Name: "c.md", Reason: "dup"},
)
if len(e.Params) != 3 {
t.Fatalf("len(Params) = %d after two calls, want 3", len(e.Params))
}
})
t.Run("wire shape nests name and reason under params", func(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
WithParam("--rel-path").
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
b, err := json.Marshal(e)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
got := string(b)
for _, want := range []string{
`"type":"validation"`,
`"param":"--rel-path"`,
`"params":[{"name":"a.md","reason":"duplicate"}]`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in %s", want, got)
}
}
})
t.Run("empty Params omitted from wire", func(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
b, err := json.Marshal(e)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
if strings.Contains(string(b), `"params"`) {
t.Errorf("empty Params should be omitted from wire; got %s", b)
}
})
}
func TestBuilderSetter_DefensiveCopy(t *testing.T) {
t.Run("WithMissingScopes clones input", func(t *testing.T) {
scopes := []string{"docx:document", "im:message:send"}

View File

@@ -5,9 +5,9 @@ package minutes
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
@@ -16,8 +16,7 @@ const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
}
body := map[string]string{"event_type": eventType}

View File

@@ -8,7 +8,6 @@ import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/events/whiteboard"
"github.com/larksuite/cli/internal/event"
)
@@ -18,7 +17,6 @@ func init() {
im.Keys(),
minutes.Keys(),
vc.Keys(),
whiteboard.Keys(),
}
for _, keys := range all {
for _, k := range keys {

View File

@@ -1,35 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
// isLarkCode must match the API code on typed errs.* errors — the consume
// runtime classifies OAPI failures via errclass.BuildAPIError, so the
// not-found retry in fillVCNoteGeneratedDetails depends on this reading
// Problem.Code rather than the legacy envelope shape.
func TestIsLarkCode_MatchesTypedAPIErrorCode(t *testing.T) {
typedNotFound := errs.NewAPIError(errs.SubtypeNotFound, "note not ready").
WithCode(vcNoteDetailNotFoundCode)
if !isLarkCode(typedNotFound, vcNoteDetailNotFoundCode) {
t.Fatal("typed API error carrying the not-found code must match (retry path)")
}
if isLarkCode(typedNotFound, 99999) {
t.Error("a different expected code must not match")
}
otherTyped := errs.NewAPIError(errs.SubtypeServerError, "boom").WithCode(500)
if isLarkCode(otherTyped, vcNoteDetailNotFoundCode) {
t.Error("typed error with another code must not match")
}
if isLarkCode(errors.New("plain failure"), vcNoteDetailNotFoundCode) {
t.Error("untyped error must not match")
}
}

View File

@@ -6,11 +6,12 @@ package vc
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
)
@@ -147,8 +148,9 @@ func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VC
}
func isLarkCode(err error, code int) bool {
if p, ok := errs.ProblemOf(err); ok {
return p.Code == code
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return exitErr.Detail.Code == code
}
return false
}

View File

@@ -5,9 +5,9 @@ package vc
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
@@ -16,8 +16,7 @@ const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
}
body := map[string]string{"event_type": eventType}

View File

@@ -1,84 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
)
// VCRecordingEndedOutput is the flattened shape for vc.recording.recording_ended_v1.
type VCRecordingEndedOutput struct {
Type string `json:"type" desc:"Event type; always vc.recording.recording_ended_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
EventTime string `json:"event_time,omitempty" desc:"Time when the recording ended and uploaded successfully, in RFC3339 / ISO 8601 with the current system timezone"`
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
}
type recordingEndedEnvelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event recordingEndedEvent `json:"event"`
}
type recordingEndedEvent struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
}
func processVCRecordingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
envelope, ok := parseRecordingEndedEnvelope(raw)
if !ok {
return raw.Payload, nil
}
if !isRecordingEndedBeanEvent(envelope) {
return nil, nil
}
out := &VCRecordingEndedOutput{
Type: recordingEndedEventType(envelope, raw),
EventID: envelope.Header.EventID,
EventTime: recordingEndedEventTime(envelope.Header.CreateTime),
UniqueKey: envelope.Event.UniqueKey,
Source: envelope.Event.Source,
}
return json.Marshal(out)
}
func parseRecordingEndedEnvelope(raw *event.RawEvent) (*recordingEndedEnvelope, bool) {
var envelope recordingEndedEnvelope
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, false
}
return &envelope, true
}
func isRecordingEndedBeanEvent(envelope *recordingEndedEnvelope) bool {
return envelope != nil && envelope.Event.Source == "recording_bean"
}
func recordingEndedEventType(envelope *recordingEndedEnvelope, raw *event.RawEvent) string {
if envelope != nil && envelope.Header.EventType != "" {
return envelope.Header.EventType
}
return raw.EventType
}
func recordingEndedEventTime(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}

View File

@@ -1,84 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
)
// VCRecordingStartedOutput is the flattened shape for vc.recording.recording_started_v1.
type VCRecordingStartedOutput struct {
Type string `json:"type" desc:"Event type; always vc.recording.recording_started_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
EventTime string `json:"event_time,omitempty" desc:"Recording start time in RFC3339 / ISO 8601 with the current system timezone"`
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
}
type recordingStartedEnvelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event recordingStartedEvent `json:"event"`
}
type recordingStartedEvent struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
}
func processVCRecordingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
envelope, ok := parseRecordingStartedEnvelope(raw)
if !ok {
return raw.Payload, nil
}
if !isRecordingStartedBeanEvent(envelope) {
return nil, nil
}
out := &VCRecordingStartedOutput{
Type: recordingStartedEventType(envelope, raw),
EventID: envelope.Header.EventID,
EventTime: recordingStartedEventTime(envelope.Header.CreateTime),
UniqueKey: envelope.Event.UniqueKey,
Source: envelope.Event.Source,
}
return json.Marshal(out)
}
func parseRecordingStartedEnvelope(raw *event.RawEvent) (*recordingStartedEnvelope, bool) {
var envelope recordingStartedEnvelope
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, false
}
return &envelope, true
}
func isRecordingStartedBeanEvent(envelope *recordingStartedEnvelope) bool {
return envelope != nil && envelope.Event.Source == "recording_bean"
}
func recordingStartedEventType(envelope *recordingStartedEnvelope, raw *event.RawEvent) string {
if envelope != nil && envelope.Header.EventType != "" {
return envelope.Header.EventType
}
return raw.EventType
}
func recordingStartedEventTime(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}

View File

@@ -1,468 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestVCKeys_RecordingEventsRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
eventType string
}{
{eventTypeRecordingStarted},
{eventTypeRecordingTranscriptGenerated},
{eventTypeRecordingEnded},
} {
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:recording: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 !strings.Contains(def.Description, "recording_bean") {
t.Errorf("Description should document recording_bean source, got %q", def.Description)
}
if !strings.Contains(def.Description, "connected to Feishu software") {
t.Errorf("Description should document Feishu software connection requirement, got %q", def.Description)
}
if strings.Contains(def.Description, "future") || strings.Contains(def.Description, "software_recording") {
t.Errorf("Description should not mention future sources, got %q", def.Description)
}
if tc.eventType == eventTypeRecordingEnded && (strings.Contains(def.Description, "object_type") || strings.Contains(def.Description, "object_id")) {
t.Errorf("ended Description should not document object metadata, got %q", def.Description)
}
wantSchemaType := reflect.TypeOf(VCRecordingStartedOutput{})
switch tc.eventType {
case eventTypeRecordingTranscriptGenerated:
wantSchemaType = reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})
case eventTypeRecordingEnded:
wantSchemaType = reflect.TypeOf(VCRecordingEndedOutput{})
}
if def.Schema.Custom.Type != wantSchemaType {
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, wantSchemaType)
}
})
}
}
func TestProcessVCRecordingStarted(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
out := runRecordingProcess[VCRecordingStartedOutput](t, eventTypeRecordingStarted, processVCRecordingStarted, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_start_001",
"event_type": "vc.recording.recording_started_v1",
"create_time": "1761782400000"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean"
}
}`)
if out.Type != eventTypeRecordingStarted {
t.Errorf("Type = %q", out.Type)
}
if out.EventID != "ev_rec_start_001" || out.EventTime != recordingTestEventTime(1761782400000) {
t.Errorf("EventID/EventTime = %q/%q", out.EventID, out.EventTime)
}
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
}
}
func TestProcessVCRecordingTranscriptGenerated(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got := runRecordingProcessRaw(t, eventTypeRecordingTranscriptGenerated, processVCRecordingTranscriptGenerated, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_transcript_001",
"event_type": "vc.recording.recording_transcript_generated_v1",
"create_time": "1761782400100"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean",
"transcript_items": [
{
"speaker": {
"id": {
"open_id": "ou_0f8bf7acdf2ae69553ecbdbfbbd10a53",
"union_id": "on_bc03f16d781bff4178a5d11e48eb1867",
"user_id": null
},
"user_type": 100,
"user_role": 1,
"user_name": "Alice"
},
"text": "hello world",
"language": "en_us",
"start_time_ms": "1761782399000",
"end_time_ms": "1761782400000",
"sentence_id": "987654321"
},
{
"speaker": {
"user_name": "Bob"
},
"text": "second sentence",
"language": "en_us",
"start_time_ms": "1761782401000",
"end_time_ms": "1761782402000",
"sentence_id": "987654322"
}
]
}
}`)
if got == nil {
t.Fatal("Process output is nil")
}
var out VCRecordingTranscriptGeneratedOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
}
if out.Type != eventTypeRecordingTranscriptGenerated {
t.Errorf("Type = %q", out.Type)
}
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
}
if out.EventTime != recordingTestEventTime(1761782400100) {
t.Errorf("EventTime = %q", out.EventTime)
}
if len(out.TranscriptItems) != 2 {
t.Fatalf("TranscriptItems len = %d, want 2", len(out.TranscriptItems))
}
item := out.TranscriptItems[0]
if item.SpeakerName != "Alice" || item.Text != "hello world" {
t.Errorf("Transcript speaker/text = %q/%q", item.SpeakerName, item.Text)
}
if item.StartTime != recordingTestEventTime(1761782399000) || item.EndTime != recordingTestEventTime(1761782400000) {
t.Errorf("Transcript timing = %q/%q", item.StartTime, item.EndTime)
}
if item.SentenceID != "987654321" {
t.Errorf("SentenceID = %q, want 987654321", item.SentenceID)
}
if out.TranscriptItems[1].SpeakerName != "Bob" || out.TranscriptItems[1].SentenceID != "987654322" {
t.Errorf("second transcript item = %+v", out.TranscriptItems[1])
}
itemJSON, err := json.Marshal(item)
if err != nil {
t.Fatalf("marshal transcript item: %v", err)
}
var itemFields map[string]any
if err := json.Unmarshal(itemJSON, &itemFields); err != nil {
t.Fatalf("unmarshal transcript item JSON: %v", err)
}
wantItemFields := map[string]bool{
"speaker_name": true,
"text": true,
"start_time": true,
"end_time": true,
"sentence_id": true,
}
for gotField := range itemFields {
if !wantItemFields[gotField] {
t.Errorf("Transcript item should not contain field %q, got %s", gotField, string(itemJSON))
}
}
for wantField := range wantItemFields {
if _, ok := itemFields[wantField]; !ok {
t.Errorf("Transcript item missing field %q, got %s", wantField, string(itemJSON))
}
}
for _, unexpected := range []string{
`"seq_id"`,
`"speaker"`,
`"user_open_id"`,
`"user_type"`,
`"user_role"`,
`"language"`,
`"start_time_ms"`,
`"end_time_ms"`,
`"sequence_id"`,
`"transcript_item"`,
} {
if strings.Contains(string(got), unexpected) {
t.Errorf("Transcript output should not contain %s, got %s", unexpected, string(got))
}
}
if !strings.Contains(string(got), `"sentence_id":"987654321"`) {
t.Errorf("Transcript output should contain sentence_id, got %s", string(got))
}
}
func TestProcessVCRecordingEnded(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
out := runRecordingProcess[VCRecordingEndedOutput](t, eventTypeRecordingEnded, processVCRecordingEnded, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_end_001",
"event_type": "vc.recording.recording_ended_v1",
"create_time": "1761782400200"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean",
"object_type": "minutes",
"object_id": "minute_token_001"
}
}`)
if out.Type != eventTypeRecordingEnded {
t.Errorf("Type = %q", out.Type)
}
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
}
if out.EventTime != recordingTestEventTime(1761782400200) {
t.Errorf("EventTime = %q", out.EventTime)
}
}
func TestProcessVCRecordingEnded_DropsObjectMetadata(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got := runRecordingProcessRaw(t, eventTypeRecordingEnded, processVCRecordingEnded, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_end_001",
"event_type": "vc.recording.recording_ended_v1",
"create_time": "1761782400200"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean",
"object_type": "minutes",
"object_id": "minute_token_001"
}
}`)
if strings.Contains(string(got), "object_type") || strings.Contains(string(got), "object_id") {
t.Fatalf("ended output should drop object metadata, got %s", string(got))
}
}
func TestProcessVCRecording_DropsTimestampField(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got := runRecordingProcessRaw(t, eventTypeRecordingStarted, processVCRecordingStarted, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_start_001",
"event_type": "vc.recording.recording_started_v1",
"create_time": "1761782400000"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean"
}
}`)
if strings.Contains(string(got), `"timestamp"`) {
t.Fatalf("recording output should use event_time instead of timestamp, got %s", string(got))
}
if !strings.Contains(string(got), `"event_time":"`+recordingTestEventTime(1761782400000)+`"`) {
t.Fatalf("recording output should include ISO 8601 event_time, got %s", string(got))
}
}
func TestProcessVCRecording_NonRecordingBeanFiltered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
payload string
}{
{
name: "started",
eventType: eventTypeRecordingStarted,
process: processVCRecordingStarted,
payload: `{
"schema": "2.0",
"header": {"event_id": "ev_rec_start_001", "event_type": "vc.recording.recording_started_v1"},
"event": {"unique_key": "recording_001", "source": "software_recording"}
}`,
},
{
name: "transcript",
eventType: eventTypeRecordingTranscriptGenerated,
process: processVCRecordingTranscriptGenerated,
payload: `{
"schema": "2.0",
"header": {"event_id": "ev_rec_transcript_001", "event_type": "vc.recording.recording_transcript_generated_v1"},
"event": {"unique_key": "recording_001", "source": "software_recording", "transcript_items": []}
}`,
},
{
name: "ended",
eventType: eventTypeRecordingEnded,
process: processVCRecordingEnded,
payload: `{
"schema": "2.0",
"header": {"event_id": "ev_rec_end_001", "event_type": "vc.recording.recording_ended_v1"},
"event": {"unique_key": "recording_001", "source": "software_recording"}
}`,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := runRecordingProcessRaw(t, tc.eventType, tc.process, tc.payload)
if got != nil {
t.Fatalf("non-recording_bean event should be filtered, got %s", string(got))
}
})
}
}
func TestProcessVCRecording_MalformedPayloadPassthrough(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: eventTypeRecordingStarted, process: processVCRecordingStarted},
{name: "transcript", eventType: eventTypeRecordingTranscriptGenerated, process: processVCRecordingTranscriptGenerated},
{name: "ended", eventType: eventTypeRecordingEnded, process: processVCRecordingEnded},
} {
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 TestVCRecording_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
eventType string
}{
{eventTypeRecordingStarted},
{eventTypeRecordingTranscriptGenerated},
{eventTypeRecordingEnded},
} {
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)
}
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 != pathRecordingSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, tc.eventType)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathRecordingUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, tc.eventType)
})
}
}
func runRecordingProcess[T any](t *testing.T, eventType string, process event.ProcessFunc, payload string) T {
t.Helper()
got := runRecordingProcessRaw(t, eventType, process, payload)
if got == nil {
t.Fatal("Process output is nil")
}
var out T
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
}
return out
}
func runRecordingProcessRaw(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
}
func recordingTestEventTime(millis int64) string {
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}

View File

@@ -1,163 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
)
// VCRecordingTranscriptItemOutput is one flattened transcript item for recording events.
type VCRecordingTranscriptItemOutput struct {
SpeakerName string `json:"speaker_name,omitempty" desc:"Speaker display name"`
Text string `json:"text,omitempty" desc:"Transcript text"`
StartTime string `json:"start_time,omitempty" desc:"Transcript item start time in RFC3339 / ISO 8601 with the current system timezone"`
EndTime string `json:"end_time,omitempty" desc:"Transcript item end time in RFC3339 / ISO 8601 with the current system timezone"`
SentenceID string `json:"sentence_id,omitempty" desc:"Transcript sentence ID"`
}
// VCRecordingTranscriptGeneratedOutput is the flattened shape for vc.recording.recording_transcript_generated_v1.
type VCRecordingTranscriptGeneratedOutput struct {
Type string `json:"type" desc:"Event type; always vc.recording.recording_transcript_generated_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
EventTime string `json:"event_time,omitempty" desc:"Time when this batch of transcript items was generated, in RFC3339 / ISO 8601 with the current system timezone"`
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
TranscriptItems []VCRecordingTranscriptItemOutput `json:"transcript_items,omitempty" desc:"Generated transcript items"`
}
type recordingTranscriptGeneratedEnvelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event recordingTranscriptGeneratedEvent `json:"event"`
}
type recordingTranscriptGeneratedEvent struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
TranscriptItems []recordingTranscriptGeneratedItemIn `json:"transcript_items"`
}
type recordingTranscriptGeneratedItemIn struct {
Speaker *recordingTranscriptGeneratedSpeakerIn `json:"speaker"`
Text string `json:"text"`
StartTimeMs recordingTranscriptGeneratedString `json:"start_time_ms"`
EndTimeMs recordingTranscriptGeneratedString `json:"end_time_ms"`
SentenceID string `json:"sentence_id"`
}
type recordingTranscriptGeneratedSpeakerIn struct {
UserName string `json:"user_name"`
}
type recordingTranscriptGeneratedString string
func processVCRecordingTranscriptGenerated(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
envelope, ok := parseRecordingTranscriptGeneratedEnvelope(raw)
if !ok {
return raw.Payload, nil
}
if !isRecordingTranscriptGeneratedBeanEvent(envelope) {
return nil, nil
}
out := &VCRecordingTranscriptGeneratedOutput{
Type: recordingTranscriptGeneratedEventType(envelope, raw),
EventID: envelope.Header.EventID,
EventTime: recordingTranscriptGeneratedEventTime(envelope.Header.CreateTime),
UniqueKey: envelope.Event.UniqueKey,
Source: envelope.Event.Source,
TranscriptItems: recordingTranscriptItems(envelope.Event.TranscriptItems),
}
return json.Marshal(out)
}
func parseRecordingTranscriptGeneratedEnvelope(raw *event.RawEvent) (*recordingTranscriptGeneratedEnvelope, bool) {
var envelope recordingTranscriptGeneratedEnvelope
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, false
}
return &envelope, true
}
func isRecordingTranscriptGeneratedBeanEvent(envelope *recordingTranscriptGeneratedEnvelope) bool {
return envelope != nil && envelope.Event.Source == "recording_bean"
}
func recordingTranscriptGeneratedEventType(envelope *recordingTranscriptGeneratedEnvelope, raw *event.RawEvent) string {
if envelope != nil && envelope.Header.EventType != "" {
return envelope.Header.EventType
}
return raw.EventType
}
func recordingTranscriptGeneratedEventTime(raw string) string {
return recordingTranscriptGeneratedMillisToLocalRFC3339(raw)
}
func recordingTranscriptGeneratedMillisToLocalRFC3339(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}
func recordingTranscriptItems(items []recordingTranscriptGeneratedItemIn) []VCRecordingTranscriptItemOutput {
if len(items) == 0 {
return nil
}
out := make([]VCRecordingTranscriptItemOutput, 0, len(items))
for _, item := range items {
out = append(out, recordingTranscriptItem(item))
}
return out
}
func recordingTranscriptItem(item recordingTranscriptGeneratedItemIn) VCRecordingTranscriptItemOutput {
return VCRecordingTranscriptItemOutput{
SpeakerName: recordingSpeakerName(item.Speaker),
Text: item.Text,
StartTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.StartTimeMs.String()),
EndTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.EndTimeMs.String()),
SentenceID: item.SentenceID,
}
}
func recordingSpeakerName(speaker *recordingTranscriptGeneratedSpeakerIn) string {
if speaker == nil {
return ""
}
return speaker.UserName
}
func (s *recordingTranscriptGeneratedString) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
return nil
}
var str string
if err := json.Unmarshal(data, &str); err == nil {
*s = recordingTranscriptGeneratedString(str)
return nil
}
var num json.Number
if err := json.Unmarshal(data, &num); err != nil {
return err
}
*s = recordingTranscriptGeneratedString(num.String())
return nil
}
func (s recordingTranscriptGeneratedString) String() string {
return string(s)
}

View File

@@ -11,18 +11,13 @@ import (
)
const (
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
eventTypeNoteGenerated = "vc.note.generated_v1"
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
eventTypeRecordingTranscriptGenerated = "vc.recording.recording_transcript_generated_v1"
eventTypeRecordingEnded = "vc.recording.recording_ended_v1"
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
eventTypeNoteGenerated = "vc.note.generated_v1"
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
pathRecordingSubscribe = "/open-apis/vc/v1/recordings/subscription"
pathRecordingUnsubscribe = "/open-apis/vc/v1/recordings/unsubscription"
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s"
)
@@ -62,53 +57,5 @@ func Keys() []event.KeyDefinition {
},
RequiredConsoleEvents: []string{eventTypeNoteGenerated},
},
{
Key: eventTypeRecordingStarted,
DisplayName: "Recording started",
Description: "Triggered when a recording_bean recording starts; only generated when connected to Feishu software.",
EventType: eventTypeRecordingStarted,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingStartedOutput{})},
},
Process: processVCRecordingStarted,
PreConsume: subscriptionPreConsume(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe),
Scopes: []string{"vc:recording:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeRecordingStarted},
},
{
Key: eventTypeRecordingTranscriptGenerated,
DisplayName: "Recording transcript generated",
Description: "Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.",
EventType: eventTypeRecordingTranscriptGenerated,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})},
},
Process: processVCRecordingTranscriptGenerated,
PreConsume: subscriptionPreConsume(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe),
Scopes: []string{"vc:recording:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeRecordingTranscriptGenerated},
},
{
Key: eventTypeRecordingEnded,
DisplayName: "Recording ended",
Description: "Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.",
EventType: eventTypeRecordingEnded,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingEndedOutput{})},
},
Process: processVCRecordingEnded,
PreConsume: subscriptionPreConsume(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe),
Scopes: []string{"vc:recording:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeRecordingEnded},
},
}
}

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
// BoardWhiteboardUpdatedV1Data is the flattened whiteboard updated source payload.
type BoardWhiteboardUpdatedV1Data struct {
// WhiteboardID is the id of the whiteboard whose content was updated.
WhiteboardID string `json:"whiteboard_id"`
// OperatorIDs lists the operators that produced this update batch.
OperatorIDs []OperatorID `json:"operator_ids"`
}
// OperatorID identifies an operator that produced the whiteboard update,
// expressed in the three Lark identity formats.
type OperatorID struct {
// OpenID is the operator's open_id within the current app.
OpenID string `json:"open_id"`
// UnionID is the operator's union_id across apps under the same ISV.
UnionID string `json:"union_id"`
// UserID is the operator's user_id within the tenant.
UserID string `json:"user_id"`
}

View File

@@ -1,53 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/validate"
)
// cleanupTimeout bounds how long the unsubscribe call has to finish during
// PreConsume cleanup so a stuck OAPI cannot block process shutdown.
const cleanupTimeout = 5 * time.Second
// whiteboardSubscriptionPreConsume calls the whiteboard event subscribe OAPI
// and returns a cleanup that invokes the matching unsubscribe.
//
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
whiteboardID := params["whiteboard_id"]
if whiteboardID == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"param whiteboard_id is required for %s", eventType).
WithParam("--param").
WithHint("pass it as --param whiteboard_id=<id>; run `lark-cli event schema %s` for details", eventType)
}
encoded := validate.EncodePathSegment(whiteboardID)
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
unsubscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/unsubscribe", encoded)
body := map[string]string{"event_type": eventType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, err
}
return func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
}, nil
}
}

View File

@@ -1,212 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
// recordedCall captures a single APIClient invocation for assertion.
type recordedCall struct {
method string
path string
body interface{}
}
// fakeAPIClient is a minimal event.APIClient stub that records calls and
// can be configured to fail when the request path matches errOnPath.
type fakeAPIClient struct {
mu sync.Mutex
calls []recordedCall
errOnPath string
}
// CallAPI records the invocation and optionally returns a simulated error
// when the path contains the configured errOnPath substring.
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
if f.errOnPath != "" && strings.Contains(path, f.errOnPath) {
return nil, errors.New("simulated subscribe failure")
}
return json.RawMessage(`{}`), nil
}
// TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID verifies that the
// PreConsume hook fails fast with an actionable error when whiteboard_id
// is absent from the params map.
func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{})
if err == nil {
t.Fatalf("expected error when whiteboard_id missing")
}
if cleanup != nil {
t.Fatalf("expected nil cleanup on error")
}
if !strings.Contains(err.Error(), "whiteboard_id") {
t.Fatalf("error should mention whiteboard_id, 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 || ve.Param != "--param" {
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
}
if ve.Hint == "" {
t.Error("missing whiteboard_id should carry a hint")
}
}
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
// returns an error when the runtime APIClient dependency is missing.
func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
_, err := pc(context.Background(), nil, map[string]string{"whiteboard_id": "wb1"})
if err == nil {
t.Fatalf("expected error when runtime client is nil")
}
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryInternal {
t.Errorf("nil-runtime invariant should be a typed internal error, got %T: %v", err, err)
}
}
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
// failed subscribe call surfaces the error and skips registering a cleanup,
// so no spurious unsubscribe is invoked.
func TestWhiteboardSubscriptionPreConsume_SubscribeError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
rt := &fakeAPIClient{errOnPath: "/subscribe"}
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
if err == nil {
t.Fatalf("expected error from subscribe call")
}
if cleanup != nil {
t.Fatalf("expected nil cleanup when subscribe fails")
}
// only the failed subscribe call should have been made; no unsubscribe.
if len(rt.calls) != 1 {
t.Fatalf("expected exactly 1 call (subscribe), got %d", len(rt.calls))
}
}
// TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup verifies the full
// happy-path: subscribe is called once with the correct method/path/body,
// and the returned cleanup invokes the matching unsubscribe.
func TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
rt := &fakeAPIClient{}
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cleanup == nil {
t.Fatalf("expected non-nil cleanup")
}
if len(rt.calls) != 1 {
t.Fatalf("expected 1 call after subscribe, got %d", len(rt.calls))
}
got := rt.calls[0]
if got.method != "POST" {
t.Errorf("subscribe method: got %q, want POST", got.method)
}
wantSubPath := "/open-apis/board/v1/whiteboards/wb1/subscribe"
if got.path != wantSubPath {
t.Errorf("subscribe path: got %q, want %q", got.path, wantSubPath)
}
body, _ := got.body.(map[string]string)
if body["event_type"] != eventTypeWhiteboardUpdated {
t.Errorf("subscribe body event_type: got %q, want %q", body["event_type"], eventTypeWhiteboardUpdated)
}
cleanup()
if len(rt.calls) != 2 {
t.Fatalf("expected 2 calls after cleanup, got %d", len(rt.calls))
}
got2 := rt.calls[1]
if got2.method != "POST" {
t.Errorf("unsubscribe method: got %q, want POST", got2.method)
}
wantUnsubPath := "/open-apis/board/v1/whiteboards/wb1/unsubscribe"
if got2.path != wantUnsubPath {
t.Errorf("unsubscribe path: got %q, want %q", got2.path, wantUnsubPath)
}
body2, _ := got2.body.(map[string]string)
if body2["event_type"] != eventTypeWhiteboardUpdated {
t.Errorf("unsubscribe body event_type: got %q, want %q", body2["event_type"], eventTypeWhiteboardUpdated)
}
}
// TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded verifies that
// whiteboard_id values containing reserved URL characters are properly
// path-segment encoded so they cannot escape into adjacent path segments.
func TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
rt := &fakeAPIClient{}
// 含特殊字符的 whiteboard_id 应被 path-segment 编码,避免越界到其他 path 段。
_, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb/1?evil"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rt.calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(rt.calls))
}
if strings.Contains(rt.calls[0].path, "wb/1?evil") {
t.Errorf("whiteboard_id was not encoded; path: %s", rt.calls[0].path)
}
}
// TestWhiteboardUpdatedV1HasPreConsume ensures the registered EventKey for
// board.whiteboard.updated_v1 wires the PreConsume hook and declares the
// required whiteboard_id parameter.
func TestWhiteboardUpdatedV1HasPreConsume(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
keys := Keys()
for _, k := range keys {
if k.Key == eventTypeWhiteboardUpdated {
if k.PreConsume == nil {
t.Fatalf("EventKey %s should have PreConsume hook", eventTypeWhiteboardUpdated)
}
if len(k.Params) == 0 {
t.Fatalf("EventKey %s should declare whiteboard_id param", eventTypeWhiteboardUpdated)
}
var found bool
for _, p := range k.Params {
if p.Name == "whiteboard_id" && p.Required {
found = true
}
}
if !found {
t.Fatalf("EventKey %s must declare required whiteboard_id param", eventTypeWhiteboardUpdated)
}
return
}
}
t.Fatalf("EventKey %s not registered", eventTypeWhiteboardUpdated)
}
// 确保 event.APIClient 接口与本测试 mock 一致。
var _ event.APIClient = (*fakeAPIClient)(nil)

View File

@@ -1,48 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package whiteboard registers Board-domain EventKeys.
package whiteboard
import (
"reflect"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
// eventTypeWhiteboardUpdated is the OAPI event type for whiteboard content updates.
const eventTypeWhiteboardUpdated = "board.whiteboard.updated_v1"
// Keys returns all Board-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeWhiteboardUpdated,
DisplayName: "Whiteboard updated",
Description: "Pushed when the whiteboard content is updated.",
EventType: eventTypeWhiteboardUpdated,
Params: []event.ParamDef{
{
Name: "whiteboard_id",
Type: event.ParamString,
Required: true,
Description: "Whiteboard id to subscribe; subscription is per-whiteboard.",
},
},
Schema: event.SchemaDef{
Native: &event.SchemaSpec{Type: reflect.TypeOf(BoardWhiteboardUpdatedV1Data{})},
FieldOverrides: map[string]schemas.FieldMeta{
"/event/whiteboard_id": {Kind: "whiteboard_id", Description: "whiteboard id to subscribe"},
"/event/operator_ids/*/open_id": {Kind: "open_id"},
"/event/operator_ids/*/union_id": {Kind: "union_id"},
"/event/operator_ids/*/user_id": {Kind: "user_id"},
},
},
PreConsume: whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated),
Scopes: []string{"board:whiteboard:node:read"},
AuthTypes: []string{"user", "bot"},
RequiredConsoleEvents: []string{eventTypeWhiteboardUpdated},
},
}
}

2
go.mod
View File

@@ -14,7 +14,7 @@ require (
github.com/sergi/go-diff v1.4.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.9
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0

View File

@@ -14,7 +14,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/util"
)
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
@@ -28,7 +28,7 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return transport.Fallback()
return util.FallbackTransport()
}
// RoundTrip implements http.RoundTripper.

View File

@@ -6,7 +6,6 @@ package cmdutil
import (
"context"
"io"
"io/fs"
"net/http"
"strings"
@@ -44,8 +43,6 @@ type Factory struct {
Credential *credential.CredentialProvider
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
}
// ResolveFileIO resolves a FileIO instance using the current execution context.

View File

@@ -23,7 +23,7 @@ import (
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/util"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
)
@@ -102,15 +102,15 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
transport.WarnIfProxied(f.IOStreams.ErrOut)
util.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
var rt http.RoundTripper = transport.Shared()
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
rt = wrapWithExtension(rt)
var transport http.RoundTripper = util.SharedTransport()
transport = &RetryTransport{Base: transport}
transport = &SecurityHeaderTransport{Base: transport}
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
transport = wrapWithExtension(transport)
client := &http.Client{
Transport: rt,
Transport: transport,
Timeout: 30 * time.Second,
CheckRedirect: safeRedirectPolicy,
}
@@ -129,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHeaders(BaseSecurityHeaders()),
}
transport.WarnIfProxied(f.IOStreams.ErrOut)
util.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
CheckRedirect: safeRedirectPolicy,
@@ -141,7 +141,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = transport.Shared()
var sdkTransport http.RoundTripper = util.SharedTransport()
sdkTransport = &RetryTransport{Base: sdkTransport}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}

View File

@@ -1,18 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import "github.com/spf13/cobra"
// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility
// command — one kept alive for users whose skill predates a refactor. Service
// registration assigns it (e.g. the sheets pre-refactor aliases); both --help
// rendering and unknown-subcommand suggestions read it to separate these
// aliases from the current commands.
const DeprecatedGroupID = "deprecated"
// IsDeprecatedCommand reports whether c was tagged into the deprecated group.
func IsDeprecatedCommand(c *cobra.Command) bool {
return c != nil && c.GroupID == DeprecatedGroupID
}

View File

@@ -9,7 +9,7 @@ import (
"time"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/util"
)
// RetryTransport is an http.RoundTripper that retries on 5xx responses
@@ -24,7 +24,7 @@ func (t *RetryTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return transport.Fallback()
return util.FallbackTransport()
}
func (t *RetryTransport) delay() time.Duration {
@@ -69,7 +69,7 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
if t.Base != nil {
return t.Base.RoundTrip(req)
}
return transport.Fallback().RoundTrip(req)
return util.FallbackTransport().RoundTrip(req)
}
// BuildHeaderTransport is an http.RoundTripper that force-writes the
@@ -87,7 +87,7 @@ func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, err
if t.Base != nil {
return t.Base.RoundTrip(req)
}
return transport.Fallback().RoundTrip(req)
return util.FallbackTransport().RoundTrip(req)
}
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
@@ -100,7 +100,7 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return transport.Fallback()
return util.FallbackTransport()
}
// RoundTrip implements http.RoundTripper.

View File

@@ -332,7 +332,7 @@ func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) {
// TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
// the transport still sets X-Cli-Build and routes the request through
// transport.Fallback rather than panicking. This covers the fallback
// util.FallbackTransport rather than panicking. This covers the fallback
// branch in RoundTrip that is otherwise unreachable with a non-nil Base.
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
var receivedBuild string

View File

@@ -4,7 +4,9 @@
package credential
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -164,9 +166,42 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
if err != nil {
return nil, err
}
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret)
ep := core.ResolveEndpoints(acct.Brand)
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
body, err := json.Marshal(map[string]string{
"app_id": acct.AppID,
"app_secret": acct.AppSecret,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal TAT request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
return &TokenResult{Token: token}, nil
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
}
if result.Code != 0 {
return nil, classifyTATResponseCode(result.Code, result.Msg, string(acct.Brand), acct.AppID)
}
return &TokenResult{Token: result.TenantAccessToken}, nil
}

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/larksuite/cli/internal/core"
)
// FetchTAT performs a single HTTP POST to mint a tenant access token with the
// given credentials. It does not read configuration or keychain, so callers
// that already hold plaintext credentials (e.g. the post-`config init` probe)
// can validate them without a second keychain round-trip.
//
// A non-zero TAT response code means the server inspected the payload and
// rejected the credentials; FetchTAT returns the canonical typed error from
// classifyTATResponseCode — the SAME classification doResolveTAT (and thus
// every token-resolving command) produces, so callers see one consistent
// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
// Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic
// credential rejection apart from upstream/transport noise.
//
// The caller owns the context timeout.
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
ep := core.ResolveEndpoints(brand)
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
body, err := json.Marshal(map[string]string{
"app_id": appID,
"app_secret": appSecret,
})
if err != nil {
return "", fmt.Errorf("failed to marshal TAT request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to parse TAT response: %w", err)
}
if result.Code != 0 {
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
}
return result.TenantAccessToken, nil
}

View File

@@ -1,237 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// stubRoundTripper lets us assert request shape and return canned responses.
type stubRoundTripper struct {
gotReq *http.Request
gotBody string
respCode int
respBody string
err error
}
func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
s.gotReq = req
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
s.gotBody = string(b)
}
if s.err != nil {
return nil, s.err
}
return &http.Response{
StatusCode: s.respCode,
Body: io.NopCloser(strings.NewReader(s.respBody)),
Header: make(http.Header),
}, nil
}
func TestFetchTAT_Success(t *testing.T) {
rt := &stubRoundTripper{
respCode: 200,
respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
}
hc := &http.Client{Transport: rt}
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != "t-abc" {
t.Errorf("token = %q, want t-abc", token)
}
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
t.Errorf("url = %s", rt.gotReq.URL.String())
}
if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
t.Errorf("request body missing credentials: %s", rt.gotBody)
}
}
// 10003 (bad / non-existent app_id, "invalid param") is classified locally by
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
// typed error doResolveTAT (and thus every token-resolving command) returns.
func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
hc := &http.Client{Transport: rt}
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for code 10003")
}
if token != "" {
t.Errorf("token = %q, want empty", token)
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
}
if cfgErr.Category != errs.CategoryConfig {
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != 10003 {
t.Errorf("Code = %d, want 10003", cfgErr.Code)
}
}
// 10014 ("app secret invalid") — the most common real-world rejection (real
// app_id + wrong secret) — is globally mapped in codemeta to
// CategoryConfig / SubtypeInvalidClient via BuildAPIError.
func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 {
t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code)
}
}
// Any non-zero body code is a deterministic server-side rejection, so it
// always yields a typed error (errs.IsTyped). An unrecognized code falls back
// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
// caller still surfaces it rather than silently swallowing.
func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for code 99999")
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
}
}
// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
// must stay UNTYPED so a probe caller treats it as upstream noise and stays
// silent.
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
for _, code := range []int{401, 403, 500, 503} {
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatalf("HTTP %d: expected error", code)
}
if errs.IsTyped(err) {
t.Errorf("HTTP %d: must be UNTYPED (ambiguous), got typed %T %v", code, err, err)
}
}
}
func TestFetchTAT_TransportError_Untyped(t *testing.T) {
sentinel := errors.New("network down")
rt := &stubRoundTripper{err: sentinel}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error")
}
if errs.IsTyped(err) {
t.Errorf("transport error must be UNTYPED, got typed %T", err)
}
if !errors.Is(err, sentinel) {
t.Errorf("error chain missing sentinel: %v", err)
}
}
func TestFetchTAT_ParseError_Untyped(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `not json`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected parse error")
}
if errs.IsTyped(err) {
t.Errorf("parse error must be UNTYPED, got typed %T", err)
}
}
func TestFetchTAT_BrandRouting(t *testing.T) {
tests := []struct {
brand core.LarkBrand
wantURL string
}{
{core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
{core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
}
for _, tc := range tests {
t.Run(string(tc.brand), func(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
hc := &http.Client{Transport: rt}
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
t.Fatal(err)
}
if got := rt.gotReq.URL.String(); got != tc.wantURL {
t.Errorf("url = %s, want %s", got, tc.wantURL)
}
})
}
}
func TestFetchTAT_ContextCanceled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
defer srv.Close()
rt := &urlRewriteRT{base: srv.URL}
hc := &http.Client{Transport: rt}
ctx, cancel := context.WithCancel(context.Background())
cancel() // pre-canceled
_, err := FetchTAT(ctx, hc, core.BrandFeishu, "a", "b")
if err == nil {
t.Fatal("expected error for canceled context")
}
if errs.IsTyped(err) {
t.Errorf("canceled context must be UNTYPED, got typed %T", err)
}
if !errors.Is(err, context.Canceled) {
t.Errorf("error chain missing context.Canceled: %v", err)
}
}
// urlRewriteRT forwards requests to a fixed base URL (test server).
type urlRewriteRT struct{ base string }
func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) {
newURL := r.base + req.URL.Path
req2, err := http.NewRequestWithContext(req.Context(), req.Method, newURL, req.Body)
if err != nil {
return nil, err
}
req2.Header = req.Header
return http.DefaultTransport.RoundTrip(req2)
}

View File

@@ -1,57 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package deprecation carries a process-level notice that the command currently
// being executed is a backward-compatibility alias, kept alive for users whose
// skill predates a refactor. The notice is surfaced in JSON output envelopes via
// output.PendingNotice (wired in cmd/root.go), mirroring internal/skillscheck.
//
// A CLI process runs exactly one shortcut, so a single process-level slot is
// sufficient: the command's Execute records the notice before producing output,
// and the output layer reads it back when building the envelope.
package deprecation
import (
"strings"
"sync/atomic"
)
// Notice describes a deprecated command alias and the current command that
// replaces it. Replacement and Skill are optional.
type Notice struct {
Command string `json:"command"`
Replacement string `json:"replacement,omitempty"`
Skill string `json:"skill,omitempty"`
}
// Message returns a single-line, AI-agent-parseable description of the alias
// plus the canonical fix (update the skill). Mirrors the style of
// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update").
func (n *Notice) Message() string {
var b strings.Builder
b.WriteString(n.Command)
b.WriteString(" is a pre-refactor compatibility alias")
if n.Replacement != "" {
b.WriteString("; use ")
b.WriteString(n.Replacement)
b.WriteString(" instead")
}
if n.Skill != "" {
b.WriteString("; update your ")
b.WriteString(n.Skill)
b.WriteString(" skill, run: lark-cli update")
} else {
b.WriteString("; update your skill, run: lark-cli update")
}
return b.String()
}
// pending stores the latest deprecation notice for the current process.
var pending atomic.Pointer[Notice]
// SetPending stores the notice for consumption by output decorators.
// Pass nil to clear.
func SetPending(n *Notice) { pending.Store(n) }
// GetPending returns the pending deprecation notice, or nil.
func GetPending() *Notice { return pending.Load() }

View File

@@ -1,58 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package deprecation
import "testing"
func TestNoticeMessage(t *testing.T) {
tests := []struct {
name string
notice Notice
want string
}{
{
name: "replacement and skill",
notice: Notice{Command: "+read", Replacement: "+cells-get", Skill: "lark-sheets"},
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill, run: lark-cli update",
},
{
name: "no replacement",
notice: Notice{Command: "+read", Skill: "lark-sheets"},
want: "+read is a pre-refactor compatibility alias; update your lark-sheets skill, run: lark-cli update",
},
{
name: "no skill",
notice: Notice{Command: "+read", Replacement: "+cells-get"},
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your skill, run: lark-cli update",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.notice.Message(); got != tt.want {
t.Errorf("Message() =\n %q\nwant\n %q", got, tt.want)
}
})
}
}
func TestSetGetPending(t *testing.T) {
t.Cleanup(func() { SetPending(nil) })
SetPending(nil)
if got := GetPending(); got != nil {
t.Fatalf("expected nil pending after clear, got %#v", got)
}
n := &Notice{Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets"}
SetPending(n)
got := GetPending()
if got == nil || got.Command != "+write" || got.Replacement != "+cells-set" {
t.Fatalf("GetPending() = %#v, want %#v", got, n)
}
SetPending(nil)
if GetPending() != nil {
t.Fatal("expected nil after clearing")
}
}

View File

@@ -92,18 +92,6 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
base.Troubleshooter = ts
}
}
// Upstream-provided field-level reasons (resp.error.details[].value). Lark
// returns these as free-text reason strings with no machine-readable field
// name (verified for code 190014:
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}),
// so they are lifted into Problem.Hint — the sanctioned free-text recovery
// prompt — rather than fabricated structured params. Lifted before the
// category switch so any classified arm inherits it; the CategoryAPI arm
// below prefers this server detail over the context-free APIHint default.
detailHint := liftErrorDetailValues(resp)
if detailHint != "" {
base.Hint = detailHint
}
switch meta.Category {
case errs.CategoryAuthorization:
@@ -141,11 +129,6 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
Action: action,
}
case errs.CategoryAPI:
// A server-supplied detail (lifted into base.Hint above) wins over the
// context-free APIHint default; only fall back to APIHint when absent.
if base.Hint == "" {
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
}
return &errs.APIError{Problem: base}
default:
// Fail closed: an unrecognized Category routes to InternalError
@@ -230,10 +213,6 @@ func stringFromAny(v any) string {
// per-subtype recovery hint before returning it, so the wire envelope
// emitted via BuildAPIError always carries a hint for known config subtypes.
func buildConfigError(p errs.Problem) *errs.ConfigError {
// Config categories have authoritative recovery guidance, so the curated
// ConfigHint deliberately overrides any server detail lifted into p.Hint
// (the opposite precedence from the CategoryAPI arm, where the lifted
// detail wins).
p.Hint = ConfigHint(p.Subtype)
return &errs.ConfigError{Problem: p}
}
@@ -252,24 +231,6 @@ func ConfigHint(subtype errs.Subtype) string {
return ""
}
// APIHint returns the canonical per-subtype recovery hint for a typed APIError
// emitted via BuildAPIError, for API subtypes whose recovery is context-free.
// Context-specific guidance (e.g. a command's flags, an API's own quota) is
// layered on by the caller after BuildAPIError returns and overrides this.
func APIHint(subtype errs.Subtype) string {
switch subtype {
case errs.SubtypeConflict:
return "retry later and avoid concurrent duplicate requests on the same resource"
case errs.SubtypeCrossTenant:
return "operate on source and target within the same tenant and region/unit"
case errs.SubtypeCrossBrand:
return "operate on source and target within the same brand environment"
case errs.SubtypeQuotaExceeded:
return "reduce the request volume or free quota, then retry after the relevant quota resets"
}
return ""
}
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
missing := extractMissingScopes(resp)
identity := cc.Identity
@@ -278,10 +239,6 @@ func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContex
}
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
// Permission categories have authoritative recovery guidance (scopes to
// grant, console URL), so the curated PermissionHint deliberately overrides
// any server detail lifted into p.Hint (the opposite precedence from the
// CategoryAPI arm, where the lifted detail wins).
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
permErr := &errs.PermissionError{
Problem: p,
@@ -390,32 +347,6 @@ func PermissionHint(missing []string, identity string, subtype errs.Subtype, con
return "check the calling identity has the required scope"
}
// liftErrorDetailValues collects the non-empty resp.error.details[].value reason
// strings and joins them with "; ". Returns "" when the structure is absent or
// carries no non-empty value. The shape (verified for code 190014) is
// {"error":{"details":[{"value":"<reason>"}]}}.
func liftErrorDetailValues(resp map[string]any) string {
errBlock, ok := resp["error"].(map[string]any)
if !ok {
return ""
}
details, ok := errBlock["details"].([]any)
if !ok || len(details) == 0 {
return ""
}
var values []string
for _, d := range details {
m, ok := d.(map[string]any)
if !ok {
continue
}
if v, _ := m["value"].(string); v != "" {
values = append(values, v)
}
}
return strings.Join(values, "; ")
}
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
// Returns nil when the structure is absent.
func extractMissingScopes(resp map[string]any) []string {

View File

@@ -220,111 +220,6 @@ func TestBuildAPIError_TroubleshooterLiftedOnPermissionArm(t *testing.T) {
}
}
// TestBuildAPIError_DetailsLiftedToHintOnAPIArm pins that BuildAPIError lifts
// resp.error.details[].value into Problem.Hint when the response routes to the
// catch-all CategoryAPI arm. The real Lark shape (verified for code 190014) is
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}
// — only a human-readable reason string, no machine-readable field name. It is
// lifted into Hint (sanctioned free-text recovery prompt) rather than fabricated
// structured params.
func TestBuildAPIError_DetailsLiftedToHintOnAPIArm(t *testing.T) {
resp := map[string]any{
"code": 190014,
"msg": "invalid params",
"error": map[string]any{
"details": []any{
map[string]any{"value": "end_time should be later than start_time"},
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if !strings.Contains(p.Hint, "end_time should be later than start_time") {
t.Errorf("Hint = %q, want it to contain the server detail value", p.Hint)
}
}
// TestBuildAPIError_MultipleDetailsJoinedIntoHint pins that multiple non-empty
// detail values are joined with "; " into a single Hint, and empty values are
// skipped.
func TestBuildAPIError_MultipleDetailsJoinedIntoHint(t *testing.T) {
resp := map[string]any{
"code": 190014,
"msg": "invalid params",
"error": map[string]any{
"details": []any{
map[string]any{"value": "first reason"},
map[string]any{"value": ""},
map[string]any{"value": "second reason"},
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if p.Hint != "first reason; second reason" {
t.Errorf("Hint = %q, want %q", p.Hint, "first reason; second reason")
}
}
// TestBuildAPIError_DetailsSkipsNonMapEntries pins that malformed entries in
// the details array (not a JSON object) are skipped rather than panicking, and
// well-formed siblings still surface in the Hint.
func TestBuildAPIError_DetailsSkipsNonMapEntries(t *testing.T) {
resp := map[string]any{
"code": 190014,
"msg": "invalid params",
"error": map[string]any{
"details": []any{
"i am a bare string, not an object",
map[string]any{"value": "the real reason"},
42,
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if p.Hint != "the real reason" {
t.Errorf("Hint = %q, want %q", p.Hint, "the real reason")
}
}
// TestBuildAPIError_DetailsMalformedShapesNoHint pins that a missing error
// block, a non-array details field, and an empty details array all leave the
// Hint untouched (no lifted detail) instead of erroring.
func TestBuildAPIError_DetailsMalformedShapesNoHint(t *testing.T) {
cases := []struct {
name string
resp map[string]any
}{
{"no error block", map[string]any{"code": 190014, "msg": "invalid params"}},
{"details not array", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": "nope"}}},
{"empty details", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{}}}},
{"detail values all empty", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{map[string]any{"value": ""}}}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
// With no liftable detail, the Hint must not echo a server detail.
if strings.Contains(p.Hint, "nope") {
t.Errorf("Hint should not lift a non-array details field, got %q", p.Hint)
}
})
}
}
// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty
// when the upstream response omits it — wire envelope must omit the field.
func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) {

View File

@@ -1,16 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// calendarCodeMeta holds calendar-service Lark code → CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var calendarCodeMeta = map[int]CodeMeta{
190014: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid params (carries a field-level detail lifted into Hint)
}
func init() { mergeCodeMeta(calendarCodeMeta, "calendar") }

View File

@@ -1,39 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
// TestLookupCodeMeta_CalendarCodes pins each calendar-service code registered
// via the codemeta_calendar.go init() merge to its expected
// Category/Subtype/Retryable.
func TestLookupCodeMeta_CalendarCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
// 190014: calendar "invalid params" with a field-level detail
// (error.details[].value) lifted into Hint by BuildAPIError.
{190014, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("code %d not registered in codeMeta", tc.code)
}
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}

View File

@@ -1,17 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// driveCodeMeta holds drive/docs-service Lark code → CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var driveCodeMeta = map[int]CodeMeta{
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
}
func init() { mergeCodeMeta(driveCodeMeta, "drive") }

View File

@@ -1,43 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
// TestLookupCodeMeta_DriveCodes pins each drive-service code registered via the
// codemeta_drive.go init() merge to its expected Category/Subtype/Retryable.
// Each case traces to repo evidence (see codemeta_drive.go comments).
func TestLookupCodeMeta_DriveCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
// 1061044: upload with a nonexistent parent folder token. The drive E2E
// (tests_e2e/drive/2026_06_01_errs_migrate_drive_test.go) drives this
// producer via a nonexistent parent folder → referenced resource missing.
{1061044, errs.CategoryAPI, errs.SubtypeNotFound, false},
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("code %d not registered in codeMeta", tc.code)
}
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}

View File

@@ -1,20 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// mailCodeMeta holds mail-service Lark code -> CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
var mailCodeMeta = map[int]CodeMeta{
1234013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // mailbox not found or not active
1236007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily send count exceeded
1236008: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily external recipient count exceeded
1236009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant daily external recipient count exceeded
1236010: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // mail quota limit
1236013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant storage limit exceeded
}
func init() { mergeCodeMeta(mailCodeMeta, "mail") }

View File

@@ -1,18 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// minutesCodeMeta holds minutes-service Lark code → CodeMeta mappings.
// Only codes whose meaning is stable across minutes endpoints are registered;
// endpoint-specific codes fall back to CategoryAPI via BuildAPIError.
// Command-specific messages, hints, and subtypes are layered on top via
// per-command enrichment.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var minutesCodeMeta = map[int]CodeMeta{
2091005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller lacks edit/read permission for the minute
}
func init() { mergeCodeMeta(minutesCodeMeta, "minutes") }

View File

@@ -70,12 +70,6 @@ func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
}
}
func TestLookupCodeMeta_MinutesEndpointSpecificCode_NotGlobal(t *testing.T) {
if got, ok := LookupCodeMeta(2091001); ok {
t.Fatalf("LookupCodeMeta(2091001) = %+v, want unregistered; minutes endpoints use this code for different failures", got)
}
}
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
got, ok := LookupCodeMeta(20050)
if !ok {

View File

@@ -1,19 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// vcCodeMeta holds vc-service Lark code → CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes (e.g. 124002 "recording still generating", which has no
// precise taxonomy fit) fall back to CategoryAPI via BuildAPIError and rely on
// per-command enrichment for a retry hint.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var vcCodeMeta = map[int]CodeMeta{
121004: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // meeting has no minute file
121005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller is not a participant / lacks view permission
}
func init() { mergeCodeMeta(vcCodeMeta, "vc") }

View File

@@ -14,7 +14,6 @@ import (
"sync/atomic"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/transport"
)
@@ -45,9 +44,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
keyDef, ok := event.Lookup(opts.EventKey)
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown EventKey: %s", opts.EventKey).
WithHint("run `lark-cli event list` to see available keys")
return fmt.Errorf("unknown EventKey: %s\nRun 'lark-cli event list' to see available keys", opts.EventKey)
}
if err := validateParams(keyDef, opts.Params); err != nil {
@@ -83,8 +80,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
if err != nil {
return errs.NewInternalError(errs.SubtypeUnknown,
"event bus handshake failed: %s", err).WithCause(err)
return fmt.Errorf("handshake failed: %w", err)
}
var cleanup func()
@@ -94,11 +90,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
}
cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params)
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"pre-consume failed: %s", err).WithCause(err)
return fmt.Errorf("pre-consume failed: %w", err)
}
}
@@ -138,7 +130,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
if !opts.Quiet {
fmt.Fprintln(errOut, listeningText(opts))
if !opts.IsTTY {
fmt.Fprintln(errOut, stopHintText(opts))
fmt.Fprintln(errOut, stopHintText())
}
}
@@ -160,10 +152,8 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
for _, p := range def.Params {
if p.Required {
if _, ok := params[p.Name]; !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"required param %q missing for EventKey %s", p.Name, def.Key).
WithParam("--param").
WithHint("pass it as --param %s=<value>; run `lark-cli event schema %s` for details", p.Name, def.Key)
return fmt.Errorf("required param %q missing for EventKey %s. Run 'lark-cli event schema %s' for details",
p.Name, def.Key, def.Key)
}
}
}
@@ -179,15 +169,11 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
continue
}
if len(validNames) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown param %q: EventKey %s accepts no params", k, def.Key).
WithParam("--param").
WithHint("run `lark-cli event schema %s` for details", def.Key)
return fmt.Errorf("unknown param %q: EventKey %s accepts no params. Run 'lark-cli event schema %s' for details",
k, def.Key, def.Key)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown param %q for EventKey %s. valid params: %s", k, def.Key, strings.Join(validNames, ", ")).
WithParam("--param").
WithHint("run `lark-cli event schema %s` for details", def.Key)
return fmt.Errorf("unknown param %q for EventKey %s. valid params: %s. Run 'lark-cli event schema %s' for details",
k, def.Key, strings.Join(validNames, ", "), def.Key)
}
return nil
}
@@ -227,11 +213,7 @@ func exitReason(ctx context.Context, emitted int64, opts Options) string {
return "signal"
}
func stopHintText(opts Options) string {
if opts.MaxEvents > 0 || opts.Timeout > 0 {
return "[event] to stop gracefully: send SIGTERM (kill <pid>). " +
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
}
func stopHintText() string {
return "[event] to stop gracefully: send SIGTERM (kill <pid>) or close stdin. " +
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
}

View File

@@ -8,21 +8,17 @@ import (
"fmt"
"github.com/itchyny/gojq"
"github.com/larksuite/cli/errs"
)
// CompileJQ compiles once for hot-path reuse; exported so callers can preflight before side effects.
func CompileJQ(expr string) (*gojq.Code, error) {
query, err := gojq.Parse(expr)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid jq expression: %s", err).WithParam("--jq").WithCause(err)
return nil, fmt.Errorf("invalid jq expression: %w", err)
}
code, err := gojq.Compile(query)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"jq compile error: %s", err).WithParam("--jq").WithCause(err)
return nil, fmt.Errorf("jq compile error: %w", err)
}
return code, nil
}

View File

@@ -50,32 +50,12 @@ func TestListeningText_NonTTY_MaxEventsAndTimeout(t *testing.T) {
}
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
func TestStopHintText_Unbounded(t *testing.T) {
got := stopHintText(Options{})
mustContain := []string{"SIGTERM", "kill -9", "cleanup", "close stdin"}
func TestStopHintText_Content(t *testing.T) {
got := stopHintText()
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
for _, s := range mustContain {
if !bytes.Contains([]byte(got), []byte(s)) {
t.Errorf("stopHintText(unbounded) missing %q; got %q", s, got)
}
}
}
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
func TestStopHintText_Bounded(t *testing.T) {
cases := []Options{
{MaxEvents: 1},
{Timeout: 30 * time.Second},
}
for _, opts := range cases {
got := stopHintText(opts)
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
for _, s := range mustContain {
if !bytes.Contains([]byte(got), []byte(s)) {
t.Errorf("stopHintText(bounded) missing %q; got %q", s, got)
}
}
if bytes.Contains([]byte(got), []byte("close stdin")) {
t.Errorf("stopHintText(bounded) must not contain \"close stdin\"; got %q", got)
t.Errorf("stopHintText missing %q; got %q", s, got)
}
}
}

View File

@@ -5,13 +5,10 @@ package consume
import (
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/errs"
)
func TestCompileJQReportsErrorEarly(t *testing.T) {
@@ -23,16 +20,6 @@ func TestCompileJQReportsErrorEarly(t *testing.T) {
if !strings.Contains(msg, "compile") && !strings.Contains(msg, "parse") && !strings.Contains(msg, "invalid") {
t.Errorf("error should mention compile/parse/invalid, 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 || ve.Param != "--jq" {
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--jq")
}
if errors.Unwrap(err) == nil {
t.Error("compile error should preserve its cause")
}
}
func TestCompileJQReturnsUsableCode(t *testing.T) {

View File

@@ -13,7 +13,6 @@ import (
"sync/atomic"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/vfs"
)
@@ -24,8 +23,7 @@ type Sink interface {
func newSink(opts Options) (Sink, error) {
if opts.OutputDir != "" {
if err := vfs.MkdirAll(opts.OutputDir, 0755); err != nil {
return nil, errs.NewInternalError(errs.SubtypeFileIO,
"create output dir: %s", err).WithCause(err)
return nil, fmt.Errorf("create output dir: %w", err)
}
// PID disambiguates filenames across processes sharing a Dir.
return &DirSink{Dir: opts.OutputDir, pid: os.Getpid()}, nil

View File

@@ -16,7 +16,6 @@ import (
"path/filepath"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
@@ -52,9 +51,10 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
} else {
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
if count > 0 {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
"another event bus is already connected to this app (%d active connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
WithHint("use `lark-cli event status` to check, or `lark-cli event stop` on the other machine first")
return nil, fmt.Errorf("another event bus is already connected to this app "+
"(%d active connection(s) detected via API).\n"+
"Only one bus should run globally to avoid duplicate event delivery.\n"+
"Use 'lark-cli event status' to check, or 'lark-cli event stop' on the other machine first", count)
}
}
} else {
@@ -65,10 +65,8 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
pid, forkErr := forkBus(tr, appID, profileName, domain)
if forkErr != nil && !errors.Is(forkErr, lockfile.ErrHeld) {
eventsRoot := filepath.Join(core.GetConfigDir(), "events")
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"failed to start event bus daemon: %s", forkErr).
WithCause(forkErr).
WithHint("check disk space, permissions on %s, and `lark-cli doctor`", eventsRoot)
return nil, fmt.Errorf("failed to start event bus daemon: %w\n"+
"Check: disk space, permissions on %s, and 'lark-cli doctor'", forkErr, eventsRoot)
}
if pid > 0 {
announceForkedBus(errOut, pid)
@@ -90,9 +88,7 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
fmt.Fprintln(errOut, "[event] event bus exited unexpectedly.")
fmt.Fprintln(errOut, "[event] please check app credentials (lark-cli config show) and retry.")
fmt.Fprintf(errOut, "[event] logs: %s\n", logPath)
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"failed to connect to event bus within %v (app=%s)", dialTimeout, appID).
WithHint("check app credentials (`lark-cli config show`) and retry; bus logs: %s", logPath)
return nil, fmt.Errorf("failed to connect to event bus within %v (app=%s)", dialTimeout, appID)
}
// probeAndDialBus distinguishes a healthy bus from a mid-shutdown listener via StatusQuery first.

View File

@@ -1,99 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
// failDialTransport refuses every dial so EnsureBus falls through to the
// remote-connection check without a local bus.
type failDialTransport struct{}
func (failDialTransport) Listen(string) (net.Listener, error) { return nil, errors.New("no listen") }
func (failDialTransport) Dial(string) (net.Conn, error) { return nil, errors.New("refused") }
func (failDialTransport) Address(string) string { return "guard-test-addr" }
func (failDialTransport) Cleanup(string) {}
// remoteBusyAPIClient reports active remote WebSocket connections.
type remoteBusyAPIClient struct{ count int }
func (c remoteBusyAPIClient) CallAPI(context.Context, string, string, interface{}) (json.RawMessage, error) {
return json.RawMessage(`{"code":0,"msg":"ok","data":{"online_instance_cnt":` +
strconv.Itoa(c.count) + `}}`), nil
}
func TestEnsureBus_RemoteBusAlreadyConnectedIsFailedPrecondition(t *testing.T) {
conn, err := EnsureBus(context.Background(), failDialTransport{},
"cli_guard_test", "", "", remoteBusyAPIClient{count: 2}, io.Discard)
if conn != nil {
t.Fatal("expected nil conn when a remote bus is already connected")
}
if err == nil {
t.Fatal("expected single-bus guard error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if !strings.Contains(ve.Hint, "event stop") {
t.Errorf("hint should point at `event stop`, got: %q", ve.Hint)
}
}
func TestRun_UnknownEventKeyIsTypedValidation(t *testing.T) {
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
EventKey: "bogus.run.key",
ErrOut: io.Discard,
})
if err == nil {
t.Fatal("expected unknown EventKey error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(ve.Hint, "event list") {
t.Errorf("hint should point at `event list`, got: %q", ve.Hint)
}
}
func TestRun_InvalidJQFailsBeforeAnySideEffect(t *testing.T) {
event.RegisterKey(event.KeyDefinition{
Key: "consume.runtest.jq",
EventType: "consume.runtest.jq_v1",
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{}`)}},
})
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
EventKey: "consume.runtest.jq",
JQExpr: "[invalid{{{",
ErrOut: io.Discard,
})
if err == nil {
t.Fatal("expected jq validation error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Param != "--jq" {
t.Errorf("param = %q, want %q", ve.Param, "--jq")
}
}

View File

@@ -1,64 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
func requireParamValidationError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected validation error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
}
if ve.Hint == "" {
t.Error("param validation error should hint at `lark-cli event schema`")
}
}
func TestValidateParams_RequiredMissing(t *testing.T) {
def := &event.KeyDefinition{
Key: "x.test",
Params: []event.ParamDef{{Name: "chat_id", Required: true}},
}
requireParamValidationError(t, validateParams(def, map[string]string{}))
}
func TestValidateParams_UnknownParam(t *testing.T) {
def := &event.KeyDefinition{
Key: "x.test",
Params: []event.ParamDef{{Name: "chat_id"}},
}
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
}
func TestValidateParams_UnknownParamNoParamsAccepted(t *testing.T) {
def := &event.KeyDefinition{Key: "x.test"}
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
}
func TestValidateParams_DefaultAppliedAndValidPasses(t *testing.T) {
def := &event.KeyDefinition{
Key: "x.test",
Params: []event.ParamDef{{Name: "mode", Required: true, Default: "all"}},
}
params := map[string]string{}
if err := validateParams(def, params); err != nil {
t.Fatalf("default should satisfy required param, got: %v", err)
}
if params["mode"] != "all" {
t.Errorf("default not applied, params=%v", params)
}
}

View File

@@ -170,28 +170,6 @@ func ErrBare(code int) *ExitError {
return &ExitError{Code: code}
}
// PartialFailureError is the exit signal for a batch / multi-status command that
// has already written an ok:false result envelope to stdout. The per-item
// outcomes are the primary, machine-readable output and live on stdout, so the
// dispatcher sets only the exit code and writes nothing to stderr.
//
// It is deliberately distinct from ErrBare (the predicate silent-exit signal)
// so the predicate contract stays narrow, and from a typed *errs.XxxError
// (which owns the stderr error envelope): a partial failure is a result, not an
// error envelope.
type PartialFailureError struct {
Code int
}
func (e *PartialFailureError) Error() string {
return fmt.Sprintf("partial failure (exit %d)", e.Code)
}
// PartialFailure builds the partial-failure exit signal with the given code.
func PartialFailure(code int) *PartialFailureError {
return &PartialFailureError{Code: code}
}
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
// Each typed error owns its wire shape via its own struct tags: Problem fields
// are promoted to the top level through embedding, and extension fields

View File

@@ -61,10 +61,6 @@ func ExitCodeOf(err error) int {
if _, ok := errs.ProblemOf(err); ok {
return ExitCodeForCategory(errs.CategoryOf(err))
}
var pfErr *PartialFailureError
if errors.As(err, &pfErr) {
return pfErr.Code
}
var exitErr *ExitError
if errors.As(err, &exitErr) {
return exitErr.Code

View File

@@ -1,15 +1,14 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package transport owns how the CLI assembles its outbound HTTP transport: the
// shared base RoundTripper (Shared/Fallback/NewHTTPClient), the LARK_CLI_NO_PROXY
// direct-egress clone, and the ~/.lark-cli/proxy_config.json proxy-plugin mode.
// Package proxyplugin implements the ~/.lark-cli/proxy_config.json based security proxy plugin mode.
//
// Proxy-plugin mode forces all outbound HTTP(S) requests through a fixed loopback
// proxy, optionally trusting an extra root CA PEM bundle for TLS-inspection
// proxies, and fails closed on misconfiguration. Environment variables override
// matching values from proxy_config.json.
package transport
// It supports:
// - forcing all outbound HTTP(S) requests through a fixed HTTP proxy
// - trusting an additional root CA PEM bundle for MITM/inspection proxies
//
// Environment variables override matching values from proxy_config.json.
package proxyplugin
import (
"encoding/json"
@@ -223,6 +222,21 @@ func (c *Config) proxyURL() (*url.URL, error) {
return u, nil
}
// redactProxyURL masks userinfo (username:password) in a proxy URL.
// Handles both scheme-prefixed ("http://user:pass@host") and bare formats.
func redactProxyURL(raw string) string {
u, err := url.Parse(raw)
if err == nil && u.User != nil {
u.User = url.User("***")
return u.String()
}
// Fallback: handle "user:pass@proxy:8080"
if at := strings.LastIndex(raw, "@"); at > 0 {
return "***@" + raw[at+1:]
}
return raw
}
// ApplyToTransport clones base and applies proxy plugin settings to the clone.
// Caller owns the returned *http.Transport.
func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) {

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
package proxyplugin
import (
"net/http"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
package proxyplugin
import (
"crypto/tls"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
package proxyplugin
import (
"crypto/rand"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
package proxyplugin
import (
"fmt"
@@ -16,7 +16,7 @@ var proxyPluginTransport = sync.OnceValue(buildProxyPluginTransport)
// cachedBlockedTransport is a fail-closed transport cached on first use when
// the proxy plugin config exists but is invalid. This avoids cloning
// http.DefaultTransport on every pluginTransport call.
// http.DefaultTransport on every SharedTransport call.
var cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
func buildBlockedTransport() http.RoundTripper {
@@ -28,7 +28,7 @@ func buildProxyPluginTransport() http.RoundTripper {
if !ok {
// Cannot clone the stdlib transport. Fail closed with a concrete
// *http.Transport (not a bare RoundTripper) so downcasting callers such
// as Fallback cannot silently degrade this into a
// as util.FallbackTransport cannot silently degrade this into a
// direct-egress transport.
return failClosedTransport(fmt.Errorf("proxy plugin transport unavailable: http.DefaultTransport is %T, want *http.Transport", http.DefaultTransport))
}
@@ -51,9 +51,9 @@ func buildProxyPluginTransport() http.RoundTripper {
return t
}
// pluginTransport returns the proxy plugin transport when proxy plugin mode is
// SharedTransport returns the proxy plugin transport when proxy plugin mode is
// configured. The bool return is false when the plugin is not configured or not enabled.
func pluginTransport() (http.RoundTripper, bool) {
func SharedTransport() (http.RoundTripper, bool) {
cfg, err := Load()
if err != nil {
return cachedBlockedTransport(), true
@@ -68,7 +68,7 @@ func pluginTransport() (http.RoundTripper, bool) {
// err. It clones http.DefaultTransport when possible (preserving dial/timeout
// tuning); otherwise it builds a minimal transport. Returning a concrete
// *http.Transport (rather than a bare RoundTripper) is required so downcasting
// callers such as Fallback cannot silently degrade a fail-closed
// callers such as util.FallbackTransport cannot silently degrade a fail-closed
// signal into a direct-egress transport.
func failClosedTransport(err error) *http.Transport {
if def, ok := http.DefaultTransport.(*http.Transport); ok {

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
package proxyplugin
import (
"io"
@@ -20,21 +20,21 @@ func resetProxyPluginState() {
cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
}
func TestPluginTransport_NotConfigured(t *testing.T) {
func TestSharedTransport_NotConfigured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
tr, ok := pluginTransport()
tr, ok := SharedTransport()
if ok {
t.Fatalf("pluginTransport() ok = true, want false")
t.Fatalf("SharedTransport() ok = true, want false")
}
if tr != nil {
t.Fatalf("pluginTransport() transport = %T, want nil", tr)
t.Fatalf("SharedTransport() transport = %T, want nil", tr)
}
}
func TestPluginTransport_EnabledReturnsFixedProxy(t *testing.T) {
func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
@@ -46,13 +46,13 @@ func TestPluginTransport_EnabledReturnsFixedProxy(t *testing.T) {
"LARKSUITE_CLI_CA_PATH": ""
}`), 0600)
rt, ok := pluginTransport()
rt, ok := SharedTransport()
if !ok {
t.Fatal("pluginTransport() ok = false, want true")
t.Fatal("SharedTransport() ok = false, want true")
}
tr, ok := rt.(*http.Transport)
if !ok {
t.Fatalf("pluginTransport() = %T, want *http.Transport", rt)
t.Fatalf("SharedTransport() = %T, want *http.Transport", rt)
}
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err != nil {
@@ -63,7 +63,7 @@ func TestPluginTransport_EnabledReturnsFixedProxy(t *testing.T) {
}
}
func TestPluginTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *testing.T) {
func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
@@ -72,12 +72,12 @@ func TestPluginTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *test
writeFile(t, Path(), []byte(`{`), 0600)
rt, ok := pluginTransport()
rt, ok := SharedTransport()
if !ok {
t.Fatal("pluginTransport() ok = false, want true")
t.Fatal("SharedTransport() ok = false, want true")
}
if rt == http.DefaultTransport {
t.Fatalf("pluginTransport() returned http.DefaultTransport, want fail-closed transport")
t.Fatalf("SharedTransport() returned http.DefaultTransport, want fail-closed transport")
}
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err == nil {
@@ -88,23 +88,23 @@ func TestPluginTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *test
}
}
func TestPluginTransport_InvalidConfigReturnsCachedInstance(t *testing.T) {
func TestSharedTransport_InvalidConfigReturnsCachedInstance(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
writeFile(t, Path(), []byte(`{`), 0600)
a, ok := pluginTransport()
a, ok := SharedTransport()
if !ok {
t.Fatal("pluginTransport() ok = false, want true")
t.Fatal("SharedTransport() ok = false, want true")
}
b, ok := pluginTransport()
b, ok := SharedTransport()
if !ok {
t.Fatal("pluginTransport() ok = false, want true")
t.Fatal("SharedTransport() ok = false, want true")
}
if a != b {
t.Fatalf("pluginTransport() returned different instances on repeated calls; blocked transport must be cached")
t.Fatalf("SharedTransport() returned different instances on repeated calls; blocked transport must be cached")
}
}
@@ -148,13 +148,13 @@ func TestBuildProxyPluginTransport_NonTransportDefaultFailsClosed(t *testing.T)
}
}
// TestPluginTransport_InvalidConfigBlockerIsConcreteTransport guards the
// fail-closed invariant that Fallback relies on: even when
// TestSharedTransport_InvalidConfigBlockerIsConcreteTransport guards the
// fail-closed invariant that util.FallbackTransport relies on: even when
// http.DefaultTransport is not an *http.Transport, an invalid proxy config must
// produce a blocked transport that is itself a concrete *http.Transport. If it
// were a bare RoundTripper, Fallback would downcast-fail and
// were a bare RoundTripper, util.FallbackTransport would downcast-fail and
// silently degrade it into a direct-egress transport.
func TestPluginTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
func TestSharedTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
@@ -163,12 +163,12 @@ func TestPluginTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
writeFile(t, Path(), []byte(`{`), 0600)
rt, ok := pluginTransport()
rt, ok := SharedTransport()
if !ok {
t.Fatal("pluginTransport() ok = false, want true")
t.Fatal("SharedTransport() ok = false, want true")
}
if _, isTransport := rt.(*http.Transport); !isTransport {
t.Fatalf("pluginTransport() blocked transport = %T, want *http.Transport so Fallback cannot degrade it to direct egress", rt)
t.Fatalf("SharedTransport() blocked transport = %T, want *http.Transport so FallbackTransport cannot degrade it to direct egress", rt)
}
// Must remain fail-closed.
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})

View File

@@ -231,9 +231,14 @@ func TestLoadAutoApproveSet(t *testing.T) {
t.Fatal("expected non-empty auto-approve set")
}
// From scope_priorities.json recommend=="true"
// From scope_overrides.json allow list
if !aaSet["calendar:calendar.event:create"] {
t.Error("expected calendar:calendar.event:create in auto-approve set (from allow list)")
}
// Verify allow list entries are present
if !aaSet["sheets:spreadsheet:read"] {
t.Error("expected sheets:spreadsheet:read in auto-approve set (recommend=true in priorities)")
t.Error("expected sheets:spreadsheet:read in auto-approve set (from allow list)")
}
t.Logf("Auto-approve set has %d scopes", len(aaSet))
@@ -252,10 +257,16 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
allowSet := LoadOverrideAutoApproveAllow()
// recommend.allow in scope_overrides.json is intentionally empty:
// no scopes are special-cased into the auto-approve set anymore.
if len(allowSet) != 0 {
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
if len(allowSet) == 0 {
t.Fatal("expected non-empty override allow set")
}
// Known entries from scope_overrides.json
if !allowSet["calendar:calendar.event:create"] {
t.Error("expected calendar:calendar.event:create in allow set")
}
if !allowSet["mail:event"] {
t.Error("expected mail:event in allow set")
}
}
@@ -266,9 +277,9 @@ func TestLoadOverrideAutoApproveDeny(t *testing.T) {
}
func TestIsAutoApproveScope(t *testing.T) {
// Known auto-approve scope (recommend=true in scope_priorities.json)
if !IsAutoApproveScope("sheets:spreadsheet:read") {
t.Error("expected sheets:spreadsheet:read to be auto-approve")
// Known auto-approve scope (in allow list)
if !IsAutoApproveScope("calendar:calendar.event:create") {
t.Error("expected calendar:calendar.event:create to be auto-approve")
}
// Completely unknown scope
@@ -279,8 +290,9 @@ func TestIsAutoApproveScope(t *testing.T) {
func TestFilterAutoApproveScopes(t *testing.T) {
scopes := []string{
"sheets:spreadsheet:read", // auto-approve (recommend=true in priorities)
"zzz:unknown:scope", // not in auto-approve
"calendar:calendar.event:create", // auto-approve (in allow list)
"zzz:unknown:scope", // not in auto-approve
"sheets:spreadsheet:read", // auto-approve (in allow list)
}
result := FilterAutoApproveScopes(scopes)
@@ -288,10 +300,10 @@ func TestFilterAutoApproveScopes(t *testing.T) {
t.Fatal("expected at least 1 auto-approve scope in result")
}
// Check that sheets:spreadsheet:read is included
// Check that calendar:calendar.event:create is included
found := false
for _, s := range result {
if s == "sheets:spreadsheet:read" {
if s == "calendar:calendar.event:create" {
found = true
}
// Ensure unknown scopes are not included
@@ -300,7 +312,7 @@ func TestFilterAutoApproveScopes(t *testing.T) {
}
}
if !found {
t.Error("expected sheets:spreadsheet:read in result")
t.Error("expected calendar:calendar.event:create in result")
}
}

View File

@@ -17,7 +17,7 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
@@ -181,7 +181,7 @@ func saveCachedMerged(data []byte, meta CacheMeta) error {
func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) {
// Route through the shared proxy-plugin-aware transport so remote API
// definition fetches honor proxy plugin mode instead of bypassing it.
client := transport.NewHTTPClient(fetchTimeout)
client := util.NewHTTPClient(fetchTimeout)
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
if err != nil {
return nil, nil, err

View File

@@ -12,7 +12,25 @@
"vc:meeting.meetingevent:read": 75
},
"recommend": {
"allow": [],
"allow": [
"calendar:calendar.event:create",
"calendar:calendar.event:delete",
"calendar:calendar.event:read",
"calendar:calendar.event:update",
"calendar:calendar.free_busy:read",
"calendar:calendar:create",
"calendar:calendar:delete",
"calendar:calendar:read",
"calendar:calendar:update",
"contact:user.basic_profile:readonly",
"mail:event",
"mail:user_mailbox.mail_contact:read",
"mail:user_mailbox.mail_contact:write",
"mail:user_mailbox.message.address:read",
"mail:user_mailbox.message.body:read",
"mail:user_mailbox.message.subject:read",
"mail:user_mailbox.message:readonly"
],
"deny": [
"im:chat",
"im:message.send_as_user"

View File

@@ -10,13 +10,10 @@ import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os/exec"
"strings"
"time"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/vfs"
)
@@ -40,15 +37,9 @@ const (
)
const (
npmInstallTimeout = 10 * time.Minute
skillsUpdateTimeout = 2 * time.Minute
skillsIndexMaxBodySize = 1 << 20
verifyTimeout = 10 * time.Second
)
var (
skillsIndexFetchTimeout = 10 * time.Second
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
npmInstallTimeout = 10 * time.Minute
skillsUpdateTimeout = 2 * time.Minute
verifyTimeout = 10 * time.Second
)
// DetectResult holds installation detection results.
@@ -92,7 +83,6 @@ func (r *NpmResult) CombinedOutput() string {
type Updater struct {
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
SkillsIndexFetchOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error
RestoreAvailableOverride func() bool
@@ -163,53 +153,6 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r
}
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
if u.SkillsIndexFetchOverride != nil {
return u.SkillsIndexFetchOverride()
}
r := &NpmResult{}
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
if err != nil {
r.Err = err
return r
}
client := transport.NewHTTPClient(0)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" {
return fmt.Errorf("official skills index redirected to non-HTTPS URL: %s", req.URL.Redacted())
}
return nil
}
resp, err := client.Do(req)
if err != nil {
r.Err = err
return r
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
r.Err = fmt.Errorf("official skills index returned HTTP %d", resp.StatusCode)
return r
}
limited := io.LimitReader(resp.Body, skillsIndexMaxBodySize+1)
if _, err := io.Copy(&r.Stdout, limited); err != nil {
r.Err = err
return r
}
if r.Stdout.Len() > skillsIndexMaxBodySize {
r.Stdout.Reset()
r.Err = fmt.Errorf("official skills index exceeds %d bytes", skillsIndexMaxBodySize)
return r
}
return r
}
func (u *Updater) ListOfficialSkills() *NpmResult {
r := u.runSkillsListOfficial("https://open.feishu.cn")
if r.Err != nil {
@@ -222,10 +165,6 @@ func (u *Updater) ListGlobalSkills() *NpmResult {
return u.runSkillsListGlobal()
}
func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
return u.runSkillsCommand("-y", "skills", "ls", "-g", "--json")
}
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
if r.Err != nil {

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