Compare commits

..

108 Commits

Author SHA1 Message Date
xiongyuanwen-byted
d2aa27dac8 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-refactor
# Conflicts:
#	internal/cmdutil/factory_default.go
#	internal/transport/warn_test.go
#	internal/util/proxy.go
2026-06-02 17:53:17 +08:00
xiongyuanwen-byted
3bca545796 docs(sheets): sync skill description from spec (cloud-drive alias, lark-drive search, doubao routing) 2026-06-02 17:05:03 +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
1274 changed files with 21097 additions and 178700 deletions

30
.github/CODEOWNERS vendored
View File

@@ -1,30 +0,0 @@
/internal/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1
/skills/lark-approval/
/skills/lark-apps/
/skills/lark-attendance/
/skills/lark-base/
/skills/lark-calendar/
/skills/lark-contact/
/skills/lark-doc/
/skills/lark-drive/
/skills/lark-event/
/skills/lark-im/
/skills/lark-mail/
/skills/lark-markdown/
/skills/lark-minutes/
/skills/lark-okr/
/skills/lark-openapi-explorer/
/skills/lark-shared/
/skills/lark-sheets/
/skills/lark-skill-maker/
/skills/lark-slides/
/skills/lark-task/
/skills/lark-vc/
/skills/lark-vc-agent/
/skills/lark-whiteboard/
/skills/lark-wiki/
/skills/lark-workflow-meeting-summary/
/skills/lark-workflow-standup-report/

View File

@@ -10,6 +10,8 @@ on:
permissions:
contents: read
actions: read
checks: write
pull-requests: write
jobs:
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
@@ -78,47 +80,10 @@ jobs:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Resolve changed-from baseline
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
- name: Run errs/ lint guards (lintcheck)
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
deterministic-gate:
needs: fast-gate
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Resolve changed-from baseline
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Run CLI deterministic gate
run: make quality-gate
- name: Upload quality gate facts
if: ${{ always() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: quality-gate-facts-${{ github.event.pull_request.base.sha }}-${{ github.event.pull_request.head.sha }}
path: .tmp/quality-gate/facts.json
if-no-files-found: error
retention-days: 7
run: go run -C lint . ..
coverage:
needs: fast-gate
@@ -138,7 +103,6 @@ jobs:
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
with:
files: coverage.txt
@@ -220,7 +184,7 @@ jobs:
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint, deterministic-gate]
needs: [unit-test, lint]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
@@ -241,12 +205,9 @@ jobs:
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint, deterministic-gate]
needs: [unit-test, lint]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
@@ -293,9 +254,6 @@ jobs:
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
security:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -333,7 +291,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -345,7 +303,6 @@ jobs:
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
@@ -361,7 +318,6 @@ jobs:
"${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.deterministic-gate.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \
"${{ needs.e2e-dry-run.result }}" \

View File

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

7
.gitignore vendored
View File

@@ -7,11 +7,6 @@ bin/
# Node
node_modules/
# Python (skill-bundled helper scripts)
__pycache__/
*.py[cod]
*$py.class
# OS
.DS_Store
@@ -40,8 +35,6 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
.lark-slides/
/notes/
/minutes/
internal/registry/meta_data.json
cmd/api/download.bin
app.log

View File

@@ -29,11 +29,11 @@ linters:
- unused # checks for unused constants, variables, functions and types
- depguard # blocks forbidden package imports
- forbidigo # forbids specific function calls
- errorlint # enforces error wrapping (%w) and errors.Is/As over == and type asserts
# To enable later after fixing existing issues:
# - errcheck # checks for unchecked errors
# - errname # checks that error types are named XxxError
# - errorlint # checks error wrapping best practices
# - gosec # security-oriented linter
# - misspell # finds commonly misspelled English words
# - staticcheck # comprehensive static analysis
@@ -49,47 +49,24 @@ linters:
- gocritic
- depguard
- forbidigo
- errorlint # tests legitimately do identity (==) and concrete type-assert checks
# forbidigo runs repo-wide (minus the boundaries below) so errs-no-bare-wrap
# has no gap. The framework bans (os/vfs, raw HTTP, fmt.Print, filepath,
# log) stay scoped to shortcuts/ + internal/ + config/auth/service via the
# next rule; elsewhere only errs-no-bare-wrap fires.
- path-except: (shortcuts/|internal/|cmd/|events/)
linters:
- forbidigo
# Paths that run forbidigo. Add an entry when a path joins one of
# the rules below.
- path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/)
text: (vfs|IOStreams|ctx\.Out|shortcuts-no-raw-http|filepath functions|os\.Exit|structured error return)
linters:
- forbidigo
- path: internal/vfs/
linters:
- forbidigo
# internal/gen build-time generators (standalone `package main` run via
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
# impossible here: a structured error return needs os.Exit (also banned),
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
- path: shortcuts/.*/internal/gen/
linters:
- forbidigo
# internal/qualitygate/cmd contains standalone CI tools. Their main
# entrypoints legitimately own process exit codes and stdio, matching the
# old tools/ layout before these packages moved under internal/.
- path: internal/qualitygate/cmd/[^/]+/main\.go$
linters:
- forbidigo
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer.
- path-except: shortcuts/
text: shortcuts-no-raw-http
linters:
- forbidigo
# errs-no-bare-wrap enforced across every command/wire boundary by
# structural prefix, so any future business domain or command is covered
# without editing an allowlist. Genuine intermediate wraps inside these
# paths use //nolint:forbidigo with a reason.
- path-except: (cmd/|shortcuts/|events/)
text: errs-no-bare-wrap
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go)
text: errs-typed-only
linters:
- forbidigo
@@ -110,12 +87,13 @@ linters:
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
forbidigo:
forbid:
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
# ── legacy output.Err* helpers banned on migrated paths ──
# output.ErrBare is intentionally not listed — it is the predicate-
# command silent-exit signal, outside the typed envelope contract.
- pattern: output\.(ErrValidation|ErrAuth|ErrNetwork|ErrAPI|ErrWithHint|Errorf)\b
msg: >-
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
wrap a cause with .WithCause(err). Genuine intermediate wraps:
//nolint:forbidigo with a reason.
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── 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

@@ -17,7 +17,6 @@ builds:
goarch:
- amd64
- arm64
- riscv64
archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"

View File

@@ -11,7 +11,7 @@
```bash
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make unit-test # Required before PR (runs with -race)
make test # Full: vet + unit + integration
```
@@ -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,278 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.57] - 2026-06-23
### Features
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
- **base**: Support record comments (#1043)
- **search**: Surface search API notices (#1413)
### Bug Fixes
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
- **meta**: Backfill enum value descriptions from options (#1541)
- **cli**: Add missing CLI headers for git credential helper (#1539)
### Documentation
- **doc**: Refine rich block, path, and block ID guidance (#1508)
- **mail**: Trim lark-mail skill context (#1527)
- **drive**: Add permission governance workflow guidance (#1292)
### Build
- **ci**: Bind semantic review to workflow run head (#1551)
## [v1.0.56] - 2026-06-18
### Features
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
### Bug Fixes
- **api**: Align API success envelopes (#1489)
- **base**: Reject out-of-range pagination flags (#1495)
### Refactor
- Retire legacy error envelopes and enforce typed contract (#1449)
### Documentation
- **skills**: Soften lark-doc style guidance (#1463)
### Build
- Add CI quality gate with semantic review
## [v1.0.55] - 2026-06-16
### Features
- **vc**: Support agent meeting event workflows (#1483)
- **drive**: Support exporting Base structure snapshots (#1481)
- **doc**: Add docx cover resource commands (#1468)
- **doc**: Support `lang` for docx fetch v2 (#1459)
- **event**: Optimize subscription precheck, links, and consumer guard (#1447)
### Bug Fixes
- **drive**: Validate drive import folder target (#1485)
## [v1.0.54] - 2026-06-15
### Features
- **mail**: Auto-attach default signature on send/reply/forward (#1415)
- **drive**: Support `original_creator_ids` filter in search (#1046)
- **cli**: Simplify proxy plugin warning and gate it on TTY (#1448)
### Bug Fixes
- **doc**: Fix docs fetch and update ergonomics (#1466)
- **vfs**: Reject blank local paths (#1460)
- **vfs**: Reject Windows absolute paths cross-platform (#1401)
- **event**: Clarify remote bus blocker recovery (#1454)
### Refactor
- Converge command pipelines onto a typed metadata model + catalog (#1191)
### Documentation
- **im**: Document `@mention` format per message type (text/post/card) (#1419)
- **doc**: Clarify lark-doc create title guidance (#1474)
- **skills**: Add rename prompt for import without `--name` (#1461)
- **apps**: Drop Miaoda brand word from apps command help text (#1399)
## [v1.0.53] - 2026-06-12
### Features
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
- **auth**: Add `--json` flag support to auth subcommands (#1431)
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
### Bug Fixes
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
### Documentation
- **skills**: Optimize whiteboard skill (#1371)
- **skills**: Optimize okr skill (#1368)
## [v1.0.52] - 2026-06-11
### Features
- **events**: Per-resource subscription identity + Match hook (#1185)
- **apps**: Emit typed error envelopes across the apps domain (#1288)
- **wiki**: Emit typed error envelopes across the wiki domain (#1350)
- **im**: Add `--chat-modes` filter to chat search (#1317)
- **apps**: Exclude `.git` directory from `+html-publish` package (#1396)
- **build**: Support riscv64 prebuilt binaries in release and install pipeline
### Bug Fixes
- **apps**: Support git credential dry-run (#1390)
- **whiteboard**: Fix parsing empty whiteboard content (#1391)
- **build**: Make `-race` flag arch-conditional to support riscv64
### Documentation
- **im**: Document `chat.user_setting` batch_query/batch_update (#1339)
- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294)
- **skills**: Optimize lark-drive skill routing (#1284)
- **skills**: Expand cite user guidance and fix typos (#1394)
## [v1.0.51] - 2026-06-10
### Features
- **apps**: Support multi dev modes (#1175)
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
- **base**: Configure initial base table schema (#1377)
- **vc**: Add recording event support (#1369)
- **minutes**: Replace words for transcript (#1372)
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
- **slides**: Emit typed error envelopes across the slides domain (#1349)
### Documentation
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
## [v1.0.50] - 2026-06-09
### Features
- **doc**: Emit typed error envelopes across the doc domain (#1346)
- **event**: Emit typed error envelopes across the event domain (#1289)
- **contact**: Emit typed error envelopes across the contact domain (#1287)
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
- **cli**: Adjust agent timeout hint output conditions (#1328)
### Bug Fixes
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
- **slides**: Build create URL locally instead of drive metas call (#1329)
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
### Documentation
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
- **doc**: Document `<folder-manager>` resource block (#1168)
- **drive**: Add drive comment location guidance (#1258)
## [v1.0.49] - 2026-06-08
### Features
- **events**: Add whiteboard event domain with per-board subscription (#1265)
- **im**: Support feed group (#1102)
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
- **im**: Format feed group error handling (#1308)
- **im**: Return typed error envelopes across the im domain (#1230)
- **base**: Emit typed error envelopes across the base domain (#1248)
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
- **task**: Emit typed error envelopes across the task domain (#1231)
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
- **markdown**: Harden create upload failures (#1325)
- **drive**: Harden inspect shortcut failures (#1324)
- **slides**: Add IconPark lookup for Lark slides (#1123)
- **doc**: Remove docs v1 API (#1291)
- **cli**: Add `skills` command to read embedded skill content (#1318)
- **cli**: Fetch official skills index (#1301)
- **shared**: Document relative-path-only file arguments (#1319)
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
### Bug Fixes
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
- **drive**: Use docs secure label read scope (#1281)
### Documentation
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
- **skills**: Tighten drive and markdown guardrails (#1326)
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
- **markdown**: Add markdown domain template (#1293)
- **markdown**: Improve lark-markdown skill guidance (#1279)
- **doc**: Improve lark-doc skill guidance (#1283)
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
## [v1.0.48] - 2026-06-04
### Features
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
- **contact**: Add contact skill domain guidance (#1144)
### Bug Fixes
- **skills**: Use JSON skills list during update (#1251)
### Documentation
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
- **vc-agent**: Require explicit leave request (#1260)
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
## [v1.0.47] - 2026-06-03
### Features
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
- **base**: Add base block shortcuts (#1044)
- **im**: Complete card message format (#1198)
- **im**: Improve markdown guidance for messages (#1237)
- **vc**: Forward invite call-id on meeting join (#1243)
- **drive**: Emit typed error envelopes across the drive domain (#1205)
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
- **wiki**: Support `appid` member type (#1235)
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
- **config**: Validate credentials after `config init` (#1151)
### Bug Fixes
- **skills**: Recover empty fallback for skills to update (#1233)
## [v1.0.46] - 2026-06-02
### Features
- **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
@@ -1236,18 +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.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
[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

@@ -5,24 +5,10 @@ BINARY := lark-cli
MODULE := github.com/larksuite/cli
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
DATE := $(shell date +%Y-%m-%d)
NODE ?= node
QUALITY_GATE_CHANGED_FROM ?= $(shell bash scripts/resolve-changed-from.sh)
QUALITY_GATE_CHANGED_FROM_RESOLVED = $(if $(strip $(QUALITY_GATE_CHANGED_FROM)),$(QUALITY_GATE_CHANGED_FROM),$(shell bash scripts/resolve-changed-from.sh))
QUALITY_GATE_DIR ?= .tmp/quality-gate
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
# The repository's Go 1.23 CI toolchain does not support -race on riscv64.
# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`)
# over `go env GOARCH`, because command-line make variables are not visible to
# $(shell ...).
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
all: test
@@ -46,15 +32,9 @@ fmt-check:
exit 1; \
fi
script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
go test -race -gcflags="all=-N -l" -count=1 \
./cmd/... ./internal/... ./shortcuts/... ./extension/...
# examples-build keeps the shipped plugin-SDK examples compilable. If this
@@ -66,30 +46,7 @@ examples-build:
integration-test: build
go test -v -count=1 ./tests/...
test: vet fmt-check script-test unit-test examples-build integration-test
quality-gate: build
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT))
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
go run ./internal/qualitygate/cmd/manifest-export \
--manifest-out $(QUALITY_GATE_MANIFEST_OUT) \
--command-index-out $(QUALITY_GATE_COMMAND_INDEX_OUT)
LARKSUITE_CLI_APP_ID=dry-run \
LARKSUITE_CLI_APP_SECRET=dry-run \
LARKSUITE_CLI_BRAND=feishu \
LARKSUITE_CLI_CONFIG_DIR=$${TMPDIR:-/tmp}/quality-gate-cli-config \
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
go run ./internal/qualitygate/cmd/quality-gate check \
--repo . \
--cli-bin ./$(BINARY) \
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
--facts-out $(QUALITY_GATE_FACTS_OUT)
test: vet fmt-check unit-test examples-build integration-test
install: build
install -d $(PREFIX)/bin

View File

@@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
| 🔗 Apps | 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

@@ -10,7 +10,6 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -91,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)")
@@ -124,13 +122,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--params and --data cannot both read from stdin (-)").
WithHint("pass at most one flag as '-'; give the other inline JSON or @file").
WithParams(
errs.InvalidParam{Name: "--params", Reason: "reads from stdin (-)"},
errs.InvalidParam{Name: "--data", Reason: "reads from stdin (-)"},
)
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
@@ -160,10 +152,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
return client.RawApiRequest{}, nil, err
}
if _, ok := dataFields.(map[string]any); !ok {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--data must be a JSON object when used with --file").
WithHint(`with --file, --data carries multipart form fields, e.g. --data '{"image_type":"message"}'`).
WithParam("--data")
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
}
}
@@ -206,13 +195,7 @@ func apiRun(opts *APIOptions) error {
}
if opts.PageAll && opts.Output != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--output and --page-all are mutually exclusive").
WithHint("drop --page-all to save a binary response, or drop --output to paginate JSON").
WithParams(
errs.InvalidParam{Name: "--output", Reason: "conflicts with --page-all"},
errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"},
)
return output.ErrValidation("--output and --page-all are mutually exclusive")
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
@@ -249,7 +232,7 @@ func apiRun(opts *APIOptions) error {
}
if opts.PageAll {
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
}
@@ -259,7 +242,7 @@ func apiRun(opts *APIOptions) error {
// pass on *output.ExitError values. Typed *errs.* errors that flow
// through here keep their canonical message / hint from BuildAPIError;
// MarkRaw is a no-op on those (it only flips a flag on *ExitError).
return errs.MarkRaw(err)
return output.MarkRaw(err)
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
@@ -279,7 +262,7 @@ func apiRun(opts *APIOptions) error {
// MarkRaw: see comment above on the DoAPI path. Skips legacy
// *ExitError enrichment; typed errors flow through unchanged.
if err != nil {
return errs.MarkRaw(err)
return output.MarkRaw(err)
}
return nil
}
@@ -288,76 +271,46 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
return output.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
return nil
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return errs.MarkRaw(err)
return output.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
return errs.MarkRaw(apiErr)
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
output.FormatValue(out, result, output.FormatJSON)
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
return output.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
return output.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
output.FormatValue(out, result, format)
return nil
}
}

View File

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

@@ -91,29 +91,6 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *CheckOptions
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -132,27 +109,6 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *LogoutOptions
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthListCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -170,27 +126,6 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ListOptions
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -210,29 +145,6 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *StatusOptions
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -355,32 +267,6 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ScopesOptions
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--format", "pretty", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
if gotOpts.Format != "json" {
t.Errorf("expected format json, got %s", gotOpts.Format)
}
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,

View File

@@ -19,7 +19,6 @@ import (
type CheckOptions struct {
Factory *cmdutil.Factory
Scope string
JSON bool
}
// NewCmdAuthCheck creates the auth check subcommand.
@@ -38,7 +37,6 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
}
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.MarkFlagRequired("scope")
cmdutil.SetRisk(cmd, "read")

View File

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

View File

@@ -9,7 +9,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -19,7 +18,6 @@ import (
// ListOptions holds all inputs for auth list.
type ListOptions struct {
Factory *cmdutil.Factory
JSON bool
}
// NewCmdAuthList creates the auth list subcommand.
@@ -36,7 +34,6 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
return authListRun(opts)
},
}
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd
@@ -47,20 +44,12 @@ func authListRun(opts *ListOptions) error {
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_configured",
})
return nil
}
// auth list is a read-only probe; the "configured but no users"
// branch below already returns exit 0 with a stderr hint, so we
// keep the same contract here. We still want the hint to be
// workspace-aware, so we pull the message+hint out of
// NotConfiguredError() instead of hard-coding it.
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
if cfgErr.Hint != "" {
@@ -72,14 +61,6 @@ func authListRun(opts *ListOptions) error {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
return nil
}

View File

@@ -4,7 +4,6 @@
package auth
import (
"encoding/json"
"strings"
"testing"
@@ -35,33 +34,6 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
}
}
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
// reason this hint exists workspace-aware in the first place: an AI agent
// in OpenClaw / Hermes that probes auth list before binding gets routed to
@@ -85,48 +57,3 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
t.Errorf("agent hint must not mention config init: %s", out)
}
}
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "No logged-in users") {
t.Errorf("stderr = %q, want no-users hint", stderr.String())
}
}

View File

@@ -296,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

@@ -92,11 +92,16 @@ func buildDomainMeta(name, lang string) domainMeta {
Description: desc,
}
}
// Fallback: read from the typed service spec (legacy)
// Fallback: read from from_meta spec (legacy)
meta := registry.LoadFromMeta(name)
dm := domainMeta{Name: name}
if svc, ok := registry.ServiceTyped(name); ok {
dm.Title = svc.Title
dm.Description = svc.Description
if meta != nil {
if t, ok := meta["title"].(string); ok {
dm.Title = t
}
if d, ok := meta["description"].(string); ok {
dm.Description = d
}
}
return dm
}

View File

@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
return []string{"base", "contact", "docs", "markdown", "apps"}
}

View File

@@ -9,7 +9,6 @@ import (
"errors"
"io"
"net/http"
"slices"
"sort"
"strings"
"testing"
@@ -215,12 +214,6 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
}
}
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
}
}
func TestCollectScopesForDomains(t *testing.T) {
projects := registry.ListFromMetaProjects()
if len(projects) == 0 {
@@ -878,7 +871,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
// contract that when --json is set and pollDeviceToken returns OK=false,
// stdout carries the structured authorization_failed event and stderr is
// NOT polluted with a typed envelope. The returned error is a bare
// BareError with ExitAuth so the dispatcher only propagates the exit code
// ExitError with ExitAuth so the dispatcher only propagates the exit code
// without emitting a second envelope on top of the JSON event.
func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
keyring.MockInit()
@@ -945,13 +938,16 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr)
}
// Returned error must be the bare *output.BareError signal (no envelope).
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
// Returned error must be the bare *output.ExitError signal (no envelope).
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if bareErr.Code != output.ExitAuth {
t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth)
if exitErr.Code != output.ExitAuth {
t.Fatalf("ExitError.Code = %d, want %d", exitErr.Code, output.ExitAuth)
}
if exitErr.Detail != nil {
t.Errorf("ExitError.Detail should be nil for bare signal, got: %+v", exitErr.Detail)
}
}

View File

@@ -18,7 +18,6 @@ import (
// LogoutOptions holds all inputs for auth logout.
type LogoutOptions struct {
Factory *cmdutil.Factory
JSON bool
}
// NewCmdAuthLogout creates the auth logout subcommand.
@@ -35,7 +34,6 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
return authLogoutRun(opts)
},
}
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "write")
return cmd
@@ -46,65 +44,25 @@ func authLogoutRun(opts *LogoutOptions) error {
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_configured",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
return nil
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
return nil
}
httpClient, httpErr := f.HttpClient()
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
for _, user := range app.Users {
if httpErr == nil && secretErr == nil {
if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil {
revokeToken := token.RefreshToken
tokenTypeHint := "refresh_token"
if revokeToken == "" {
revokeToken = token.AccessToken
tokenTypeHint = "access_token"
}
if revokeToken != "" {
_ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint)
}
}
}
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
}
}
app.Users = []core.AppUser{}
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": true,
})
return nil
}
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
return nil
}

View File

@@ -1,356 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"net/url"
"strings"
"testing"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/zalando/go-keyring"
)
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
t.Helper()
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "test-app",
Apps: []core.AppConfig{
{
AppId: "test-app",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
Users: users,
},
},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != true {
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
}
if _, hasReason := payload["reason"]; hasReason {
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "Logged out") {
t.Errorf("stderr = %q, want success text", stderr.String())
}
}
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-refresh-token" &&
values.Get("token_type_hint") == "refresh_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-access-token" &&
values.Get("token_type_hint") == "access_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Status: 500,
Body: map[string]interface{}{"error": "server_error"},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
gotErr := stderr.String()
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
}
if !strings.Contains(gotErr, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", gotErr)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}

View File

@@ -19,7 +19,6 @@ type ScopesOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
Format string
JSON bool
}
// NewCmdAuthScopes creates the auth scopes subcommand.
@@ -31,9 +30,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
Short: "Query scopes enabled for the app",
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
if opts.JSON {
opts.Format = "json"
}
if runF != nil {
return runF(opts)
}
@@ -42,7 +38,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd

View File

@@ -17,7 +17,6 @@ import (
type StatusOptions struct {
Factory *cmdutil.Factory
Verify bool
JSON bool
}
// NewCmdAuthStatus creates the auth status subcommand.
@@ -36,7 +35,6 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
}
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd

View File

@@ -6,7 +6,6 @@ package cmd
import (
"context"
"io"
"io/fs"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
@@ -17,10 +16,8 @@ import (
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -34,13 +31,9 @@ import (
type BuildOption func(*buildConfig)
type buildConfig struct {
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
skipPlugins bool
skipStrictMode bool
skipService bool
serviceCatalog *apicatalog.Catalog
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
@@ -58,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
@@ -80,41 +61,6 @@ func HideProfile(hide bool) BuildOption {
}
}
// WithoutPlugins builds only repository-owned commands. It is intended for
// inspection tools that need a deterministic command tree.
func WithoutPlugins() BuildOption {
return func(c *buildConfig) {
c.skipPlugins = true
}
}
// WithoutStrictMode builds the complete repository-owned command tree without
// applying user/profile strict-mode pruning. It is intended for offline
// inspection tools, not production execution.
func WithoutStrictMode() BuildOption {
return func(c *buildConfig) {
c.skipStrictMode = true
}
}
// WithoutServiceCommands builds only hand-authored commands. It is intended for
// repository quality gates that should not depend on the remote OpenAPI
// metadata command surface.
func WithoutServiceCommands() BuildOption {
return func(c *buildConfig) {
c.skipService = true
}
}
// WithServiceCatalog builds generated service commands from a specific metadata
// catalog. It is intended for offline inspection tools that need deterministic
// embedded metadata while production execution keeps using the runtime catalog.
func WithServiceCatalog(catalog apicatalog.Catalog) BuildOption {
return func(c *buildConfig) {
c.serviceCatalog = &catalog
}
}
// Build constructs the full command tree. It also installs registered
// plugins and emits the Startup lifecycle event during assembly --
// so Plugin.On(Startup) handlers run even if the returned command is
@@ -157,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",
@@ -195,27 +140,15 @@ 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))
if !cfg.skipService {
if cfg.serviceCatalog != nil {
service.RegisterServiceCommandsFromCatalog(ctx, rootCmd, f, *cfg.serviceCatalog)
} else {
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
}
}
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
installUnknownSubcommandGuard(rootCmd)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}
if cfg.skipPlugins {
recordInventory(nil)
return f, rootCmd, nil
}
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
if installErr != nil {
installPluginInstallErrorGuard(rootCmd, installErr)

View File

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

View File

@@ -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

@@ -1,52 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"reflect"
"testing"
"github.com/spf13/cobra"
)
// TestCommandCatalogPath pins that the auth-hint path reconstruction inverts the
// service command tree for any depth — flat dotted resources AND genuinely
// nested resources — so it round-trips through apicatalog.Resolve instead of
// assuming a fixed root->service->resource->method shape.
func TestCommandCatalogPath(t *testing.T) {
chain := func(names ...string) *cobra.Command {
var parent, leaf *cobra.Command
for _, n := range names {
c := &cobra.Command{Use: n}
if parent != nil {
parent.AddCommand(c)
}
parent = c
leaf = c
}
return leaf
}
tests := []struct {
name string
leaf *cobra.Command
want []string
}{
{"flat dotted resource", chain("lark-cli", "im", "chat.members", "create"), []string{"im", "chat.members", "create"}},
{"nested resources", chain("lark-cli", "im", "spaces", "items", "get"), []string{"im", "spaces", "items", "get"}},
{"service level", chain("lark-cli", "im"), []string{"im"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := commandCatalogPath(tt.leaf); !reflect.DeepEqual(got, tt.want) {
t.Errorf("commandCatalogPath = %v, want %v", got, tt.want)
}
})
}
// The root command (no parent) has no catalog path.
if got := commandCatalogPath(&cobra.Command{Use: "lark-cli"}); len(got) != 0 {
t.Errorf("root path = %v, want empty", got)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,92 +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 unified Token Endpoint deterministically rejected the
// credentials — an OAuth2 invalid_client / unauthorized_client classified as
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
// typed error is propagated so the root dispatcher renders the canonical
// envelope and `config init` exits non-zero — identical to how every other
// token-resolving command reports the same bad credentials. Ambiguous
// failures (transport errors, transient 5xx/server_error, JSON parse errors,
// timeouts) come back as raw untyped errors and are swallowed (return nil),
// so valid configurations are never disturbed by upstream noise.
// errs.IsTyped is the discriminator.
//
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
// that call (success, server error, timeout, parse failure) is always
// 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,287 +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, "/oauth/v3/token"):
f.tatCalls++
if f.tatHandler == nil {
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
}
return f.tatHandler(req)
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
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). This
// is the same typed error every other token-resolving command returns for the
// same bad credentials, and nothing is written to stderr (the root dispatcher
// renders the envelope). The numeric code is not asserted: the unified v3 Token
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
t.Helper()
if err == nil {
t.Fatal("expected *errs.ConfigError, got nil")
}
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 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())
}
}
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
// propagated. The probe endpoint must not be called when TAT fails.
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), 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)
}
// unauthorized_client is treated as the same credential rejection, propagated.
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
// it rather than swallowing — but is not a credential (ConfigError) rejection.
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), 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

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

View File

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

View File

@@ -11,10 +11,10 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/apicatalog"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
@@ -48,6 +48,32 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
authErr.Hint += "\n" + scopeHint
}
// enrichMissingScopeError appends a "current command requires scope(s): X"
// hint to a legacy *output.ExitError when the underlying error carries the
// need_user_authorization marker AND the current command declares scopes
// locally.
//
// Deprecated: enrichment for the legacy envelope; the typed path is
// applyNeedAuthorizationHint above.
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr == nil || exitErr.Detail == nil {
return
}
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
return
}
scopes := resolveDeclaredScopesForCurrentCommand(f)
if len(scopes) == 0 {
return
}
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
if exitErr.Detail.Hint == "" {
exitErr.Detail.Hint = scopeHint
return
}
exitErr.Detail.Hint += "\n" + scopeHint
}
// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
// current command for the resolved identity, checking shortcuts first and then
// service methods from local registry metadata.
@@ -92,37 +118,38 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
}
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
// service/resource/method command. It reconstructs the catalog path from the
// command ancestry and resolves it through the same navigation Module the
// command tree is built from (apicatalog), so it stays correct for nested
// resources instead of hard-coding a root->service->resource->method depth.
// Non-method commands (services, resources, shortcuts) resolve to a non-method
// target and yield no scopes.
// service/resource/method command from the embedded from_meta registry.
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
if cmd == nil || strings.HasPrefix(cmd.Name(), "+") {
// Service-method scope lookup only applies to commands mounted as
// root -> service -> resource -> method. Non-resource/method commands
// intentionally return no scopes here so auth-hint enrichment does not
// change runtime semantics for other command shapes.
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
return nil
}
path := commandCatalogPath(cmd)
if len(path) == 0 {
if strings.HasPrefix(cmd.Name(), "+") {
return nil
}
target, err := registry.RuntimeCatalog().Resolve(path)
if err != nil || target.Kind != apicatalog.TargetMethod {
return nil
}
return registry.DeclaredScopesForMethod(target.Method.Method, identity)
}
// commandCatalogPath reconstructs the catalog path [service, resource..., method]
// from a command's ancestry, excluding the root command. It is the inverse of
// the service command tree's construction, so any depth (flat or nested)
// round-trips through apicatalog.Resolve.
func commandCatalogPath(cmd *cobra.Command) []string {
var path []string
for c := cmd; c != nil && c.Parent() != nil; c = c.Parent() {
path = append([]string{c.Name()}, path...)
service := cmd.Parent().Parent().Name()
resource := cmd.Parent().Name()
method := cmd.Name()
spec := registry.LoadFromMeta(service)
if spec == nil {
return nil
}
return path
resources, _ := spec["resources"].(map[string]interface{})
resMap, _ := resources[resource].(map[string]interface{})
if resMap == nil {
return nil
}
methods, _ := resMap["methods"].(map[string]interface{})
methodMap, _ := methods[method].(map[string]interface{})
if methodMap == nil {
return nil
}
return registry.DeclaredScopesForMethod(methodMap, identity)
}
// shortcutSupportsIdentity reports whether a shortcut supports the requested

View File

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

View File

@@ -12,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

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

View File

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

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

@@ -143,79 +143,6 @@ func TestWriteStatusText_CoversAllStates(t *testing.T) {
}
}
func TestWriteStatusText_ShowsSubColumn(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 2,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0},
{PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB column header: %s", out)
}
if !strings.Contains(out, "alice") {
t.Errorf("missing alice suffix in SUB column: %s", out)
}
if !strings.Contains(out, "bob") {
t.Errorf("missing bob suffix in SUB column: %s", out)
}
}
func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 1,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB header: %s", out)
}
if !strings.Contains(out, "-") {
t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out)
}
}
func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 1,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB header: %s", out)
}
if !strings.Contains(out, "-") {
t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out)
}
}
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
var buf bytes.Buffer
if err := writeStatusJSON(&buf, []appStatus{
@@ -270,15 +197,15 @@ func TestExitForOrphan(t *testing.T) {
if err == nil {
t.Fatal("flag on + orphan → expected error, got nil")
}
var exit *output.BareError
var exit *output.ExitError
if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
t.Errorf("exit code = %v, want ExitValidation", err)
}
}
func errorAs(err error, target interface{}) bool {
if e, ok := err.(*output.BareError); ok {
if t, ok := target.(**output.BareError); ok {
if e, ok := err.(*output.ExitError); ok {
if t, ok := target.(**output.ExitError); ok {
*t = e
return true
}

View File

@@ -26,7 +26,6 @@ func TestRunList_TextOutput(t *testing.T) {
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
} {
if !strings.Contains(out, want) {
t.Errorf("list output missing %q; full output:\n%s", want, out)
@@ -56,17 +55,4 @@ func TestRunList_JSONOutput(t *testing.T) {
}
}
}
var foundTask bool
for _, row := range rows {
if row["key"] == "task.task.update_user_access_v2" {
foundTask = true
if row["single_consumer"] != true {
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
}
}
}
if !foundTask {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
}

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

View File

@@ -6,8 +6,8 @@ package event
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)
@@ -26,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 {
@@ -134,16 +131,12 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
if len(def.Params) > 0 {
fmt.Fprintf(out, "\nParameters:\n")
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tSUB-KEY\tDEFAULT\tDESCRIPTION\n")
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
for _, p := range def.Params {
required := "no"
if p.Required {
required = "yes"
}
subKey := "no"
if p.SubscriptionKey {
subKey = "yes"
}
defaultVal := p.Default
if defaultVal == "" {
defaultVal = "-"
@@ -152,7 +145,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
if desc == "" {
desc = "-"
}
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, subKey, defaultVal, desc)
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
}
w.Flush()
@@ -172,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"
@@ -96,107 +95,6 @@ func TestRunSchema_JSONOutput(t *testing.T) {
}
}
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload["jq_root_path"] != ".event" {
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
}
if payload["single_consumer"] != true {
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
}
resolved := payload["resolved_output_schema"].(map[string]interface{})
props := resolved["properties"].(map[string]interface{})
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
t.Errorf("task_guid format = %v, want task_guid", got)
}
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
}
}
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{
{Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"},
{Name: "folders", Description: "filter only"},
},
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, false); err != nil {
t.Fatalf("runSchema: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "SUB-KEY") {
t.Errorf("missing SUB-KEY column header in:\n%s", out)
}
// Find the mailbox row and verify "yes" is present
var mailboxRow string
for _, ln := range strings.Split(out, "\n") {
if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") {
mailboxRow = ln
break
}
}
if !strings.Contains(mailboxRow, "yes") {
t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow)
}
// Find the folders row and verify "no" is present
var foldersRow string
for _, ln := range strings.Split(out, "\n") {
if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") {
foldersRow = ln
break
}
}
if !strings.Contains(foldersRow, "no") {
t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow)
}
}
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
const syntheticKey = "test.evt_json"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
if !strings.Contains(stdout.String(), `"subscription_key"`) {
t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `true`) {
t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
}
}
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
const syntheticKey = "t.custom.overlay"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
@@ -231,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

@@ -7,7 +7,6 @@ import (
"fmt"
"io"
"sort"
"strings"
"sync"
"time"
@@ -243,17 +242,12 @@ func writeStatusText(out io.Writer, statuses []appStatus) {
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
if len(s.Consumers) > 0 {
headers := []string{"CONSUMER", "EVENT KEY", "SUB", "RECEIVED", "DROPPED"}
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
rows := make([][]string, 0, len(s.Consumers))
for _, c := range s.Consumers {
subDisplay := "-"
if c.SubscriptionID != "" && c.SubscriptionID != c.EventKey {
subDisplay = strings.TrimPrefix(c.SubscriptionID, c.EventKey+":")
}
rows = append(rows, []string{
fmt.Sprintf("pid=%d", c.PID),
c.EventKey,
subDisplay,
fmt.Sprintf("%d", c.Received),
fmt.Sprintf("%d", c.Dropped),
})

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,12 +13,16 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
@@ -31,7 +35,7 @@ const rootLong = `lark-cli — Lark/Feishu CLI tool.
USAGE:
lark-cli <command> [subcommand] [method] [options]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method>
lark-cli schema <service.resource.method> [--format pretty]
EXAMPLES:
# View upcoming events
@@ -46,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.
@@ -67,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)
@@ -139,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.
@@ -212,106 +202,100 @@ func configureFlagCompletions(args []string) {
// and returns the process exit code.
//
// Dispatch order:
// 1. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
// *errs.SecurityPolicyError, *errs.AuthenticationError, *errs.ConfigError):
// render via the typed envelope writer, which lifts extension fields
// (missing_scopes, console_url, challenge_url, ...) to the top level.
// Routed by errs.CategoryOf via ExitCodeOf. Auth and config errors are
// constructed typed at their origin (internal/auth, internal/core), so the
// dispatcher no longer promotes any legacy shape here.
// 2. PartialFailure / BareError signals: the result envelope is already on
// stdout; honor the exit code and write nothing to stderr.
// 3. Residual cobra usage errors (missing required flag, unknown command,
// argument validation): typed as an invalid_argument envelope (exit 2),
// matching the explicit flag/subcommand guards. Flag parse errors are
// already typed upstream by the root FlagErrorFunc.
// 1. Legacy shapes (*core.ConfigError, *internalauth.NeedAuthorizationError)
// are promoted via errcompat to their typed errs/ counterparts, with the
// original preserved in the Cause chain.
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
// *errs.SecurityPolicyError, *errs.AuthenticationError): render via the
// typed envelope writer, which lifts extension fields (missing_scopes,
// console_url, challenge_url, ...) to the top level. Routed by
// errs.CategoryOf via ExitCodeOf.
// 3. Legacy *output.ExitError: asExitError adapts it to the legacy
// envelope, written via WriteErrorEnvelope.
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut
// Promote legacy error shapes into typed errs/ before envelope marshal.
// NeedAuthorizationError check is first because it is the more specific
// shape; *core.ConfigError check follows. errors.As preserves the original
// in the Cause chain, so external errors.As(&core.ConfigError{}) consumers
// (cmd/auth/list.go, cmd/doctor/doctor.go, ...) still match.
//
// Outer-typed short-circuit: if err is already a typed *errs.* error,
// skip PromoteXxxError so the producer's Subtype / Hint / extension
// fields are not overwritten by a coarser promoted shape derived from a
// legacy error buried in its Cause chain. Promotion is only for legacy
// untyped entry points.
if !isOuterTypedError(err) {
var needAuthErr *internalauth.NeedAuthorizationError
if errors.As(err, &needAuthErr) {
err = errcompat.PromoteAuthError(needAuthErr)
} else {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
err = errcompat.PromoteConfigError(cfgErr)
}
}
}
// When the typed error is a need_user_authorization signal, fold in the
// current command's declared scopes as a Hint so the user/AI sees the
// concrete scope(s) to re-auth with. The hint is computed on the fly from
// local shortcut/service metadata — it never depends on server state.
if !errs.IsRaw(err) {
applyNeedAuthorizationHint(f, err)
}
applyNeedAuthorizationHint(f, err)
// Staged dispatch: capture the typed exit code BEFORE attempting the
// envelope write. WriteTypedErrorEnvelope is best-effort on the wire
// (partial-write still returns true) so the exit code we read here is
// preserved even if stderr is torn — torn stderr must not downgrade
// typed exits 3/4/6/10 to the plain "Error:" path with exit 1.
// typed exits 3/4/6/10 to the legacy "Error:" path with exit 1.
// WriteTypedErrorEnvelope still returns false when err carries no
// Problem; in that case we fall through to the signal / plain-text paths.
// Problem; in that case we fall through to the legacy bridge below.
typedExit := output.ExitCodeOf(err)
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
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
}
// Silent-exit signal (e.g. `auth check` predicate, or `update --json`):
// stdout already carries the result; honor the requested exit code and
// write nothing to stderr.
var bareErr *output.BareError
if errors.As(err, &bareErr) {
return bareErr.Code
}
// Errors reaching here are untyped: every RunE returns a typed errs.* error
// and flag-parse errors are typed by the root FlagErrorFunc. The remainder
// is either a cobra usage mistake (missing required flag, unknown command,
// wrong arg count), which cobra surfaces as a plain error identified by its
// stable text — the same external contract unknownFlagName relies on — or an
// untyped error that leaked past the typed boundary. Classify the former as
// invalid_argument (exit 2, like the explicit guards); treat the latter as an
// internal fault (exit 5) rather than blaming the user's input. The message
// is preserved either way, and the typed envelope still carries any pending
// deprecation notice.
var fallback error
if isCobraUsageError(err) {
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error())
} else {
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
}
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
return output.ExitCodeOf(fallback)
}
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
// not a typed value we can match on, so the dispatcher recognizes them by text;
// this is the same external contract unknownFlagName already depends on. A
// residual error matching none of these has leaked the typed boundary and is
// treated as an internal fault, not a user error.
var cobraUsageErrorMarkers = []string{
"unknown command ",
"unknown flag: ",
"unknown shorthand",
"required flag(s) ",
"flag needs an argument",
"bad flag syntax:",
"no such flag ",
"invalid argument ",
"arg(s), ", // accepts / requires N arg(s), received / only received M
}
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
// identified by the stable error text of the pinned cobra version.
func isCobraUsageError(err error) bool {
msg := err.Error()
for _, m := range cobraUsageErrorMarkers {
if strings.Contains(msg, m) {
return true
if exitErr := asExitError(err); exitErr != nil {
if !exitErr.Raw {
// Raw errors (e.g. from `api` command via output.MarkRaw)
// preserve the original API error detail; skip enrichment
// which would clear it.
enrichMissingScopeError(f, exitErr)
enrichPermissionError(f, exitErr)
}
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
return exitErr.Code
}
return false
fmt.Fprintln(errOut, "Error:", err)
return 1
}
// isOuterTypedError returns true if err is a typed *errs.* error AT THE
// TOP OF THE CHAIN (not buried inside Unwrap). Used by handleRootError
// to gate PromoteXxxError so a producer's outer typed envelope is never
// overwritten by a coarser shape derived from its legacy Cause.
func isOuterTypedError(err error) bool {
_, ok := err.(errs.TypedError)
return ok
}
// asExitError converts known structured error types to *output.ExitError.
// Returns nil for unrecognized errors (e.g. cobra flag errors).
//
// Deprecated: legacy *output.ExitError bridge.
func asExitError(err error) *output.ExitError {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return exitErr
}
return nil
}
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
@@ -342,174 +326,44 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
}
}
// unknownSubcommandRunE replaces cobra's silent help fallback on group commands
// with a typed *errs.ValidationError: a flag that belongs to a missing
// subcommand, a misplaced subcommand-only flag, or an unknown subcommand name
// each fail structured (exit 2) instead of degrading to help + exit 0.
// Deprecated: unknownSubcommandRunE produces a legacy *output.ExitError that
// predates the typed error contract introduced by errs/. New code MUST NOT
// add producers of this shape — unknown-subcommand signals should move to
// a typed *errs.ValidationError (or a dedicated typed error) carrying the
// agent-protocol metadata as typed extension fields. This helper is retained
// only while existing dispatch sites are migrated; it will be removed once
// they have moved to the typed surface.
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 {
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()).
WithHint("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath())
for _, flag := range unknown {
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "unknown flag before a subcommand"})
}
return verr
}
// The remaining flags are all defined somewhere in the tree. Those valid
// on the group itself or inherited (e.g. the global --profile) do not
// require a subcommand, so a bare group carrying only those still prints
// help. Anything left belongs to a subcommand that was omitted
// (e.g. `im --format json`): distinct from unknown_flag — the flags are
// real, the subcommand is what's missing.
misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs)
if len(misplaced) == 0 {
return cmd.Help()
}
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")).
WithHint("run `%s --help` to list subcommands and their flags", cmd.CommandPath())
for _, flag := range misplaced {
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "flag belongs to a subcommand, not the group"})
}
return verr
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())
}
// Record the offending subcommand and its ranked candidates as a param with
// machine-readable Suggestions so an agent can retry without parsing the
// hint; the hint carries the same candidates as prose.
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
WithParams(errs.InvalidParam{Name: unknown, Reason: "unknown subcommand", Suggestions: suggestions}).
WithHint("%s", hint)
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_subcommand",
Message: msg,
Hint: hint,
Detail: map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"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
@@ -518,57 +372,60 @@ 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
// converts cobra's flag-parse errors into a typed validation envelope: an
// unknown flag gets a focused "did you mean" hint (so agents recover even when
// the typo is semantic, e.g. --query vs --find, where edit distance alone finds
// nothing) and the offending flag in `params`. Other flag errors stay typed
// but generic.
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
// --find, where edit distance alone finds nothing). Other flag errors stay
// structured but generic.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "flag_error",
Message: ferr.Error(),
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
},
}
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if len(suggestions) > 0 {
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
// The ranked candidates ride on the param as machine-readable Suggestions so
// an agent can retry without parsing the hint; the hint carries the same
// candidates as prose. The full valid-flag list stays recoverable via --help.
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
WithHint("%s", hint)
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
Hint: hint,
Detail: map[string]any{
"unknown": "--" + name,
"command_path": c.CommandPath(),
"suggestions": suggestions,
"valid_flags": valid,
},
},
}
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
// those structured but generic — hallucinated flags are essentially always long.
//
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
// silently fails and unknown flags degrade to a generic flag_error — re-verify
// this prefix when bumping cobra.
func unknownFlagName(err error) (string, bool) {
const p = "unknown flag: --"
msg := err.Error()
@@ -627,3 +484,56 @@ func installTipsHelpFunc(root *cobra.Command) {
}
})
}
// enrichPermissionError rewrites the legacy *output.ExitError envelope so its
// Message + Hint match the per-subtype canonical text produced by the typed
// dispatcher path (errclass.CanonicalPermissionMessage / errclass.PermissionHint).
// This guarantees a caller observing the wire envelope cannot tell whether
// the error reached the dispatcher via the legacy *ExitError bridge or via
// the typed *errs.PermissionError fast path.
//
// Deprecated: legacy *output.ExitError enrichment; typed PermissionError
// values produced by errclass.BuildAPIError already carry MissingScopes +
// ConsoleURL directly.
func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr.Detail == nil {
return
}
// Only the legacy permission-class envelope types route here. "app_status"
// covers 99991662 (app_disabled) / 99991673 (app_unavailable); "permission"
// covers the four scope-class codes (99991672 / 99991676 / 99991679 / 230027).
if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "app_status" {
return
}
larkCode := exitErr.Detail.Code
meta, ok := errclass.LookupCodeMeta(larkCode)
if !ok || meta.Category != errs.CategoryAuthorization {
return
}
// Extract required scopes from API error detail (shared helper). May be
// empty for app-status codes — canonical message + hint still apply.
missing := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
cfg, err := f.Config()
if err != nil {
return
}
// Reuse the same console URL builder as the typed path so both wire
// envelopes carry identical console_url values for the same input.
consoleURL := errclass.ConsoleURL(string(cfg.Brand), cfg.AppID, missing)
// Clear raw API detail — useful info is now in message/hint/console_url.
exitErr.Detail.Detail = nil
identity := string(f.ResolvedIdentity)
if identity == "" {
identity = "user"
}
exitErr.Detail.Message = errclass.CanonicalPermissionMessage(meta.Subtype, cfg.AppID, missing, exitErr.Detail.Message)
exitErr.Detail.Hint = errclass.PermissionHint(missing, identity, meta.Subtype, consoleURL)
exitErr.Detail.ConsoleURL = consoleURL
}

View File

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

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

View File

@@ -5,17 +5,17 @@ package schema
import (
"context"
"errors"
"fmt"
"io"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/schema"
"github.com/larksuite/cli/internal/util"
"github.com/spf13/cobra"
)
@@ -24,10 +24,336 @@ type SchemaOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
// Args are the positional path segments, in either the dotted single-arg
// form ("im.messages.reply") or the space-separated form ("im messages
// reply"); apicatalog.ParsePath normalizes both.
Args []string
// Positional args
Path string // first positional, when only one is given
ExtraArgs []string // 2nd+ positional args (space-separated form)
// Flags
Format string
}
func printServices(w io.Writer) {
services := registry.ListFromMetaProjects()
fmt.Fprintf(w, "%sAvailable services:%s\n\n", output.Bold, output.Reset)
for _, s := range services {
spec := registry.LoadFromMeta(s)
title := registry.GetStrFromMap(spec, "title")
if title == "" {
title = registry.GetStrFromMap(spec, "description")
}
fmt.Fprintf(w, " %s%s%s %s%s%s\n", output.Cyan, s, output.Reset, output.Dim, title, output.Reset)
}
fmt.Fprintf(w, "\n%sUsage: lark-cli schema <service>.<resource>.<method>%s\n", output.Dim, output.Reset)
}
func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) {
name := registry.GetStrFromMap(spec, "name")
version := registry.GetStrFromMap(spec, "version")
title := registry.GetStrFromMap(spec, "title")
if title == "" {
title = registry.GetStrFromMap(spec, "description")
}
servicePath := registry.GetStrFromMap(spec, "servicePath")
fmt.Fprintf(w, "%s%s%s (%s) — %s\n\n", output.Bold, name, output.Reset, version, title)
fmt.Fprintf(w, "%sBase path: %s%s\n\n", output.Dim, servicePath, output.Reset)
resources, _ := spec["resources"].(map[string]interface{})
for _, resName := range sortedKeys(resources) {
resMap, _ := resources[resName].(map[string]interface{})
methods, _ := resMap["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
if len(methods) == 0 {
continue
}
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
for _, methodName := range sortedKeys(methods) {
m, _ := methods[methodName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
desc := registry.GetStrFromMap(m, "description")
danger := ""
if d, _ := m["danger"].(bool); d {
danger = fmt.Sprintf(" %s[danger]%s", output.Red, output.Reset)
}
fmt.Fprintf(w, " %-7s %s%s%s %s%s%s%s\n", httpMethod, output.Bold, methodName, output.Reset, output.Dim, desc, output.Reset, danger)
}
fmt.Fprintln(w)
}
fmt.Fprintf(w, "%sUsage: lark-cli schema %s.<resource>.<method>%s\n", output.Dim, name, output.Reset)
}
// hasFileFields returns true if any requestBody field has type "file".
func hasFileFields(method map[string]interface{}) (bool, []string) {
names := cmdutil.DetectFileFields(method)
return len(names) > 0, names
}
func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) {
servicePath := registry.GetStrFromMap(spec, "servicePath")
specName := registry.GetStrFromMap(spec, "name")
methodPath := registry.GetStrFromMap(method, "path")
fullPath := servicePath + "/" + methodPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
desc := registry.GetStrFromMap(method, "description")
isFileUpload, fileFieldNames := hasFileFields(method)
fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset)
httpColor := output.Yellow
if httpMethod == "GET" {
httpColor = output.Green
} else if httpMethod == "DELETE" {
httpColor = output.Red
}
fmt.Fprintf(w, " %s%s%s %s\n", httpColor, httpMethod, output.Reset, fullPath)
if desc != "" {
fmt.Fprintf(w, " %s\n", desc)
}
fmt.Fprintln(w)
// Parameters
params, _ := method["parameters"].(map[string]interface{})
if len(params) > 0 {
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
fmt.Fprintf(w, " %s--params%s <json> %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
for _, paramName := range sortedParamKeys(params) {
p, _ := params[paramName].(map[string]interface{})
pType := registry.GetStrFromMap(p, "type")
if pType == "" {
pType = "string"
}
location := registry.GetStrFromMap(p, "location")
required, _ := p["required"].(bool)
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
if required {
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
}
locColor := output.Dim
if location == "path" {
locColor = output.Yellow
}
// Options (enum values)
optStr := formatOptions(p)
fmt.Fprintf(w, " - %s%s%s (%s, %s%s%s, %s)%s\n", output.Cyan, paramName, output.Reset, pType, locColor, location, output.Reset, reqStr, optStr)
if pdesc := registry.GetStrFromMap(p, "description"); pdesc != "" {
pdesc = util.TruncateStrWithEllipsis(pdesc, 100)
fmt.Fprintf(w, " %s%s%s\n", output.Dim, pdesc, output.Reset)
}
if ex := registry.GetStrFromMap(p, "example"); ex != "" {
fmt.Fprintf(w, " %se.g. %s%s\n", output.Dim, ex, output.Reset)
}
if rangeStr := formatRange(p); rangeStr != "" {
fmt.Fprintf(w, " %srange: %s%s\n", output.Dim, rangeStr, output.Reset)
}
}
fmt.Fprintln(w)
}
// --data for write methods
if httpMethod == "POST" || httpMethod == "PUT" || httpMethod == "PATCH" || httpMethod == "DELETE" {
if len(params) == 0 {
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
}
fileUploadTag := ""
if isFileUpload {
fileUploadTag = fmt.Sprintf(" %s[file upload]%s", output.Yellow, output.Reset)
}
fmt.Fprintf(w, " %s--data%s <json> %soptional%s%s\n", output.Cyan, output.Reset, output.Dim, output.Reset, fileUploadTag)
requestBody, _ := method["requestBody"].(map[string]interface{})
if len(requestBody) > 0 {
printNestedFields(w, requestBody, " ", "")
}
if isFileUpload {
if len(fileFieldNames) == 1 {
fmt.Fprintf(w, "\n %s--file%s <[field=]path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
fmt.Fprintf(w, " Upload file as multipart/form-data. Default field: %q\n", fileFieldNames[0])
} else {
fmt.Fprintf(w, "\n %s--file%s <field=path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
fmt.Fprintf(w, " Upload file as multipart/form-data. Fields: %s\n", strings.Join(fileFieldNames, ", "))
}
}
fmt.Fprintln(w)
}
// Response
responseBody, _ := method["responseBody"].(map[string]interface{})
if len(responseBody) > 0 {
fmt.Fprintf(w, "%sResponse:%s\n\n", output.Bold, output.Reset)
printNestedFields(w, responseBody, " ", "")
fmt.Fprintln(w)
}
// Identity
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
var identities []string
for _, t := range tokens {
if s, ok := t.(string); ok {
switch s {
case "user":
identities = append(identities, "user")
case "tenant":
identities = append(identities, "bot")
}
}
}
if len(identities) > 0 {
fmt.Fprintf(w, "%sIdentity:%s %s\n", output.Bold, output.Reset, strings.Join(identities, ", "))
}
}
// Scopes (all)
if scopes, ok := method["scopes"].([]interface{}); ok && len(scopes) > 0 {
var scopeStrs []string
for _, s := range scopes {
if str, ok := s.(string); ok {
scopeStrs = append(scopeStrs, str)
}
}
fmt.Fprintf(w, "%sScopes:%s %s\n", output.Bold, output.Reset, strings.Join(scopeStrs, ", "))
}
// CLI example
if isFileUpload && len(fileFieldNames) == 1 {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <path>\n", output.Bold, output.Reset, specName, resName, methodName)
} else if isFileUpload {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <field=path>\n", output.Bold, output.Reset, specName, resName, methodName)
} else {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
}
// Docs
if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" {
fmt.Fprintf(w, "%sDocs:%s %s\n", output.Bold, output.Reset, docUrl)
}
}
func printNestedFields(w io.Writer, fields map[string]interface{}, indent, prefix string) {
for _, fieldName := range sortedFieldKeys(fields) {
f, _ := fields[fieldName].(map[string]interface{})
fullName := fieldName
if prefix != "" {
fullName = prefix + "." + fieldName
}
fType := registry.GetStrFromMap(f, "type")
required, _ := f["required"].(bool)
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
if required {
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
}
optStr := formatOptions(f)
fmt.Fprintf(w, "%s- %s%s%s (%s, %s)%s\n", indent, output.Cyan, fullName, output.Reset, fType, reqStr, optStr)
desc := registry.GetStrFromMap(f, "description")
if desc != "" {
desc = util.TruncateStrWithEllipsis(desc, 100)
fmt.Fprintf(w, "%s %s%s%s\n", indent, output.Dim, desc, output.Reset)
}
if ex := registry.GetStrFromMap(f, "example"); ex != "" {
fmt.Fprintf(w, "%s %se.g. %s%s\n", indent, output.Dim, ex, output.Reset)
}
if rangeStr := formatRange(f); rangeStr != "" {
fmt.Fprintf(w, "%s %srange: %s%s\n", indent, output.Dim, rangeStr, output.Reset)
}
if props, ok := f["properties"].(map[string]interface{}); ok && len(props) > 0 {
printNestedFields(w, props, indent+" ", fullName)
}
}
}
// formatOptions returns " — val1 | val2 | ..." if field has options, else "".
func formatOptions(f map[string]interface{}) string {
opts, ok := f["options"].([]interface{})
if !ok || len(opts) == 0 {
return ""
}
var vals []string
for _, o := range opts {
if om, ok := o.(map[string]interface{}); ok {
if v := registry.GetStrFromMap(om, "value"); v != "" {
vals = append(vals, v)
}
}
}
if len(vals) == 0 {
return ""
}
return fmt.Sprintf(" %s— %s%s", output.Dim, strings.Join(vals, " | "), output.Reset)
}
// formatRange returns "min..max" if field has min/max, else "".
func formatRange(f map[string]interface{}) string {
minVal := registry.GetStrFromMap(f, "min")
maxVal := registry.GetStrFromMap(f, "max")
if minVal == "" && maxVal == "" {
return ""
}
if minVal != "" && maxVal != "" {
return minVal + ".." + maxVal
}
if minVal != "" {
return ">=" + minVal
}
return "<=" + maxVal
}
// sortedKeys returns map keys in alphabetical order.
func sortedKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// sortedParamKeys returns parameter keys sorted: required first, then alphabetical.
func sortedParamKeys(params map[string]interface{}) []string {
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
pi, _ := params[keys[i]].(map[string]interface{})
pj, _ := params[keys[j]].(map[string]interface{})
ri, _ := pi["required"].(bool)
rj, _ := pj["required"].(bool)
if ri != rj {
return ri
}
return keys[i] < keys[j]
})
return keys
}
// sortedFieldKeys returns field keys sorted: required first, then alphabetical.
func sortedFieldKeys(fields map[string]interface{}) []string {
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
fi, _ := fields[keys[i]].(map[string]interface{})
fj, _ := fields[keys[j]].(map[string]interface{})
ri, _ := fi["required"].(bool)
rj, _ := fj["required"].(bool)
if ri != rj {
return ri
}
return keys[i] < keys[j]
})
return keys
}
func findResourceByPath(resources map[string]interface{}, parts []string) (map[string]interface{}, string, []string) {
for i := len(parts); i >= 1; i-- {
candidateName := strings.Join(parts[:i], ".")
if res, ok := resources[candidateName]; ok {
if resMap, ok := res.(map[string]interface{}); ok {
return resMap, candidateName, parts[i:]
}
}
}
return nil, "", nil
}
// NewCmdSchema creates the schema command. If runF is non-nil it is called instead of schemaRun (test hook).
@@ -39,7 +365,12 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
Short: "View API method parameters, types, and scopes",
Args: cobra.MaximumNArgs(8),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Args = append([]string(nil), args...)
if len(args) > 0 {
opts.Path = args[0]
}
if len(args) > 1 {
opts.ExtraArgs = args[1:]
}
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
@@ -49,89 +380,433 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
}
cmdutil.DisableAuthCheck(cmd)
// Tolerated for agent compatibility; ignored — schema only emits the JSON
// envelope, and its output is identity-independent (strict-mode filtering
// comes from ResolveStrictMode, never from --as).
cmd.Flags().String("format", "json", "")
cmd.Flags().Bool("json", true, "")
cmd.Flags().String("as", "", "")
_ = cmd.Flags().MarkHidden("format")
_ = cmd.Flags().MarkHidden("json")
_ = cmd.Flags().MarkHidden("as")
cmd.ValidArgsFunction = completeSchemaPath(f)
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
// It uses the embedded source so completion candidates match what `schema`
// execution can resolve (both overlay-free).
// completeSchemaPath provides tab-completion for the schema path argument.
// It handles both legacy dotted resource names (e.g. app.table.fields) and the
// newer space-separated form (e.g. `schema im messages reply`).
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
mode := f.ResolveStrictMode(cmd.Context())
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
directive := cobra.ShellCompDirectiveNoFileComp
if noSpace {
directive |= cobra.ShellCompDirectiveNoSpace
// Case 1: legacy "single dotted arg" path — no previous args yet
if len(args) == 0 {
parts := strings.Split(toComplete, ".")
if len(parts) <= 1 {
var completions []string
for _, s := range registry.ListFromMetaProjects() {
if strings.HasPrefix(s, toComplete) {
completions = append(completions, s+".")
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
spec = filterSpecByStrictMode(spec, mode)
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
afterService := strings.Join(parts[1:], ".")
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
allTrailingDot := len(completions) > 0
for _, c := range completions {
if !strings.HasSuffix(c, ".") {
allTrailingDot = false
break
}
}
directive := cobra.ShellCompDirectiveNoFileComp
if allTrailingDot {
directive |= cobra.ShellCompDirectiveNoSpace
}
return completions, directive
}
return completions, directive
// Case 2: space-form, args already has segments
// Walk down service -> resource(s) -> method based on existing args
serviceName := args[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
spec = filterSpecByStrictMode(spec, mode)
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// args[1:] are resource path segments (possibly partial); current
// toComplete is the next segment under cursor.
consumed := args[1:]
resource, _, remaining := findResourceByPath(resources, consumed)
if resource == nil {
// Suggest top-level resource names that match toComplete
var completions []string
for resName := range resources {
if strings.HasPrefix(resName, toComplete) {
completions = append(completions, resName)
}
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
}
if len(remaining) > 0 {
// Already typed past the resource — suggest methods
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
var completions []string
for mName := range methods {
if strings.HasPrefix(mName, toComplete) {
completions = append(completions, mName)
}
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
}
// Resource matched exactly, suggest methods
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
var completions []string
for mName := range methods {
if strings.HasPrefix(mName, toComplete) {
completions = append(completions, mName)
}
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
}
}
func completeSchemaPathForSpec(serviceName string, resources map[string]interface{}, afterService string) []string {
var completions []string
for resName, resVal := range resources {
if strings.HasPrefix(resName, afterService) {
completions = append(completions, serviceName+"."+resName+".")
continue
}
if !strings.HasPrefix(afterService, resName+".") {
continue
}
methodPrefix := afterService[len(resName)+1:]
resMap, _ := resVal.(map[string]interface{})
if resMap == nil {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
for methodName := range methods {
if strings.HasPrefix(methodName, methodPrefix) {
completions = append(completions, serviceName+"."+resName+"."+methodName)
}
}
}
sort.Strings(completions)
return completions
}
func schemaRun(opts *SchemaOptions) error {
out := opts.Factory.IOStreams.Out
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
// args may have arrived as a single string (legacy single-arg path) or
// split into multiple — normalize to a single args slice.
var rawArgs []string
if opts.Path != "" {
rawArgs = []string{opts.Path}
}
if len(opts.ExtraArgs) > 0 {
if opts.Path != "" {
rawArgs = append([]string{opts.Path}, opts.ExtraArgs...)
} else {
rawArgs = append([]string(nil), opts.ExtraArgs...)
}
}
parts := schema.ParsePath(rawArgs)
if opts.Format == "pretty" {
return runPrettyMode(out, parts, mode)
}
return runJSONMode(out, parts, mode)
}
// runSchema resolves the path through the embedded catalog and renders the
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
// output shape — a single resolved method renders as one envelope object,
// anything broader as an array — and maps resolve failures to hints.
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
catalog := registry.EmbeddedCatalog()
target, err := catalog.Resolve(parts)
if err != nil {
return resolveError(err)
}
refs := catalog.MethodRefs(target, registry.FilterForStrictMode(mode))
if target.Kind == apicatalog.TargetMethod {
if len(refs) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"Method %s not available in current identity mode", target.Method.SchemaPath()).
WithHint("strict mode hides methods the active account identity cannot call; it is shown for an identity (user or bot) that has the required access token")
// runJSONMode dispatches list/single envelope output based on parts.
// JSON mode uses embedded data only (bypasses remote overlay) so envelope
// output is deterministic across machines.
func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error {
filter := strictModeFilter(mode)
switch len(parts) {
case 0:
envs := schema.AssembleAll(filter)
output.PrintJson(out, envs)
return nil
case 1:
spec := registry.EmbeddedSpec(parts[0])
if spec == nil {
return errUnknownEmbeddedService(parts[0])
}
output.PrintJson(out, schema.EnvelopeOf(refs[0]))
envs := schema.AssembleService(parts[0], spec, filter)
output.PrintJson(out, envs)
return nil
default:
return runJSONForPath(out, parts, filter)
}
}
// runJSONForPath handles len(parts) >= 2: try resource match first, fallback
// to single-method match. Uses embedded data only.
func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error {
serviceName := parts[0]
spec := registry.EmbeddedSpec(serviceName)
if spec == nil {
return errUnknownEmbeddedService(serviceName)
}
resources, _ := spec["resources"].(map[string]interface{})
resource, resName, remaining := findResourceByPath(resources, parts[1:])
if resource == nil {
var names []string
for k := range resources {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) == 0 {
// Resource-scoped envelope array
envs := assembleResource(serviceName, resName, resource, filter)
output.PrintJson(out, envs)
return nil
}
output.PrintJson(out, schema.Envelopes(refs))
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var names []string
for k := range methods {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) > 1 {
// Method exists but caller appended extra segments — reject so they
// don't silently get this method's schema when they typo'd the path.
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown path: %s.%s.%s",
serviceName, resName, strings.Join(remaining, ".")),
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
methodName, strings.Join(remaining[1:], ".")))
}
if filter != nil && !filter(method) {
// Method exists in spec but filtered out by strict mode
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName),
"Use --as user / --as bot to switch")
}
env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method)
output.PrintJson(out, env)
return nil
}
// resolveError maps a catalog *ResolveError to a typed *errs.ValidationError
// (CategoryValidation drives the exit code; Hint promotes to the envelope),
// preserving the historical message + hint text.
func resolveError(err error) error {
var re *apicatalog.ResolveError
if !errors.As(err, &re) {
return err
func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope {
methods, _ := resource["methods"].(map[string]interface{})
resourcePath := []string{resName}
var envs []schema.Envelope
for methodName, raw := range methods {
method, ok := raw.(map[string]interface{})
if !ok {
continue
}
if filter != nil && !filter(method) {
continue
}
envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method))
}
switch re.Kind {
case apicatalog.ErrService:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown service: %s", re.Subject).
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
case apicatalog.ErrResource:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown resource: %s", re.Subject).
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
case apicatalog.ErrMethod:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown method: %s", re.Subject).
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
case apicatalog.ErrPath:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown path: %s", re.Subject).
WithHint("Method %q exists but the trailing segments %q do not resolve", re.Method, re.Trailing)
}
return err
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
return envs
}
// runPrettyMode preserves the existing legacy pretty rendering verbatim.
// All printServices/printResourceList/printMethodDetail calls stay unchanged.
func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
if len(parts) == 0 {
printServices(out)
return nil
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return errUnknownService(serviceName)
}
if len(parts) == 1 {
printResourceList(out, spec, mode)
return nil
}
resources, _ := spec["resources"].(map[string]interface{})
resource, resName, remaining := findResourceByPath(resources, parts[1:])
if resource == nil {
var names []string
for k := range resources {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) == 0 {
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
for _, mName := range sortedKeys(methods) {
m, _ := methods[mName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
desc := registry.GetStrFromMap(m, "description")
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
}
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
return nil
}
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var names []string
for k := range methods {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) > 1 {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown path: %s.%s.%s",
serviceName, resName, strings.Join(remaining, ".")),
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
methodName, strings.Join(remaining[1:], ".")))
}
printMethodDetail(out, spec, resName, methodName, method)
return nil
}
// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns
// nil if strict mode is not active.
func strictModeFilter(mode core.StrictMode) schema.MethodFilter {
if !mode.IsActive() {
return nil
}
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
return func(method map[string]interface{}) bool {
tokens, _ := method["accessTokens"].([]interface{})
if tokens == nil {
return true // permissive when meta_data lacks accessTokens
}
for _, t := range tokens {
if s, _ := t.(string); s == token {
return true
}
}
return false
}
}
func errUnknownService(name string) error {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown service: %s", name),
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
}
// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded
// services (no overlay) because JSON mode itself bypasses overlay; suggesting
// overlay-only services would mislead callers when those services subsequently
// fail to resolve in envelope output.
func errUnknownEmbeddedService(name string) error {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown service: %s", name),
fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", ")))
}
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
// filtered by strict mode. Returns the original spec when strict mode is off.
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
if !mode.IsActive() {
return spec
}
result := make(map[string]interface{}, len(spec))
for k, v := range spec {
result[k] = v
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return result
}
filteredRes := make(map[string]interface{}, len(resources))
for resName, resVal := range resources {
resMap, ok := resVal.(map[string]interface{})
if !ok {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
filtered := filterMethodsByStrictMode(methods, mode)
if len(filtered) == 0 {
continue
}
resCopy := make(map[string]interface{}, len(resMap))
for k, v := range resMap {
resCopy[k] = v
}
resCopy["methods"] = filtered
filteredRes[resName] = resCopy
}
result["resources"] = filteredRes
return result
}
// filterMethodsByStrictMode removes methods incompatible with the active strict mode.
// Returns the original map unmodified when strict mode is off.
func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} {
if !mode.IsActive() || methods == nil {
return methods
}
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
filtered := make(map[string]interface{}, len(methods))
for name, val := range methods {
m, ok := val.(map[string]interface{})
if !ok {
continue
}
tokens, _ := m["accessTokens"].([]interface{})
if tokens == nil {
filtered[name] = val
continue
}
for _, t := range tokens {
if ts, ok := t.(string); ok && ts == token {
filtered[name] = val
break
}
}
}
return filtered
}

View File

@@ -4,12 +4,11 @@
package schema
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
@@ -22,46 +21,29 @@ func TestSchemaCmd_FlagParsing(t *testing.T) {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"calendar.events.list"})
cmd.SetArgs([]string{"calendar.events.list", "--format", "pretty"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(gotOpts.Args) != 1 || gotOpts.Args[0] != "calendar.events.list" {
t.Errorf("expected args [calendar.events.list], got %v", gotOpts.Args)
if gotOpts.Path != "calendar.events.list" {
t.Errorf("expected path calendar.events.list, got %s", gotOpts.Path)
}
if gotOpts.Format != "pretty" {
t.Errorf("expected Format=pretty, got %s", gotOpts.Format)
}
}
func TestSchemaCmd_OutputFlagsAcceptedForCompat(t *testing.T) {
// Agents are habituated to --format/--json/--as from api/service commands.
// schema must accept them without erroring and always emit the JSON envelope —
// its output is structured JSON and identity-independent, so the values have
// no effect.
argSets := [][]string{
{"--format", "json"},
{"--format", "pretty"},
{"--format", "table"}, // no table rendering for a nested schema -> JSON
{"--format", "csv"},
{"--json"},
{"--json", "--format", "ndjson"},
{"--as", "user"},
{"--as", "bot"},
{"--as", "user", "--json"},
func TestSchemaCmd_NoArgs_Pretty(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, extra := range argSets {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs(append([]string{"im.images.create"}, extra...))
if err := cmd.Execute(); err != nil {
t.Fatalf("args %v should be accepted, got error: %v", extra, err)
}
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("args %v: output is not a JSON envelope: %v\n%s", extra, err, stdout.String())
}
if env["name"] != "im images create" {
t.Errorf("args %v: expected the im images create envelope, got name=%v", extra, env["name"])
}
if !strings.Contains(stdout.String(), "Available services") {
t.Error("expected service list in pretty mode")
}
}
@@ -69,7 +51,7 @@ func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{})
cmd.SetArgs([]string{}) // default --format json
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -94,7 +76,7 @@ func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im.images.create"})
cmd.SetArgs([]string{"im.images.create", "--format", "json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -197,6 +179,23 @@ func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
}
}
func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im.images.create", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Existing pretty rendering surfaces these markers — they must still appear
for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} {
if !strings.Contains(out, want) {
t.Errorf("pretty output missing marker %q", want)
}
}
}
func TestSchemaCmd_UnknownService(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -211,47 +210,170 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
if !strings.Contains(err.Error(), "Unknown service") {
t.Errorf("expected 'Unknown service' error, got: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
func TestPrintMethodDetail_FileUpload(t *testing.T) {
spec := map[string]interface{}{
"name": "im",
"servicePath": "/open-apis/im/v1",
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
method := map[string]interface{}{
"path": "images",
"httpMethod": "POST",
"description": "Upload an image",
"requestBody": map[string]interface{}{
"image_type": map[string]interface{}{
"type": "string",
"required": true,
},
"image": map[string]interface{}{
"type": "file",
"required": true,
},
},
"accessTokens": []interface{}{"user", "tenant"},
}
if !strings.Contains(ve.Hint, "Available:") {
t.Errorf("expected hint listing available services, got: %q", ve.Hint)
var buf bytes.Buffer
printMethodDetail(&buf, spec, "images", "create", method)
out := buf.String()
if !strings.Contains(out, "file upload") {
t.Errorf("expected 'file upload' marker in output, got:\n%s", out)
}
if !strings.Contains(out, "--file") {
t.Errorf("expected '--file' in output, got:\n%s", out)
}
if !strings.Contains(out, `"image"`) {
t.Errorf("expected default field name 'image' in output, got:\n%s", out)
}
if !strings.Contains(out, "--file <path>") {
t.Errorf("expected CLI example with --file <path>, got:\n%s", out)
}
}
// TestSchemaCmd_UnknownMethod_TypedValidation pins the typed envelope for the
// JSON-mode unknown-method path: *errs.ValidationError with
// subtype invalid_argument and a hint listing the available methods.
func TestSchemaCmd_UnknownMethod_TypedValidation(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
func TestPrintMethodDetail_NoFileUpload(t *testing.T) {
spec := map[string]interface{}{
"name": "calendar",
"servicePath": "/open-apis/calendar/v4",
}
method := map[string]interface{}{
"path": "events",
"httpMethod": "POST",
"description": "Create an event",
"requestBody": map[string]interface{}{
"summary": map[string]interface{}{
"type": "string",
"required": true,
},
},
}
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"calendar.events.nonexistent_method"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for unknown method")
var buf bytes.Buffer
printMethodDetail(&buf, spec, "events", "create", method)
out := buf.String()
if strings.Contains(out, "file upload") {
t.Errorf("did not expect 'file upload' marker for non-file method, got:\n%s", out)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(err.Error(), "Unknown method") {
t.Errorf("expected 'Unknown method' error, got: %v", err)
}
if !strings.Contains(ve.Hint, "Available:") {
t.Errorf("expected hint listing available methods, got: %q", ve.Hint)
if strings.Contains(out, "--file") {
t.Errorf("did not expect '--file' for non-file method, got:\n%s", out)
}
}
// Completion candidate generation (dotted + space forms, strict-mode filtering,
// dotted-resource handling) now lives in internal/apicatalog and is covered by
// apicatalog's TestComplete. cmd/schema only adapts catalog.Complete to cobra.
func TestHasFileFields(t *testing.T) {
tests := []struct {
name string
method map[string]interface{}
wantBool bool
wantFields []string
}{
{
name: "has file field",
method: map[string]interface{}{
"requestBody": map[string]interface{}{
"image": map[string]interface{}{"type": "file"},
"name": map[string]interface{}{"type": "string"},
},
},
wantBool: true,
wantFields: []string{"image"},
},
{
name: "no file field",
method: map[string]interface{}{
"requestBody": map[string]interface{}{
"name": map[string]interface{}{"type": "string"},
},
},
wantBool: false,
wantFields: nil,
},
{
name: "no requestBody",
method: map[string]interface{}{},
wantBool: false,
wantFields: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, names := hasFileFields(tt.method)
if got != tt.wantBool {
t.Errorf("hasFileFields() = %v, want %v", got, tt.wantBool)
}
if tt.wantFields == nil && names != nil {
t.Errorf("expected nil names, got %v", names)
}
if tt.wantFields != nil && len(names) != len(tt.wantFields) {
t.Errorf("expected %d field names, got %d", len(tt.wantFields), len(names))
}
})
}
}
func TestCompleteSchemaPathForSpec(t *testing.T) {
resources := map[string]interface{}{
"records": map[string]interface{}{
"methods": map[string]interface{}{
"create": map[string]interface{}{},
"list": map[string]interface{}{},
},
},
"record_permissions": map[string]interface{}{
"methods": map[string]interface{}{
"get": map[string]interface{}{},
},
},
}
got := completeSchemaPathForSpec("base", resources, "records.cr")
if len(got) != 1 || got[0] != "base.records.create" {
t.Fatalf("completions = %v, want [base.records.create]", got)
}
got = completeSchemaPathForSpec("base", resources, "record")
if len(got) != 2 || got[0] != "base.record_permissions." || got[1] != "base.records." {
t.Fatalf("resource completions = %v", got)
}
}
func TestFilterSpecByStrictMode_RemovesIncompatibleMethodsFromCompletionSource(t *testing.T) {
spec := map[string]interface{}{
"resources": map[string]interface{}{
"records": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{"accessTokens": []interface{}{"tenant"}},
"create": map[string]interface{}{"accessTokens": []interface{}{"user"}},
},
},
},
}
filtered := filterSpecByStrictMode(spec, core.StrictModeBot)
resources, _ := filtered["resources"].(map[string]interface{})
got := completeSchemaPathForSpec("base", resources, "records.")
if len(got) != 1 || got[0] != "base.records.list" {
t.Fatalf("filtered completions = %v, want [base.records.list]", got)
}
}

View File

@@ -1,80 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/meta"
)
// methodLong composes a method command's long help in one place: the
// description, the affordance guidance block (when the method has one), the
// pointer to the full schema, and the params-only addendum (params whose flag
// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance
// sits near the top so an agent sees when-to-use and few-shot examples before
// the flag list.
func methodLong(description, affordance, schemaPath, paramsOnly string) string {
var b strings.Builder
b.WriteString(description)
if affordance != "" {
b.WriteString("\n\n")
b.WriteString(affordance)
}
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
b.WriteString(paramsOnly)
return b.String()
}
// renderAffordance renders a method's affordance as a help block — when to use,
// prerequisites, and (most importantly for agents) few-shot Examples — or "" when
// the method carries no affordance. It reads the single typed model
// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape.
func renderAffordance(m meta.Method) string {
a, ok := m.ParsedAffordance()
if !ok {
return ""
}
var b strings.Builder
bullets := func(title string, items []string) {
var nonEmpty []string
for _, it := range items {
if strings.TrimSpace(it) != "" {
nonEmpty = append(nonEmpty, it)
}
}
if len(nonEmpty) == 0 {
return
}
fmt.Fprintf(&b, "%s:\n", title)
for _, it := range nonEmpty {
fmt.Fprintf(&b, " • %s\n", it)
}
}
bullets("When to use", a.UseWhen)
bullets("Avoid when", a.DoNotUseWhen)
bullets("Prerequisites", a.Prerequisites)
if len(a.Examples) > 0 {
var lines []string
for _, ex := range a.Examples {
if ex.Command == "" {
continue
}
if ex.Description != "" {
lines = append(lines, fmt.Sprintf(" • %s\n %s", ex.Description, ex.Command))
} else {
lines = append(lines, fmt.Sprintf(" • %s", ex.Command))
}
}
if len(lines) > 0 {
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
}
}
bullets("Related", a.Related)
return strings.TrimRight(b.String(), "\n")
}

View File

@@ -1,72 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
)
func TestRenderAffordance(t *testing.T) {
raw := json.RawMessage(`{
"use_when": ["发送文本消息"],
"do_not_use_when": ["群已解散"],
"prerequisites": ["已获取 chat_id"],
"examples": [
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
{"command":"lark-cli im messages list"},
{"description":"no command, skipped","command":""}
],
"related": ["im.messages.list"]
}`)
out := renderAffordance(meta.Method{Affordance: raw})
for _, want := range []string{
"When to use:", "发送文本消息",
"Avoid when:", "群已解散",
"Prerequisites:", "已获取 chat_id",
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
"lark-cli im messages list", // example with no description -> bare command line
"Related:", "im.messages.list",
} {
if !strings.Contains(out, want) {
t.Errorf("renderAffordance missing %q in:\n%s", want, out)
}
}
if strings.Contains(out, "no command, skipped") {
t.Errorf("example with empty command should be skipped:\n%s", out)
}
// Absent or empty affordance renders nothing (so methods without an overlay
// add nothing to their help).
if renderAffordance(meta.Method{}) != "" || renderAffordance(meta.Method{Affordance: json.RawMessage(`{}`)}) != "" {
t.Error("empty affordance should render nothing")
}
}
func TestServiceMethod_AffordanceInLong(t *testing.T) {
withAff := map[string]interface{}{
"path": "messages", "httpMethod": "POST", "description": "发送消息",
"affordance": map[string]interface{}{
"examples": []interface{}{
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
},
},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") {
t.Errorf("affordance examples not in command Long:\n%s", cmd.Long)
}
// A method with no affordance adds no guidance block.
plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"}
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil)
if strings.Contains(cmd2.Long, "Examples:") {
t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long)
}
}

View File

@@ -1,211 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// Flag annotations the grouped service-method help renderer reads.
const (
flagGroupAnnotation = "lark_flag_group" // display group key
flagSubAnnotation = "lark_flag_sub" // "required" | "optional" within API Parameters
flagNoteAnnotation = "lark_flag_note" // extra lines shown indented under a flag
groupParams = "params" // typed path/query flags
groupBody = "body" // --data, --file
groupRaw = "raw" // --params
groupExecution = "execution" // --as/--dry-run/--page-*/--yes
groupOutput = "output" // --output/--format/--jq
subRequired = "required"
subOptional = "optional"
)
// serviceFlagGroupOrder is the display order + titles of the flag groups. API
// Parameters carries only typed path/query flags; raw --params, request body and
// execution/output controls each get their own group so an agent can tell the
// distinct input kinds apart.
var serviceFlagGroupOrder = []struct{ key, title string }{
{groupParams, "API Parameters"},
{groupBody, "Request Body"},
{groupRaw, "Raw Parameter Input"},
{groupExecution, "Execution"},
{groupOutput, "Output"},
}
// applyGroupedUsage installs the grouped usage renderer on a service method
// cmd: local flags via the grouped renderer instead of cobra's flat Flags:
// list; global (inherited) flags and the Risk/Tips sections appended by the
// root help func are unaffected. Rendered by hand rather than via
// cmd.SetUsageTemplate: cobra lazy-links text/template on the first
// SetUsageTemplate call, whose executor reaches reflect.Value.MethodByName —
// that disables the linker's method-level dead-code elimination and costs
// ~19 MB of binary size.
func applyGroupedUsage(cmd *cobra.Command) {
cmd.SetUsageFunc(func(c *cobra.Command) error {
w := c.OutOrStderr()
fmt.Fprintf(w, "Usage:\n %s\n", c.UseLine())
if c.HasAvailableLocalFlags() {
fmt.Fprintf(w, "\n%s\n", renderServiceFlagGroups(c))
}
if c.HasAvailableInheritedFlags() {
fmt.Fprintf(w, "\nGlobal Flags:\n%s\n", strings.TrimRight(c.InheritedFlags().FlagUsages(), " \t\n"))
}
return nil
})
}
func annotate(f *pflag.Flag, key string, vals []string) {
if f.Annotations == nil {
f.Annotations = map[string][]string{}
}
f.Annotations[key] = vals
}
// tagFlagGroup records a flag's display group (no-op if the flag is absent).
func tagFlagGroup(fs *pflag.FlagSet, name, group string) {
if f := fs.Lookup(name); f != nil {
annotate(f, flagGroupAnnotation, []string{group})
}
}
func annotationOf(f *pflag.Flag, key string) []string {
if f.Annotations != nil {
return f.Annotations[key]
}
return nil
}
func flagGroupOf(f *pflag.Flag) string {
if v := annotationOf(f, flagGroupAnnotation); len(v) > 0 {
return v[0]
}
return ""
}
func flagSubOf(f *pflag.Flag) string {
if v := annotationOf(f, flagSubAnnotation); len(v) > 0 {
return v[0]
}
return ""
}
// renderServiceFlagGroups renders the command's local flags into ordered,
// titled groups; the API Parameters group is further split into Required /
// Optional. It is the body of the usage func applyGroupedUsage installs.
func renderServiceFlagGroups(cmd *cobra.Command) string {
var b strings.Builder
seen := map[*pflag.Flag]bool{}
for _, g := range serviceFlagGroupOrder {
flags := groupFlags(cmd, g.key, seen)
if len(flags) == 0 {
continue
}
fmt.Fprintf(&b, "%s:\n", g.title)
if g.key == groupParams {
writeSection(&b, " Required:", subFlags(flags, subRequired))
writeSection(&b, " Optional:", subFlags(flags, subOptional))
} else {
writeSection(&b, "", flags)
}
fmt.Fprintln(&b)
}
// Anything untagged (e.g. -h/--help) goes last under "Other".
var other []*pflag.Flag
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
if f.Hidden || seen[f] {
return
}
other = append(other, f)
})
if len(other) > 0 {
fmt.Fprintln(&b, "Other:")
writeSection(&b, "", other)
}
return strings.TrimRight(b.String(), "\n")
}
// groupFlags returns the visible local flags tagged with group key, marking them
// seen so the trailing "Other" bucket only catches genuinely untagged flags.
func groupFlags(cmd *cobra.Command, key string, seen map[*pflag.Flag]bool) []*pflag.Flag {
var flags []*pflag.Flag
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
if f.Hidden || flagGroupOf(f) != key {
return
}
flags = append(flags, f)
seen[f] = true
})
return flags
}
func subFlags(flags []*pflag.Flag, sub string) []*pflag.Flag {
var out []*pflag.Flag
for _, f := range flags {
s := flagSubOf(f)
// Untagged subgroup defaults to Optional so nothing is dropped.
if s == sub || (s == "" && sub == subOptional) {
out = append(out, f)
}
}
return out
}
// writeSection prints an optional (sub)header and the flags, aligned in a
// column, each flag row followed by its note lines indented under the usage.
func writeSection(b *strings.Builder, header string, flags []*pflag.Flag) {
if len(flags) == 0 {
return
}
if header != "" {
fmt.Fprintf(b, "%s\n", header)
}
specs := make([]string, len(flags))
maxSpec := 0
for i, f := range flags {
specs[i] = flagSpec(f)
if len(specs[i]) > maxSpec {
maxSpec = len(specs[i])
}
}
for i, f := range flags {
_, usage := pflag.UnquoteUsage(f)
if showsDefault(f) {
usage += fmt.Sprintf(" (default %s)", f.DefValue)
}
fmt.Fprintf(b, "%-*s %s\n", maxSpec, specs[i], strings.TrimSpace(usage))
for _, note := range annotationOf(f, flagNoteAnnotation) {
fmt.Fprintf(b, "%*s%s\n", maxSpec+3+4, "", note)
}
}
}
// flagSpec is pflag's " --name type" / " -x, --name type" left column.
func flagSpec(f *pflag.Flag) string {
typeName, _ := pflag.UnquoteUsage(f)
spec := " --" + f.Name
if f.Shorthand != "" && f.ShorthandDeprecated == "" {
spec = " -" + f.Shorthand + ", --" + f.Name
}
if typeName != "" {
spec += " " + typeName
}
return spec
}
// showsDefault mirrors pflag's "non-zero default" rule for the flag types these
// commands use, so the grouped rendering shows the same "(default x)" hints as
// cobra's flat list.
func showsDefault(f *pflag.Flag) bool {
switch f.DefValue {
case "", "0", "false", "[]":
return false
}
return true
}

View File

@@ -1,115 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
)
func TestServiceFlagGroups_AgentContract(t *testing.T) {
method := map[string]interface{}{
"path": "chats/:chat_id/members",
"httpMethod": "POST",
"parameters": map[string]interface{}{
"chat_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
"member_id_type": map[string]interface{}{
"type": "string", "location": "query",
"options": []interface{}{
map[string]interface{}{"value": "open_id", "description": "以 open_id 标识用户"},
map[string]interface{}{"value": "user_id", "description": "以 user_id 标识用户"},
},
},
},
// Documented body field -> --data belongs under Request Body.
"requestBody": map[string]interface{}{
"id_list": map[string]interface{}{"type": "list", "required": true},
},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "create", "chat.members", nil)
out := renderServiceFlagGroups(cmd)
idx := func(s string) int { return strings.Index(out, s) }
// Section order: API Parameters → Request Body → Raw Parameter Input → Execution → Output.
iParams, iBody, iRaw, iExec, iOut := idx("API Parameters:"), idx("Request Body:"), idx("Raw Parameter Input:"), idx("Execution:"), idx("Output:")
for name, i := range map[string]int{"API Parameters": iParams, "Request Body": iBody, "Raw Parameter Input": iRaw, "Execution": iExec, "Output": iOut} {
if i < 0 {
t.Fatalf("missing section %q in:\n%s", name, out)
}
}
if !(iParams < iBody && iBody < iRaw && iRaw < iExec && iExec < iOut) {
t.Errorf("section order wrong:\n%s", out)
}
// Required/Optional subsections under API Parameters.
if i := idx(" Required:"); i < iParams || i > iBody {
t.Errorf("Required subsection misplaced:\n%s", out)
}
if i := idx(" Optional:"); i < iParams || i > iBody {
t.Errorf("Optional subsection misplaced:\n%s", out)
}
// Typed flags are API Parameters; required path flag under Required, enum
// flag under Optional with an inline "enum: ..." (not multi-line meanings).
if i := idx("--chat-id"); i < iParams || i > iBody {
t.Errorf("--chat-id not under API Parameters:\n%s", out)
}
if !strings.Contains(out, "chat_id, required") {
t.Errorf("typed flag help format wrong:\n%s", out)
}
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
t.Errorf("expected compact enum value=meaning inline:\n%s", out)
}
// --data is Request Body; --params is Raw Parameter Input (NOT API Parameters)
// and carries the precedence rule.
if i := idx("--data"); i < iBody || i > iRaw {
t.Errorf("--data not under Request Body:\n%s", out)
}
if i := idx("--params"); i < iRaw || i > iExec {
t.Errorf("--params not under Raw Parameter Input:\n%s", out)
}
if !strings.Contains(out, "typed flags override matching keys in --params") {
t.Errorf("missing --params precedence rule:\n%s", out)
}
// Control flags land in Execution/Output.
if i := idx("--dry-run"); i < iExec || i > iOut {
t.Errorf("--dry-run not under Execution:\n%s", out)
}
if idx("--format") < iOut {
t.Errorf("--format not under Output:\n%s", out)
}
// The usage template is wired to the grouped renderer (no flat Flags: list).
if u := cmd.UsageString(); !strings.Contains(u, "API Parameters:") || strings.Contains(u, "\nFlags:\n") {
t.Errorf("usage template not grouped:\n%s", u)
}
}
// TestServiceFlagGroups_UndocumentedBodyIsRaw: a POST with no documented body
// fields still offers --data (escape hatch) but must NOT imply a declared body —
// it goes under Raw Parameter Input, not "Request Body".
func TestServiceFlagGroups_UndocumentedBodyIsRaw(t *testing.T) {
method := map[string]interface{}{"path": "things/do", "httpMethod": "POST"} // POST, no requestBody, no params
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "do", "things", nil)
out := renderServiceFlagGroups(cmd)
if strings.Contains(out, "Request Body:") {
t.Errorf("undocumented body must not render a Request Body section:\n%s", out)
}
iRaw, iData := strings.Index(out, "Raw Parameter Input:"), strings.Index(out, "--data")
if iRaw < 0 || iData < iRaw {
t.Errorf("--data not under Raw Parameter Input:\n%s", out)
}
if !strings.Contains(out, "no documented fields") {
t.Errorf("--data should be labeled a raw escape hatch:\n%s", out)
}
}

View File

@@ -1,166 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
type boundParamFlag struct {
field meta.Field
read func() interface{}
}
// paramsOnlyField is a path/query parameter that got no typed flag because its
// kebab name is already taken by another flag (a standard flag like --format, or
// a root persistent flag). It stays reachable via --params; the binder keeps it,
// with the flag that claimed the name, so --help can show the exact --params form
// and steer the reader off the wrong flag.
type paramsOnlyField struct {
field meta.Field
claimed *pflag.Flag
}
// paramFlagBinder owns one service method's generated typed param flags: it
// registers them (kind, help, enum completion, reserved-name skip) and applies
// the --params overlay, where a changed typed flag overrides its key in the
// --params JSON. Holding the field<->flag binding here keeps the request builder
// from re-deriving which flags map to which param keys.
type paramFlagBinder struct {
bound []boundParamFlag
paramsOnly []paramsOnlyField
}
// newParamFlagBinder registers one typed kebab flag per path/query parameter on
// cmd and returns a binder for the --params overlay. A name already taken by
// another flag is skipped — pflag panics on a local duplicate and a generated
// flag would silently shadow a persistent one — and recorded as paramsOnly so
// the parameter stays reachable (and discoverable) via --params. The taken set
// is derived, not hand-listed: local flags (the standard set, registered before
// this runs) via cmd, the lazily-added --help materialized here, and the root's
// persistent flags via reserved (nil for direct callers that have no root).
func newParamFlagBinder(cmd *cobra.Command, params []meta.Field, reserved *pflag.FlagSet) *paramFlagBinder {
cmd.InitDefaultHelpFlag() // materialize --help/-h so the local guard below sees it
b := &paramFlagBinder{}
for _, f := range params {
name := f.FlagName()
if claimed := flagClaiming(cmd, reserved, name); claimed != nil {
b.paramsOnly = append(b.paramsOnly, paramsOnlyField{field: f, claimed: claimed})
continue
}
read := registerTypedFlag(cmd.Flags(), name, f.CanonicalType(), paramFlagUsage(f))
if values := enumStrings(f.EnumValues()); len(values) > 0 {
cmdutil.RegisterFlagCompletion(cmd, name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return values, cobra.ShellCompDirectiveNoFileComp
})
}
// Group as an API parameter and mark required/optional for the
// Required/Optional subsections of the grouped --help renderer.
if fl := cmd.Flags().Lookup(name); fl != nil {
annotate(fl, flagGroupAnnotation, []string{groupParams})
sub := subOptional
if f.Required {
sub = subRequired
}
annotate(fl, flagSubAnnotation, []string{sub})
}
b.bound = append(b.bound, boundParamFlag{field: f, read: read})
}
return b
}
// flagClaiming returns the flag already occupying name (so a typed param flag
// would collide), or nil when the name is free. It checks the command's own
// flags (the standard set + the materialized --help) and the root's persistent
// flags — so the reserved set is whatever is actually registered, never a
// hand-kept list that drifts when a global flag is added.
func flagClaiming(cmd *cobra.Command, reserved *pflag.FlagSet, name string) *pflag.Flag {
if fl := cmd.Flags().Lookup(name); fl != nil {
return fl
}
if reserved != nil {
return reserved.Lookup(name)
}
return nil
}
// paramsOnlyHelp renders the --help addendum for parameters that have no typed
// flag, or "" when there are none. Per field: a copy-pasteable --params form,
// the same fieldFacts a typed flag would show on its usage line, and what the
// colliding flag actually does — so neither a human nor an agent sets the
// wrong one (e.g. --format, which is the output format, not the API parameter).
func (b *paramFlagBinder) paramsOnlyHelp() string {
if len(b.paramsOnly) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("\nParameters set via --params (no typed flag; the name is taken by another flag):\n")
for _, p := range b.paramsOnly {
name := p.field.Name
fmt.Fprintf(&sb, " %s: --params '{%q: %s}'\n", name, name, paramExample(p.field))
for _, fact := range fieldFacts(p.field) {
fmt.Fprintf(&sb, " %s\n", fact)
}
if p.claimed != nil {
fmt.Fprintf(&sb, " do not use --%s (%s)\n", p.claimed.Name, p.claimed.Usage)
}
}
return sb.String()
}
// hasTypedFlag reports whether the binder registered a typed flag for the
// param named name. False for params-only fields — a flag with the same kebab
// name may exist (that's the collision), but it is not this param's input.
// Nil-safe for direct buildServiceRequest callers that have no binder.
func (b *paramFlagBinder) hasTypedFlag(name string) bool {
if b == nil {
return false
}
for _, pf := range b.bound {
if pf.field.Name == name {
return true
}
}
return false
}
// overlay lets an explicit typed flag override the same key in --params
// (--params is the base). Only changed flags apply, so the --params-only path is
// unchanged. A nil binder or cmd is a no-op.
func (b *paramFlagBinder) overlay(cmd *cobra.Command, params map[string]interface{}) {
if b == nil || cmd == nil {
return
}
for _, pf := range b.bound {
if cmd.Flags().Changed(pf.field.FlagName()) {
params[pf.field.Name] = pf.read()
}
}
}
// registerTypedFlag registers one flag of the given canonical JSON-Schema kind
// and returns a reader for its parsed value; the kind→pflag-type switch lives
// only here.
func registerTypedFlag(fs *pflag.FlagSet, name, kind, usage string) func() interface{} {
switch kind {
case "integer":
return flagReader(fs.Int(name, 0, usage))
case "boolean":
return flagReader(fs.Bool(name, false, usage))
case "array":
return flagReader(fs.StringArray(name, nil, usage))
default:
return flagReader(fs.String(name, "", usage))
}
}
func flagReader[T any](p *T) func() interface{} {
return func() interface{} { return *p }
}

View File

@@ -1,626 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// imChatMembersCreate: POST chats/{chat_id}/members with one path param and one
// optional enum query param — the canonical case from the screenshot feedback.
func imChatMembersCreate() meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "chats/{chat_id}/members",
"httpMethod": "POST",
"parameters": map[string]interface{}{
"chat_id": map[string]interface{}{
"type": "string", "location": "path", "required": true,
},
"member_id_type": map[string]interface{}{
"type": "string", "location": "query", "required": false,
"options": []interface{}{
map[string]interface{}{"value": "open_id"},
map[string]interface{}{"value": "user_id"},
},
},
},
})
}
func TestServiceMethod_TypedFlagRegistered(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
if cmd.Flags().Lookup("chat-id") == nil {
t.Error("expected generated --chat-id flag for path param chat_id")
}
if cmd.Flags().Lookup("member-id-type") == nil {
t.Error("expected generated --member-id-type flag for query param member_id_type")
}
}
// A query param literally named "format" kebab-collides with the global
// --format flag. Generation must skip it (never re-register, never panic) and
// leave the standard --format flag intact.
func TestServiceMethod_TypedFlagReservedCollisionSkipped(t *testing.T) {
method := map[string]interface{}{
"path": "messages",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"format": map[string]interface{}{"type": "string", "location": "query"},
},
}
var cmd *cobra.Command
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("flag generation panicked on reserved-name collision: %v", r)
}
}()
cmd = NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "list", "messages", nil)
}()
fl := cmd.Flags().Lookup("format")
if fl == nil || fl.DefValue != "json" {
t.Fatalf("standard --format flag must be preserved, got %+v", fl)
}
}
func TestServiceMethod_TypedFlag_DrivesPathParam(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--data", `{"id_list":["ou_x"]}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
t.Errorf("expected URL with chat_id substituted from --chat-id, got:\n%s", stdout.String())
}
}
func TestServiceMethod_TypedFlag_DrivesQueryParam(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--member-id-type", "open_id", "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "member_id_type") || !strings.Contains(out, "open_id") {
t.Errorf("expected query param member_id_type=open_id from flag, got:\n%s", out)
}
}
func TestServiceMethod_TypedFlag_AgreesWithParams(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("same value via flag and --params should be accepted, got: %v", err)
}
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
t.Errorf("expected URL with chat_id, got:\n%s", stdout.String())
}
}
// --params is the base; an explicit typed flag overrides the same key.
func TestServiceMethod_TypedFlag_OverridesParams(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_flag", "--params", `{"chat_id":"oc_params"}`, "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "chats/oc_flag/members") {
t.Errorf("expected --chat-id to override --params chat_id, got:\n%s", out)
}
if strings.Contains(out, "oc_params") {
t.Errorf("--params value should have been overridden by the flag, got:\n%s", out)
}
}
// Override works for a non-string (integer) param too, exercising the int
// register/read path end to end.
func TestServiceMethod_TypedFlag_IntegerOverridesParams(t *testing.T) {
method := map[string]interface{}{
"path": "messages",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"type": "integer", "location": "query"},
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "messages", nil)
cmd.SetArgs([]string{"--page-size", "100", "--params", `{"page_size":5}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "page_size") || !strings.Contains(out, "100") {
t.Errorf("expected --page-size 100 to override --params page_size=5, got:\n%s", out)
}
}
// Regression: with no typed flags passed, behavior is byte-identical to today.
func TestServiceMethod_TypedFlag_OnlyParamsStillWorks(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
t.Errorf("expected URL with chat_id from --params, got:\n%s", stdout.String())
}
}
// Regression: --params null is valid JSON that unmarshals to a nil map. A typed
// flag overlaying onto it must not panic (assignment to a nil map) — null is
// treated as "no base params", with the flag value applied on top.
func TestServiceMethod_TypedFlag_OverridesNullParams(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", "null", "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with a typed flag should not error, got: %v", err)
}
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
t.Errorf("expected chat_id from --chat-id over null --params, got:\n%s", stdout.String())
}
}
// Startup smoke test: registering every embedded method must not panic on a
// generated-flag name collision (pflag panics on duplicate registration, which
// would crash the whole CLI at startup), and a known path param must surface as
// a typed flag end to end.
func TestRegisterServiceCommands_GeneratesFlagsNoPanic(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
f := &cmdutil.Factory{}
defer func() {
if r := recover(); r != nil {
t.Fatalf("registering all service commands panicked: %v", r)
}
}()
RegisterServiceCommands(root, f)
create, _, err := root.Find([]string{"im", "chat.members", "create"})
if err != nil {
t.Fatalf("im chat.members create not registered: %v", err)
}
if create.Flags().Lookup("chat-id") == nil {
t.Error("expected generated --chat-id flag on im chat.members create")
}
}
// Locks the boolean and array branches of bindParamFlag end to end (string and
// integer are covered above): a bool flag yields true and a repeatable array
// flag yields all its elements in the request.
func TestServiceMethod_TypedFlag_BoolAndArrayKinds(t *testing.T) {
method := map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query"},
"ids": map[string]interface{}{"type": "list", "location": "query"},
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil)
cmd.SetArgs([]string{"--with-deleted", "--ids", "a", "--ids", "b", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"with_deleted", "true", "ids", "\"a\"", "\"b\""} {
if !strings.Contains(out, want) {
t.Errorf("expected dry-run output to contain %q, got:\n%s", want, out)
}
}
}
// Override (--params base, typed flag wins) is covered for string and integer
// above; this locks the same semantics for the boolean and array kinds.
func TestServiceMethod_TypedFlag_BoolAndArrayOverrideParams(t *testing.T) {
method := map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query"},
"ids": map[string]interface{}{"type": "list", "location": "query"},
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil)
cmd.SetArgs([]string{
"--params", `{"with_deleted":false,"ids":["from_params"]}`,
"--with-deleted", "--ids", "a", "--ids", "b",
"--dry-run",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"with_deleted", "true", "\"a\"", "\"b\""} {
if !strings.Contains(out, want) {
t.Errorf("expected flag to override --params (want %q), got:\n%s", want, out)
}
}
if strings.Contains(out, "from_params") {
t.Errorf("--params array value should have been overridden by --ids, got:\n%s", out)
}
}
// A param whose kebab name collides with a global flag (here "format" vs the
// global --format) gets no typed flag, but the collision is no longer silent:
// non-colliding params still get flags, the global --format is untouched, and
// --help shows the exact --params form and steers the reader off --format.
func TestServiceMethod_ParamsOnly_HelpSteersToParams(t *testing.T) {
method := map[string]interface{}{
"path": "things/{thing_id}",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"thing_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
"format": map[string]interface{}{"type": "string", "location": "query", "min": "1", "max": "64", "description": "返回的消息体格式。", "options": []interface{}{
map[string]interface{}{"value": "full"},
map[string]interface{}{"value": "metadata"},
}},
},
}
cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "get", "things", nil)
if cmd.Flags().Lookup("thing-id") == nil {
t.Error("non-colliding param should still get a typed --thing-id flag")
}
if fl := cmd.Flags().Lookup("format"); fl == nil || fl.DefValue != "json" {
t.Fatalf("global --format must be preserved (not shadowed), got %+v", fl)
}
for _, want := range []string{`--params '{"format"`, "返回的消息体格式", "full", "metadata", "min: 1, max: 64", "do not use --format"} {
if !strings.Contains(cmd.Long, want) {
t.Errorf("help should contain %q so the reader uses --params, not --format; got:\n%s", want, cmd.Long)
}
}
}
// The collision guard derives reserved names from the actual flag sets — local
// flags plus the root's persistent flags passed in — so a future persistent
// flag is covered with no hand-maintained list. Here a param named "profile"
// (a root persistent flag) is skipped while a normal param is bound.
func TestParamFlagBinder_PersistentFlagReserved(t *testing.T) {
cmd := &cobra.Command{Use: "x"}
reserved := pflag.NewFlagSet("root", pflag.ContinueOnError)
reserved.String("profile", "", "use a specific profile")
m := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"profile": map[string]interface{}{"type": "string", "location": "query"},
"id": map[string]interface{}{"type": "string", "location": "path"},
}})
b := newParamFlagBinder(cmd, m.Params(), reserved)
if cmd.Flags().Lookup("id") == nil {
t.Error("non-colliding param should get a typed flag")
}
if cmd.Flags().Lookup("profile") != nil {
t.Error("param colliding with a reserved persistent flag must not be registered")
}
found := false
for _, p := range b.paramsOnly {
if p.field.Name == "profile" {
found = true
}
}
if !found {
t.Error("colliding param should be recorded for the --params help note")
}
}
// boolIntQueryMethod is the fixture for the zero-value semantics tests: one
// boolean and one integer query param, where false and 0 are meaningful values.
func boolIntQueryMethod(required bool) meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query", "required": required},
"page_size": map[string]interface{}{"type": "integer", "location": "query"},
},
})
}
// Presence is intent: a typed flag is only overlaid when explicitly Changed,
// so --flag=false / --flag 0 are real values and must be sent — not silently
// dropped as "empty", which would let the API default win over an explicit
// user choice.
func TestServiceMethod_TypedFlag_ExplicitFalseAndZeroAreSent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil)
cmd.SetArgs([]string{"--with-deleted=false", "--page-size", "0", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} {
if !strings.Contains(out, want) {
t.Errorf("explicit zero value must be sent (want %s), got:\n%s", want, out)
}
}
}
// An explicitly provided false satisfies a required query parameter — the
// pre-flight must not report "missing" for a value the user just set.
func TestServiceMethod_TypedFlag_ExplicitFalseSatisfiesRequired(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(true), "list", "items", nil)
cmd.SetArgs([]string{"--with-deleted=false", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("required param explicitly set to false must pass pre-flight, got: %v", err)
}
if !strings.Contains(stdout.String(), `"with_deleted": false`) {
t.Errorf("explicit false must be sent, got:\n%s", stdout.String())
}
}
// The same presence-is-intent rule applies to the --params JSON base: a key
// deliberately written as false/0 is sent. (Zero values used to be silently
// dropped; this locks the corrected semantics as the contract.)
func TestServiceMethod_Params_JSONZeroValuesAreSent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil)
cmd.SetArgs([]string{"--params", `{"with_deleted":false,"page_size":0}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} {
if !strings.Contains(out, want) {
t.Errorf("--params zero value must be sent (want %s), got:\n%s", want, out)
}
}
}
// "" stays unusable: a required parameter fed an empty-string placeholder is
// still caught by the friendly pre-flight error, not sent as an empty value.
func TestServiceMethod_Params_EmptyStringStillMissing(t *testing.T) {
method := meta.FromMap(map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"user_id_type": map[string]interface{}{"type": "string", "location": "query", "required": true},
},
})
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{"user_id_type":""}`, "--dry-run"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "missing required query parameter") {
t.Fatalf("empty string for a required param should still pre-flight error, got: %v", err)
}
}
// A declared optional query param fed "" is dropped (unusable value), not sent
// as an empty query value — the declared-param loop owns the decision and the
// undeclared passthrough must not resurrect it. Undeclared keys stay the
// verbatim raw escape hatch.
func TestServiceMethod_Params_EmptyOptionalDroppedUndeclaredKept(t *testing.T) {
method := meta.FromMap(map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"user_id_type": map[string]interface{}{"type": "string", "location": "query"},
},
})
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{"user_id_type":"","custom_key":"v1"}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if strings.Contains(out, "user_id_type") {
t.Errorf("declared optional param with empty value must be dropped, got:\n%s", out)
}
if !strings.Contains(out, `"custom_key": "v1"`) {
t.Errorf("undeclared key must pass through verbatim, got:\n%s", out)
}
}
// min/max from the metadata surface on the typed flag's help line, in the same
// vocabulary as the envelope's minimum/maximum.
func TestParamFlagUsage_Bounds(t *testing.T) {
cases := []struct{ name, min, max, want string }{
{"both", "1", "100", "min: 1, max: 100"},
{"min only", "1", "", "min: 1"},
{"max only", "", "64", "max: 64"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"type": "integer", "location": "query", "min": tc.min, "max": tc.max},
}}).Params()
if usage := paramFlagUsage(fields[0]); !strings.Contains(usage, tc.want) {
t.Errorf("usage = %q, want contains %q", usage, tc.want)
}
})
}
t.Run("no bounds, no clause", func(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"page_token": map[string]interface{}{"type": "string", "location": "query"},
}}).Params()
if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "min:") || strings.Contains(usage, "max:") {
t.Errorf("usage without bounds should not mention min/max, got %q", usage)
}
})
}
// The sanitized field description rides the help line — a bare name like
// user_mailbox_id carries no meaning. The cut is at note separators (;), NOT
// at sentence ends (。): the later sentence often holds the key affordance.
func TestParamFlagUsage_Description(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"user_mailbox_id": map[string]interface{}{
"type": "string", "location": "path", "required": true,
"description": `用户邮箱地址。当使用用户身份访问时,可以输入"me"代表当前调用接口用户;后续补充说明不该出现`,
},
}}).Params()
usage := paramFlagUsage(fields[0])
if !strings.Contains(usage, `可以输入"me"代表当前调用接口用户`) {
t.Errorf("description must keep full sentences up to the note separator, got %q", usage)
}
if strings.Contains(usage, "补充说明") {
t.Errorf("text after the note separator must be cut, got %q", usage)
}
t.Run("long description truncated", func(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"x": map[string]interface{}{
"type": "string", "location": "query",
"description": strings.Repeat("长", 80),
},
}}).Params()
usage := paramFlagUsage(fields[0])
if !strings.Contains(usage, "...") {
t.Errorf("long description should be truncated with ellipsis, got %q", usage)
}
if strings.Contains(usage, strings.Repeat("长", 61)) {
t.Errorf("description should not exceed the cap, got %q", usage)
}
})
t.Run("trailing sentence punctuation trimmed", func(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"x": map[string]interface{}{
"type": "string", "location": "query", "description": "返回格式。",
},
}}).Params()
if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "。.") {
t.Errorf("clause join must not double the punctuation, got %q", usage)
}
})
}
// Pins the convergence contract: the params-only addendum renders the SAME
// fieldFacts list the typed flag's usage line joins inline — a fact added to
// fieldFacts reaches both surfaces, and neither can drift over what a param's
// help says (the addendum once rendered values-only enums and silently lacked
// the API default).
func TestParamHelp_BothSurfacesRenderFieldFacts(t *testing.T) {
f := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"mode": map[string]interface{}{
"type": "string", "location": "query",
"description": "模式选择。",
"default": "fast",
"min": "1", "max": "8",
"options": []interface{}{
map[string]interface{}{"value": "fast", "description": "快速"},
map[string]interface{}{"value": "full"},
},
},
}}).Params()[0]
facts := fieldFacts(f)
if len(facts) != 4 { // description, enum, bounds, API default
t.Fatalf("fieldFacts = %v, want 4 facts", facts)
}
usage := paramFlagUsage(f)
help := (&paramFlagBinder{paramsOnly: []paramsOnlyField{{field: f}}}).paramsOnlyHelp()
for _, fact := range facts {
if !strings.Contains(usage, fact) {
t.Errorf("usage line missing fact %q: %q", fact, usage)
}
if !strings.Contains(help, fact) {
t.Errorf("params-only addendum missing fact %q:\n%s", fact, help)
}
}
}
// Bounds reach the registered flag's help end to end.
func TestServiceMethod_TypedFlag_HelpShowsBounds(t *testing.T) {
method := meta.FromMap(map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"type": "integer", "location": "query", "min": "1", "max": "100", "default": "20"},
},
})
cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), method, "list", "items", nil)
fl := cmd.Flags().Lookup("page-size")
if fl == nil {
t.Fatal("expected generated --page-size flag")
}
if !strings.Contains(fl.Usage, "min: 1, max: 100") {
t.Errorf("flag usage should carry bounds, got %q", fl.Usage)
}
}
// The missing-required hint must name both recovery paths — the typed flag and
// the --params fallback — so a reader who only knows one input style can
// proceed without a round-trip through schema.
func TestServiceMethod_MissingRequired_HintNamesFlagAndParams(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--data", `{"id_list":["ou_x"]}`, "--dry-run"})
err := cmd.Execute()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
for _, want := range []string{"--chat-id", `--params '{"chat_id": "<value>"}'`, "lark-cli schema im.chat.members.create"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint %q should contain %q", ve.Hint, want)
}
}
}
// A params-only required field (kebab name claimed by the standard --format
// flag) has no typed flag to offer: the hint must give only the --params form,
// never steer the reader to the colliding flag.
func TestServiceMethod_MissingRequired_ParamsOnlyHintSkipsFlag(t *testing.T) {
method := meta.FromMap(map[string]interface{}{
"path": "messages",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"format": map[string]interface{}{"type": "string", "location": "query", "required": true},
},
})
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "messages", nil)
cmd.SetArgs([]string{"--dry-run"})
err := cmd.Execute()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(ve.Hint, `--params '{"format": "<value>"}'`) {
t.Errorf("hint %q should carry the --params form", ve.Hint)
}
if strings.Contains(ve.Hint, "set --format") {
t.Errorf("hint %q must not steer to the colliding --format flag", ve.Hint)
}
}

View File

@@ -1,162 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Help rendering for generated param flags. fieldFacts is the single list of
// agent-relevant facts a param exposes; every help surface (the typed flag's
// usage line, the params-only --params addendum) renders that one list, so the
// surfaces cannot drift over which facts exist. Values come from the
// meta.Field accessors, so nothing here depends on internal/schema.
package service
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/util"
)
// fieldFacts returns a param field's facts in display order, each as a compact
// one-line clause: the sanitized description, the allowed enum values (with
// meanings), the min/max constraint, and the API default. This is the ONE
// place that decides what a param's help says — add a fact here (e.g. a future
// deprecation marker) and every surface shows it. Unabridged prose and
// per-option detail stay in `lark-cli schema`.
func fieldFacts(f meta.Field) []string {
var facts []string
if d := sanitizeFieldDesc(f.Description); d != "" {
facts = append(facts, d)
}
if opts := f.EnumOptions(); len(opts) > 0 {
facts = append(facts, "enum: "+formatEnumInline(opts))
}
if b := formatBoundsInline(f); b != "" {
facts = append(facts, b)
}
if s := literalStr(f.CoercedDefault()); s != "" {
facts = append(facts, "API default: "+s)
}
return facts
}
// paramFlagUsage renders the typed param flag's help line:
//
// <param_name>, required|optional[. <fact>]...
//
// It leads with the canonical underscore param name (the key this flag
// overrides in --params) and required/optional, then joins the field's facts
// inline.
func paramFlagUsage(f meta.Field) string {
req := "optional"
if f.Required {
req = "required"
}
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
return strings.Join(parts, ". ") + "."
}
// paramExample picks a concrete sample for a params-only field's --help snippet:
// its first allowed enum value, else its example, else a placeholder.
func paramExample(f meta.Field) string {
if vals := enumStrings(f.EnumValues()); len(vals) > 0 {
return fmt.Sprintf("%q", vals[0])
}
if s := literalStr(f.CoercedExample()); s != "" {
return fmt.Sprintf("%q", s)
}
return `"<value>"`
}
var markdownLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`)
// inlineClause compresses metadata prose into one help clause: markdown links
// keep their text, the clause cuts at the first rune in stops, whitespace
// collapses, trailing punctuation goes — sentence enders (the clause join adds
// its own) and connectors a cut can strand, like a colon introducing a list the
// newline cut dropped — and the result caps at max runes. The two policies
// below differ only in where they cut and how much they keep.
func inlineClause(s, stops string, max int) string {
if s == "" {
return ""
}
s = markdownLinkRe.ReplaceAllString(s, "$1")
// Backquotes must go: pflag's UnquoteUsage treats a backquoted word in a
// flag's usage string as the flag's metavar, so a description like wiki
// space_id's "可替换为`my_library`" would render the flag as
// "--space-id my_library" instead of "--space-id string".
s = strings.ReplaceAll(s, "`", "")
if i := strings.IndexAny(s, stops); i >= 0 {
s = s[:i]
}
s = strings.Join(strings.Fields(s), " ")
s = strings.TrimRight(s, "。.:,、")
return util.TruncateStrWithEllipsis(s, max)
}
// sanitizeOptionDesc is the enum-option policy: many values share one line, so
// keep only the first clause (cut at 。 too) and stay ultra-compact.
func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r", 40) }
// sanitizeFieldDesc is the field-description policy: one line per field, so
// keep full sentences and cut only at note separators (meta_data appends
// bullet notes after ;/) — the later sentence often carries the key
// affordance, e.g. user_mailbox_id's `可以输入"me"`.
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";\n\r", 60) }
// formatEnumInline renders allowed values for the help line: "v=meaning" when
// the value carries a (sanitized, truncated) description — so opaque numeric
// enums like succeed_type read as "0=…|1=…|2=…" — else just "v". Full meanings
// live in the envelope's enumDescriptions / `lark-cli schema`.
func formatEnumInline(opts []meta.EnumOption) string {
items := make([]string, len(opts))
for i, o := range opts {
if d := sanitizeOptionDesc(o.Description); d != "" {
items[i] = fmt.Sprintf("%v=%s", o.Value, d)
} else {
items[i] = fmt.Sprintf("%v", o.Value)
}
}
return strings.Join(items, "|")
}
// formatBoundsInline renders the field's min/max constraint ("min: 1, max:
// 100", or the single declared side), or "" when the field declares neither.
// The vocabulary matches the envelope's minimum/maximum, so help and `lark-cli
// schema` state the same constraint.
func formatBoundsInline(f meta.Field) string {
min, max := f.MinBound(), f.MaxBound()
switch {
case min != nil && max != nil:
return fmt.Sprintf("min: %s, max: %s", formatBound(*min), formatBound(*max))
case min != nil:
return "min: " + formatBound(*min)
case max != nil:
return "max: " + formatBound(*max)
}
return ""
}
// formatBound renders a bound without a float artifact (100 not 100.000000).
func formatBound(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
// literalStr renders a coerced literal (default/example) for flag help,
// returning "" for a nil or empty value so the caller can omit the clause.
func literalStr(v interface{}) string {
if v == nil {
return ""
}
return fmt.Sprintf("%v", v)
}
func enumStrings(enum []interface{}) []string {
out := make([]string, 0, len(enum))
for _, e := range enum {
out = append(out, fmt.Sprintf("%v", e))
}
return out
}

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"strings"
"testing"
)
func TestSanitizeOptionDesc(t *testing.T) {
cases := map[string]string{
"": "",
"以 open_id 标识用户": "以 open_id 标识用户",
"中文。English second clause": "中文", // first clause only (。)
"headtail": "head", // first clause ()
"line one\nline two": "line one", // first clause (newline)
" spaced out ": "spaced out", // whitespace collapsed
"see [飞书后台](https://x/admin) 详情": "see 飞书后台 详情", // markdown link -> text, url dropped
}
for in, want := range cases {
if got := sanitizeOptionDesc(in); got != want {
t.Errorf("sanitizeOptionDesc(%q) = %q, want %q", in, got, want)
}
}
// Truncation: a long single clause is cut to 40 runes with an ellipsis,
// rune-safe (no split mid-character).
long := strings.Repeat("文", 60)
got := sanitizeOptionDesc(long)
if r := []rune(got); len(r) != 40 || !strings.HasSuffix(got, "...") {
t.Errorf("truncation = %q (%d runes), want 40 runes ending in ...", got, len(r))
}
}
func TestSanitizeFieldDesc_TrimsDanglingPunctuation(t *testing.T) {
// A clause cut can strand a connector (e.g. a colon introducing a list the
// newline cut drops, as in im.reactions.list's message_id); the help line
// joiner then renders "…获取方式:." — so dangling punctuation must go too.
cases := map[string]string{
"待查询的消息ID。ID 获取方式:\n- 调用接口获取": "待查询的消息ID。ID 获取方式",
"see the list below:\nitem": "see the list below",
"逗号结尾,\n下一行": "逗号结尾",
}
for in, want := range cases {
if got := sanitizeFieldDesc(in); got != want {
t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want)
}
}
}
func TestSanitizeFieldDesc_StripsBackquotes(t *testing.T) {
// pflag's UnquoteUsage takes a backquoted word in a flag's usage string as
// the flag's metavar: wiki space_id's description rendered the flag as
// "--space-id my_library" instead of "--space-id string".
in := "[知识空间id](https://x/wiki),如果查询我的文档库可替换为`my_library`"
want := "知识空间id如果查询我的文档库可替换为my_library"
if got := sanitizeFieldDesc(in); got != want {
t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want)
}
}

View File

@@ -10,21 +10,18 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// RegisterServiceCommands registers all service commands from from_meta specs.
@@ -33,79 +30,85 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
}
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
RegisterServiceCommandsFromCatalog(ctx, parent, f, registry.RuntimeCatalog())
}
func RegisterServiceCommandsFromCatalog(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory, catalog apicatalog.Catalog) {
// Drive the service list from the same navigation catalog the method walk
// uses, so registration is catalog-sourced end to end. Kept as a per-service
// loop rather than a flat WalkMethods(nil) drive precisely so a service with
// no methods still gets its bare command (WalkMethods yields one ref per
// method, so empty services would vanish).
for _, svc := range catalog.Services() {
if svc.Name == "" || svc.ServicePath == "" {
for _, project := range registry.ListFromMetaProjects() {
spec := registry.LoadFromMeta(project)
if spec == nil {
continue
}
registerServiceWithContext(ctx, parent, svc, f)
}
}
func registerService(parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) {
registerServiceWithContext(context.Background(), parent, svc, f)
}
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) {
svcCmd := ensureChildCommand(parent, svc.Name, serviceShort(svc))
// Build the service's subtree from the catalog's method walk
// (apicatalog.ServiceMethods recurses nested resources), so the command tree
// is sourced from the same navigation Module as schema/scope rather than a
// hand-rolled resource/method walk. Each ref's ResourcePath becomes the
// resource-command chain — one level for a flat dotted resource like
// "chat.members", deeper for genuinely nested resources. A service with no
// methods keeps its bare command (svcCmd is created above regardless).
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
resCmd := svcCmd
for _, seg := range ref.ResourcePath {
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
specName := registry.GetStrFromMap(spec, "name")
servicePath := registry.GetStrFromMap(spec, "servicePath")
if specName == "" || servicePath == "" {
continue
}
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
continue
}
registerServiceWithContext(ctx, parent, spec, resources, f)
}
}
// serviceShort is the service command's help summary: the localized description
// from the registry, falling back to the metadata's own description.
func serviceShort(svc meta.Service) string {
if d := registry.GetServiceDescription(svc.Name, "en"); d != "" {
return d
}
return svc.Description
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
registerServiceWithContext(context.Background(), parent, spec, resources, f)
}
// ensureChildCommand returns the child of parent named name, creating it (with
// short) when absent — so re-registration merges into an existing command tree
// instead of duplicating a level.
func ensureChildCommand(parent *cobra.Command, name, short string) *cobra.Command {
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
specName := registry.GetStrFromMap(spec, "name")
specDesc := registry.GetServiceDescription(specName, "en")
if specDesc == "" {
specDesc = registry.GetStrFromMap(spec, "description")
}
// Find existing service command or create one
var svc *cobra.Command
for _, c := range parent.Commands() {
if c.Name() == name {
cmdmeta.SetSource(c, cmdmeta.SourceService, true)
return c
if c.Name() == specName {
svc = c
break
}
}
cmd := &cobra.Command{Use: name, Short: short}
cmdmeta.SetSource(cmd, cmdmeta.SourceService, true)
parent.AddCommand(cmd)
return cmd
if svc == nil {
svc = &cobra.Command{
Use: specName,
Short: specDesc,
}
parent.AddCommand(svc)
}
for resName, resource := range resources {
resMap, _ := resource.(map[string]interface{})
if resMap == nil {
continue
}
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
}
}
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
res := &cobra.Command{
Use: name,
Short: name + " operations",
}
parent.AddCommand(res)
methods, _ := resource["methods"].(map[string]interface{})
for methodName, method := range methods {
methodMap, _ := method.(map[string]interface{})
if methodMap == nil {
continue
}
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
}
}
// ServiceMethodOptions holds all inputs for a dynamically registered service method command.
type ServiceMethodOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Ctx context.Context
ServicePath string
Method meta.Method
SchemaPath string
Factory *cmdutil.Factory
Cmd *cobra.Command
Ctx context.Context
Spec map[string]interface{}
Method map[string]interface{}
SchemaPath string
// Flags
Params string
@@ -120,113 +123,41 @@ type ServiceMethodOptions struct {
DryRun bool
File string // --file flag value
FileFields []string // auto-detected file field names from metadata
// binder owns the generated typed param flags — registration and the
// --params overlay — replacing the raw paramFlags side-channel.
binder *paramFlagBinder
}
// detectFileFields returns the request-body file-upload field names.
func detectFileFields(m meta.Method) []string {
files := m.Files()
if len(files) == 0 {
return nil
}
names := make([]string, len(files))
for i, f := range files {
names[i] = f.Name
}
return names
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
func detectFileFields(method map[string]interface{}) []string {
return cmdutil.DetectFileFields(method)
}
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
}
// NewCmdServiceMethod creates a command for a dynamically registered service method.
func NewCmdServiceMethod(f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
return NewCmdServiceMethodWithContext(context.Background(), f, svc, m, name, resName, runF)
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
}
// NewCmdServiceMethodWithContext builds the command for one service method from
// its (service, resource, method) coordinates, deriving the methodCommandSpec
// via an apicatalog.MethodRef so direct callers and the catalog-driven
// registration assemble the command identically.
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
m.Name = name
ref := apicatalog.MethodRef{Service: svc, ResourcePath: []string{resName}, Method: m}
// No root in scope here; persistent-flag collisions don't apply to a
// standalone command, and local/standard-flag collisions are still caught.
return buildMethodCommand(ctx, f, newMethodCommandSpec(ref), runF, nil)
}
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
desc := registry.GetStrFromMap(method, "description")
httpMethod := registry.GetStrFromMap(method, "httpMethod")
risk := registry.GetStrFromMap(method, "risk")
specName := registry.GetStrFromMap(spec, "name")
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
// methodCommandSpec is the static description of one generated service method
// command, read off an apicatalog.MethodRef — the single place command
// construction gets the method's facts (schema path, HTTP base path, risk,
// identities, params, file fields, request-body support), so the cobra command
// is assembled from a typed spec rather than recomputing paths/flags inline.
type methodCommandSpec struct {
method meta.Method
schemaPath string // "service.resource.method", for the --help hint
servicePath string // service HTTP base path
risk string // RiskRead | RiskWrite | RiskHighRiskWrite
restricts bool // method declares accessTokens (identity-restricted)
identities []string // permitted --as values; empty when unrestricted
params []meta.Field // path/query params -> typed flags
fileFields []string // request-body file-upload field names
// acceptsBody is whether the HTTP method allows a request body at all (so
// --data is offered as a raw escape hatch). declaresBody is whether the
// metadata documents body fields (data or file). They differ for e.g. a POST
// with no documented requestBody: --data still works, but help must not imply
// the API declares a body.
acceptsBody bool
declaresBody bool
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
}
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
m := ref.Method
return methodCommandSpec{
method: m,
schemaPath: ref.SchemaPath(),
servicePath: ref.Service.ServicePath,
risk: m.Risk,
restricts: m.RestrictsIdentity(),
identities: m.Identities(),
params: m.Params(),
fileFields: detectFileFields(m),
acceptsBody: methodTakesBody(m.HTTPMethod),
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
affordance: renderAffordance(m),
}
}
// methodTakesBody reports whether the HTTP method allows a request body, i.e.
// whether --data applies (as a raw escape hatch even when no body is declared).
func methodTakesBody(httpMethod string) bool {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
return true
}
return false
}
// buildMethodCommand assembles the cobra command for a service method from its
// static spec: the standard flags, the conditional --data/--file/--yes flags,
// the generated typed param flags (via paramFlagBinder), and the risk/identity
// policy annotations.
func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodCommandSpec, runF func(*ServiceMethodOptions) error, reserved *pflag.FlagSet) *cobra.Command {
m := spec.method
opts := &ServiceMethodOptions{
Factory: f,
ServicePath: spec.servicePath,
Method: m,
SchemaPath: spec.schemaPath,
FileFields: spec.fileFields,
Factory: f,
Spec: spec,
Method: method,
SchemaPath: schemaPath,
}
var asStr string
cmd := &cobra.Command{
Use: m.Name,
Short: m.Description,
// Long is assembled below, once the binder knows which params got no
// typed flag.
Use: name,
Short: desc,
Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Cmd = cmd
opts.Ctx = cmd.Context()
@@ -237,17 +168,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
return serviceMethodRun(opts)
},
}
cmdmeta.SetSource(cmd, cmdmeta.SourceService, true)
cmd.Flags().StringVar(&opts.Params, "params", "", "Raw URL/query params JSON. Supports - and @file.")
if spec.acceptsBody {
dataUsage := "JSON request body. Supports - and @file."
if !spec.declaresBody {
// POST/etc. with no documented body fields: --data is a raw escape
// hatch, not a declared body — say so rather than imply structure.
dataUsage = "Raw JSON request body (no documented fields; see schema). Supports - and @file."
}
cmd.Flags().StringVar(&opts.Data, "data", "", dataUsage)
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)")
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
}
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
@@ -255,64 +180,29 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
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 spec.risk == cmdutil.RiskHighRiskWrite {
if risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}
// --file only for body methods that actually declare file-type fields.
if len(spec.fileFields) > 0 && spec.acceptsBody {
cmd.Flags().StringVar(&opts.File, "file", "", "File upload [field=]path. Supports - and stdin.")
// Conditionally register --file for methods with file-type fields.
fileFields := detectFileFields(method)
opts.FileFields = fileFields
if len(fileFields) > 0 {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
}
}
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
// Registered last so the collision guard sees the standard flags above.
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
// Single composition point for Long: description, affordance, schema
// pointer, and the binder's params-only addendum (params whose flag name is
// taken, reachable via --params only).
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
// Group flags for the grouped --help renderer (typed param flags are grouped
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
// registered above (e.g. --data/--file/--yes only exist for some methods).
// --data sits under Request Body only when the metadata documents body
// fields; otherwise it's a raw escape hatch, grouped with --params so help
// doesn't imply a declared body the API doesn't have.
if fl := cmd.Flags().Lookup("data"); fl != nil {
if spec.declaresBody {
annotate(fl, flagGroupAnnotation, []string{groupBody})
} else {
annotate(fl, flagGroupAnnotation, []string{groupRaw})
}
}
tagFlagGroup(cmd.Flags(), "file", groupBody)
if fl := cmd.Flags().Lookup("params"); fl != nil {
annotate(fl, flagGroupAnnotation, []string{groupRaw})
// State the precedence rule where the agent reads it: --params is the
// base, typed flags override. Only meaningful when typed flags exist.
if len(spec.params) > 0 {
annotate(fl, flagNoteAnnotation, []string{
"Typed API parameter flags above are preferred.",
"If both are set, typed flags override matching keys in --params.",
})
}
}
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
tagFlagGroup(cmd.Flags(), name, groupExecution)
}
for _, name := range []string{"output", "format", "jq"} {
tagFlagGroup(cmd.Flags(), name, groupOutput)
}
applyGroupedUsage(cmd)
cmdutil.SetTips(cmd, m.Tips)
cmdutil.SetRisk(cmd, spec.risk)
if spec.restricts {
cmdutil.SetSupportedIdentities(cmd, spec.identities)
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
cmdutil.SetRisk(cmd, risk)
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
}
return cmd
@@ -327,8 +217,8 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
// Check if this API method supports the resolved identity.
if opts.Method.RestrictsIdentity() {
if err := f.CheckIdentity(opts.As, opts.Method.Identities()); err != nil {
if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
if err := f.CheckIdentity(opts.As, cmdutil.AccessTokensToIdentities(tokens)); err != nil {
return err
}
}
@@ -344,10 +234,12 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if err != nil {
return err
}
// Identity is not printed to stderr here: it is part of the JSON envelope.
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
scopes, _ := opts.Method["scopes"].([]interface{})
if !opts.As.IsBot() {
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method); err != nil {
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method, scopes); err != nil {
return err
}
}
@@ -364,7 +256,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
return serviceDryRun(f, request, config, opts.Format)
}
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
if registry.GetStrFromMap(opts.Method, "risk") == "high-risk-write" {
if yes, _ := opts.Cmd.Flags().GetBool("yes"); !yes {
return cmdutil.RequireConfirmation(opts.SchemaPath)
}
@@ -387,7 +279,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
checkErr := ac.CheckResponse
if opts.PageAll {
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
}
@@ -409,7 +301,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
// checkServiceScopes pre-checks user scopes before making the API call.
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method meta.Method) error {
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
if ctx.Err() != nil {
return ctx.Err()
}
@@ -418,15 +310,23 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
return nil //nolint:nilerr // skip scope check when token resolution fails or has no scopes
}
if len(method.RequiredScopes) > 0 {
requiredScopes, hasRequired := method["requiredScopes"].([]interface{})
if hasRequired && len(requiredScopes) > 0 {
// Strict: ALL requiredScopes must be present
if missing := auth.MissingScopes(result.Scopes, method.RequiredScopes); len(missing) > 0 {
required := make([]string, 0, len(requiredScopes))
for _, s := range requiredScopes {
if str, ok := s.(string); ok {
required = append(required, str)
}
}
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing)
}
return nil
}
if len(method.Scopes) == 0 {
if len(scopes) == 0 {
return nil
}
@@ -435,12 +335,12 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
for _, s := range strings.Fields(result.Scopes) {
grantedSet[s] = true
}
for _, s := range method.Scopes {
if grantedSet[s] {
for _, s := range scopes {
if str, ok := s.(string); ok && grantedSet[str] {
return nil
}
}
recommended := registry.SelectRecommendedScopeFromStrings(method.Scopes, "user")
recommended := registry.SelectRecommendedScope(scopes, "user")
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), []string{recommended})
}
@@ -461,44 +361,14 @@ func newPreflightMissingScopeError(brand, appID, identity string, missing []stri
WithIdentity(identity)
}
// unusableParamValue reports whether a provided path/query parameter value
// cannot form a usable request value: nil or an empty string. A key's presence
// in params is the intent signal — a typed flag is overlaid only when
// explicitly Changed, and a --params JSON key is deliberately written — so
// false and 0 are real values and must not be conflated with "unset"
// (reflect.IsZero would drop an explicit --with-deleted=false or --foo 0).
// Only nil/"" stay treated as missing: that keeps the friendly pre-flight
// error when a required param is fed an empty placeholder, and never emits a
// declared param as an empty path segment or query value. Undeclared keys are
// not judged by this rule — they pass through verbatim as the raw escape hatch.
func unusableParamValue(v interface{}) bool {
if v == nil {
return true
}
s, ok := v.(string)
return ok && s == ""
}
// missingParamHint is the recovery hint for a missing required parameter. It
// names both input paths — the typed flag when the binder registered one, and
// the --params fallback — plus the schema pointer. A params-only field gets
// only the --params form: a flag with its kebab name exists but belongs to
// something else (e.g. the output --format), and the hint must not steer
// there. Asking the binder, not cmd.Flags(), is what tells those apart.
func missingParamHint(opts *ServiceMethodOptions, f meta.Field) string {
paramsForm := fmt.Sprintf("--params '{%q: \"<value>\"}'", f.Name)
if opts.binder.hasTypedFlag(f.Name) {
return fmt.Sprintf("set --%s <value> (or %s); see: lark-cli schema %s", f.FlagName(), paramsForm, opts.SchemaPath)
}
return fmt.Sprintf("set %s; see: lark-cli schema %s", paramsForm, opts.SchemaPath)
}
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
// When dryRun is true and a file is provided, file reading is skipped and
// FileUploadMeta is returned instead so the caller can render dry-run output.
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
spec := opts.Spec
method := opts.Method
httpMethod := method.HTTPMethod
schemaPath := opts.SchemaPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
// stdin is an io.Reader consumed at most once. Only one of --params/--data
// may use "-" (stdin); the conflict check below prevents silent data loss.
@@ -516,55 +386,53 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
if err != nil {
return client.RawApiRequest{}, nil, err
}
opts.binder.overlay(opts.Cmd, params)
url := opts.ServicePath + "/" + method.Path
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
specs := method.Params()
for _, s := range specs {
if s.Location != "path" {
parameters, _ := method["parameters"].(map[string]interface{})
for name, param := range parameters {
p, _ := param.(map[string]interface{})
if registry.GetStrFromMap(p, "location") != "path" {
continue
}
val, ok := params[s.Name]
if !ok || unusableParamValue(val) {
val, ok := params[name]
if !ok || util.IsEmptyValue(val) {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing required path parameter: %s", s.Name).
WithHint("%s", missingParamHint(opts, s)).
WithParam(s.Name)
"missing required path parameter: %s", name).
WithHint("lark-cli schema %s", schemaPath).
WithParam(name)
}
valStr := fmt.Sprintf("%v", val)
if err := validate.ResourceName(valStr, s.Name); err != nil {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(s.Name).WithCause(err)
if err := validate.ResourceName(valStr, name); err != nil {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(name).WithCause(err)
}
url = strings.Replace(url, "{"+s.Name+"}", validate.EncodePathSegment(valStr), 1)
delete(params, s.Name)
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
delete(params, name)
}
queryParams := map[string]interface{}{}
for _, s := range specs {
if s.Location != "query" {
for name, param := range parameters {
p, _ := param.(map[string]interface{})
if registry.GetStrFromMap(p, "location") != "query" {
continue
}
value, exists := params[s.Name]
isPaginationParam := opts.PageAll && (s.Name == "page_token" || s.Name == "page_size")
if s.Required && !isPaginationParam && (!exists || unusableParamValue(value)) {
value, exists := params[name]
required, _ := p["required"].(bool)
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing required query parameter: %s", s.Name).
WithHint("%s", missingParamHint(opts, s)).
WithParam(s.Name)
"missing required query parameter: %s", name).
WithHint("lark-cli schema %s", schemaPath).
WithParam(name)
}
if exists && !unusableParamValue(value) {
queryParams[s.Name] = value
if exists && !util.IsEmptyValue(value) {
queryParams[name] = value
}
// This loop owns declared query params: consume the key so the
// passthrough below can't resurrect a value the gate dropped (an
// unusable "" would otherwise be sent as an empty query value).
delete(params, s.Name)
}
// Whatever remains is undeclared — the raw escape hatch for params the
// metadata doesn't (yet) describe; passed through verbatim, no filtering.
for name, value := range params {
queryParams[name] = value
if _, ok := queryParams[name]; !ok {
queryParams[name] = value
}
}
request := client.RawApiRequest{
@@ -627,45 +495,20 @@ func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *cor
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return err
@@ -675,12 +518,7 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
output.FormatValue(out, result, output.FormatJSON)
}
return nil
default:
@@ -689,14 +527,9 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
output.FormatValue(out, result, format)
return nil
}
}

View File

@@ -8,14 +8,13 @@ import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
)
// highRiskDeleteMethod mirrors a simple DELETE API with a required path
// parameter and risk metadata. The test exercises --yes registration and the
// gate behavior.
func highRiskDeleteMethod() meta.Method {
return meta.FromMap(map[string]interface{}{
// parameter and risk metadata. The returned map is what service registration
// reads; the test exercises --yes registration and the gate behavior.
func highRiskDeleteMethod() map[string]interface{} {
return map[string]interface{}{
"path": "files/{file_token}",
"httpMethod": "DELETE",
"risk": "high-risk-write",
@@ -24,11 +23,11 @@ func highRiskDeleteMethod() meta.Method {
"type": "string", "location": "path", "required": true,
},
},
})
}
}
func writeMethodNoRisk() meta.Method {
return meta.FromMap(map[string]interface{}{
func writeMethodNoRisk() map[string]interface{} {
return map[string]interface{}{
"path": "files/{file_token}",
"httpMethod": "DELETE",
"parameters": map[string]interface{}{
@@ -36,7 +35,7 @@ func writeMethodNoRisk() meta.Method {
"type": "string", "location": "path", "required": true,
},
},
})
}
}
func TestServiceMethod_YesFlagRegisteredForHighRisk(t *testing.T) {

View File

@@ -4,19 +4,13 @@
package service
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
@@ -26,14 +20,14 @@ var testConfig = &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
func driveSpec() meta.Service {
return meta.ServiceFromMap(map[string]interface{}{
func driveSpec() map[string]interface{} {
return map[string]interface{}{
"name": "drive",
"servicePath": "/open-apis/drive/v1",
})
}
}
func driveMethod(httpMethod string, params map[string]interface{}) meta.Method {
func driveMethod(httpMethod string, params map[string]interface{}) map[string]interface{} {
m := map[string]interface{}{
"path": "files/{file_token}/copy",
"httpMethod": httpMethod,
@@ -47,7 +41,7 @@ func driveMethod(httpMethod string, params map[string]interface{}) meta.Method {
},
}
}
return meta.FromMap(m)
return m
}
// ── registerService ──
@@ -55,23 +49,23 @@ func driveMethod(httpMethod string, params map[string]interface{}) meta.Method {
func TestRegisterService(t *testing.T) {
parent := &cobra.Command{Use: "root"}
f := &cmdutil.Factory{}
base := meta.ServiceFromMap(map[string]interface{}{
spec := map[string]interface{}{
"name": "base",
"description": "Base API",
"servicePath": "/open-apis/base/v3",
"resources": map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{
"description": "List tables",
"httpMethod": "GET",
},
}
resources := map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{
"description": "List tables",
"httpMethod": "GET",
},
},
},
})
}
registerService(parent, base, f)
registerService(parent, spec, resources, f)
// service command exists
svc, _, err := parent.Find([]string{"base"})
@@ -96,18 +90,18 @@ func TestRegisterService_MergesExistingCommand(t *testing.T) {
parent.AddCommand(existing)
f := &cmdutil.Factory{}
svc := meta.ServiceFromMap(map[string]interface{}{
spec := map[string]interface{}{
"name": "base", "description": "Base API", "servicePath": "/open-apis/base/v3",
"resources": map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{"description": "List", "httpMethod": "GET"},
},
}
resources := map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{"description": "List", "httpMethod": "GET"},
},
},
})
}
registerService(parent, svc, f)
registerService(parent, spec, resources, f)
// Should reuse existing, not duplicate
count := 0
@@ -149,7 +143,7 @@ func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) {
func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdServiceMethod(f, driveSpec(),
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files", nil)
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", nil)
if cmd.Flags().Lookup("data") != nil {
t.Error("GET method should not have --data flag")
@@ -165,7 +159,7 @@ func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
func TestNewCmdServiceMethod_POSTHasDataFlag(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdServiceMethod(f, driveSpec(),
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "POST"}), "create", "files", nil)
map[string]interface{}{"description": "desc", "httpMethod": "POST"}, "create", "files", nil)
if cmd.Flags().Lookup("data") == nil {
t.Error("POST method should have --data flag")
@@ -177,7 +171,7 @@ func TestNewCmdServiceMethod_RunFCallback(t *testing.T) {
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
@@ -274,15 +268,15 @@ func TestServiceMethod_MissingPathParam(t *testing.T) {
}
func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) {
spec := meta.ServiceFromMap(map[string]interface{}{
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
})
method := meta.FromMap(map[string]interface{}{
}
method := map[string]interface{}{
"path": "items", "httpMethod": "GET",
"parameters": map[string]interface{}{
"q": map[string]interface{}{"location": "query", "required": true},
},
})
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{}`, "--dry-run"})
@@ -297,15 +291,15 @@ func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) {
}
func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
spec := meta.ServiceFromMap(map[string]interface{}{
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
})
method := meta.FromMap(map[string]interface{}{
}
method := map[string]interface{}{
"path": "items", "httpMethod": "GET",
"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"location": "query", "required": true},
},
})
}
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{}`, "--page-all", "--dry-run"})
@@ -321,10 +315,10 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := meta.ServiceFromMap(map[string]interface{}{
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--params", "{bad", "--dry-run"})
@@ -339,10 +333,10 @@ func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
func TestServiceMethod_InvalidDataJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := meta.ServiceFromMap(map[string]interface{}{
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}})
}
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
cmd.SetArgs([]string{"--data", "{bad", "--dry-run"})
@@ -357,10 +351,10 @@ func TestServiceMethod_InvalidDataJSON(t *testing.T) {
func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := meta.ServiceFromMap(map[string]interface{}{
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}})
}
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
cmd.SetArgs([]string{"--params", "-", "--data", "-", "--dry-run"})
@@ -375,10 +369,10 @@ func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := meta.ServiceFromMap(map[string]interface{}{
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--page-all", "--output", "file.bin", "--as", "bot"})
@@ -404,27 +398,16 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
if got["ok"] != true || got["identity"] != "bot" {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if !ok || data["result"] != "success" {
t.Fatalf("data = %#v, want result=success", got["data"])
if !strings.Contains(stdout.String(), "success") {
t.Errorf("expected 'success' in output, got:\n%s", stdout.String())
}
}
@@ -444,320 +427,16 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("data.items = %#v, want one item", data["items"])
}
}
type serviceContentSafetyProvider struct {
called bool
path string
data interface{}
match string
}
func (p *serviceContentSafetyProvider) Name() string { return "service-test" }
func (p *serviceContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
p.called = true
p.path = req.Path
p.data = req.Data
if p.match != "" {
b, _ := json.Marshal(req.Data)
if !strings.Contains(string(b), p.match) {
return nil, nil
}
}
return &extcs.Alert{Provider: "service-test", MatchedRules: []string{"pagination"}}, nil
}
func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &serviceContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-safety", AppSecret: "test-secret-service-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan paginated output")
}
if provider.path != "list" {
t.Fatalf("scan path = %q, want list", provider.path)
}
data, ok := provider.data.(map[string]interface{})
if !ok {
t.Fatalf("scanned data type = %T, want map", provider.data)
}
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
alert, ok := got["_content_safety_alert"].(map[string]interface{})
if !ok || alert["provider"] != "service-test" {
t.Fatalf("missing content safety alert in envelope: %#v", got)
}
}
func TestServiceMethod_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &serviceContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-stream-safety", AppSecret: "test-secret-service-stream-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan streamed paginated output")
}
if provider.path != "list" {
t.Fatalf("scan path = %q, want list", provider.path)
}
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from service-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
}
if !strings.Contains(stdout.String(), `"id":"1"`) {
t.Fatalf("expected streamed ndjson output, got: %s", stdout.String())
}
}
func TestServiceMethod_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
provider := &serviceContentSafetyProvider{match: "blocked"}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-stream-block", AppSecret: "test-secret-service-stream-block", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
t.Fatal("expected content safety block error")
}
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
}
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
}
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "pagination" {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
}
}
func TestServiceMethod_BusinessErrorReturnsTypedErrorWithoutSuccessEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-err", AppSecret: "test-secret-service-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
}
func TestServiceMethod_PageAll_DefaultBusinessErrorOutputsRawResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-err", AppSecret: "test-secret-service-pageall-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
if !strings.Contains(stdout.String(), "230027") || !strings.Contains(stdout.String(), "user not authorized") {
t.Fatalf("expected raw error response on stdout, got: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
}
func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-stream-err", AppSecret: "test-secret-service-pageall-stream-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027,
"msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier successful page to remain streamed, got: %s", out)
}
if strings.Contains(out, "230027") || strings.Contains(out, "user not authorized") {
t.Fatalf("streaming stdout should not contain raw error JSON, got: %s", out)
}
if strings.Contains(out, "\n \"code\"") {
t.Fatalf("streaming stdout should not contain indented JSON error dump, got: %s", out)
if !strings.Contains(stdout.String(), `"id"`) {
t.Errorf("expected items in output, got:\n%s", stdout.String())
}
}
@@ -771,8 +450,8 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"})
@@ -791,7 +470,7 @@ func TestNewCmdServiceMethod_JqFlag(t *testing.T) {
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
@@ -813,7 +492,7 @@ func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
@@ -829,10 +508,10 @@ func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
func TestServiceMethod_JqAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := meta.ServiceFromMap(map[string]interface{}{
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--jq", ".data", "--output", "file.bin", "--as", "bot"})
@@ -863,8 +542,8 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--jq", ".data.items[].name"})
@@ -882,10 +561,10 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := meta.ServiceFromMap(map[string]interface{}{
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--jq", ".data", "--format", "ndjson", "--as", "bot"})
@@ -900,10 +579,10 @@ func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
func TestServiceMethod_JqInvalidExpression(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := meta.ServiceFromMap(map[string]interface{}{
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--jq", "invalid[", "--as", "bot"})
@@ -932,8 +611,8 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
@@ -949,55 +628,10 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
}
}
func TestServiceMethod_PageAll_WithJqBusinessErrorOutputsRawResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-spjq-err", AppSecret: "test-secret-spjq-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
}
if !strings.Contains(stdout.String(), "230027") || !strings.Contains(stdout.String(), "user not authorized") {
t.Fatalf("expected raw error response on stdout, got: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
}
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, code int) {
t.Helper()
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype || p.Code != code {
t.Fatalf("problem = %s/%s/%d, want %s/%s/%d", p.Category, p.Subtype, p.Code, category, subtype, code)
}
}
// ── file upload ──
func imImageMethod() meta.Method {
return meta.FromMap(map[string]interface{}{
func imImageMethod() map[string]interface{} {
return map[string]interface{}{
"path": "images",
"httpMethod": "POST",
"requestBody": map[string]interface{}{
@@ -1011,14 +645,14 @@ func imImageMethod() meta.Method {
},
},
"accessTokens": []interface{}{"user", "tenant"},
})
}
}
func imSpec() meta.Service {
return meta.ServiceFromMap(map[string]interface{}{
func imSpec() map[string]interface{} {
return map[string]interface{}{
"name": "im",
"servicePath": "/open-apis/im/v1",
})
}
}
func TestServiceMethod_FileFlagRegistered(t *testing.T) {
@@ -1050,7 +684,7 @@ func TestServiceMethod_FileFlagNotRegisteredForGET(t *testing.T) {
},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(getMethod), "get", "images", nil)
cmd := NewCmdServiceMethod(f, imSpec(), getMethod, "get", "images", nil)
flag := cmd.Flags().Lookup("file")
if flag != nil {
t.Fatal("expected --file flag NOT to be registered for GET method")
@@ -1118,7 +752,7 @@ func TestDetectFileFields(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectFileFields(meta.FromMap(tt.method))
got := detectFileFields(tt.method)
if len(got) != len(tt.want) {
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
return
@@ -1131,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(),
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
})
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--json should be accepted without error, got: %v", err)
}
if captured == nil {
t.Fatal("expected runF to be called")
}
}

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,8 +11,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -74,136 +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())
}
// Typed surface: a validation error (exit 2) whose Params carries the
// offending flag so an agent can recover the token without parsing prose.
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--badflag" {
t.Errorf("params = %v, want one entry named --badflag", verr.Params)
}
}
func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing.T) {
_, drive, _ := newGroupTree()
// --query is defined on the +search subcommand, so it is a *valid* flag that
// was placed before the (omitted) subcommand. Unlike an unknown flag, this
// must still fail structured (missing_subcommand) rather than fall through to
// help + exit 0 — `drive --query x` is a malformed call, not a help request.
for _, c := range drive.Commands() {
if c.Name() == "+search" {
c.Flags().String("query", "", "")
}
}
installUnknownSubcommandGuard(drive.Root())
rawInvocationArgs = []string{"drive", "--query", "x"}
t.Cleanup(func() { rawInvocationArgs = nil })
err := drive.RunE(drive, nil)
if err == nil {
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
}
if !strings.Contains(verr.Message, "missing subcommand") {
t.Errorf("message = %q, want it to mention a missing subcommand", verr.Message)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--query" {
t.Errorf("params = %v, want one entry named --query", verr.Params)
}
if !strings.Contains(verr.Message, "lark-cli drive") {
t.Errorf("message = %q, want it to name the group path", verr.Message)
}
}
// A bare group carrying only a group-valid global flag (e.g. the inherited
// --profile) is not missing a subcommand — those flags do not belong to a
// subcommand — so it must print help, not fail with missing_subcommand.
func TestUnknownSubcommandRunE_GroupValidGlobalFlagShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree()
drive.Root().PersistentFlags().String("profile", "", "") // global, inherited by drive
installUnknownSubcommandGuard(drive.Root())
rawInvocationArgs = []string{"--profile", "p", "drive"}
t.Cleanup(func() { rawInvocationArgs = nil })
var buf bytes.Buffer
drive.SetOut(&buf)
drive.SetErr(&buf)
if err := drive.RunE(drive, nil); err != nil {
t.Fatalf("bare group with only a global flag should print help, got error: %v", err)
}
if !strings.Contains(buf.String(), "drive ops") {
t.Errorf("expected help output, got:\n%s", buf.String())
}
}
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
@@ -229,23 +97,45 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
t.Fatal("expected error for unknown subcommand")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("expected exit code %d, got %d", output.ExitValidation, output.ExitCodeOf(err))
if exitErr.Code != output.ExitValidation {
t.Errorf("expected exit code %d, got %d", output.ExitValidation, exitErr.Code)
}
if !strings.Contains(verr.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", verr.Message)
if exitErr.Detail == nil {
t.Fatal("expected ExitError to carry Detail")
}
if !strings.Contains(verr.Message, "lark-cli drive") {
t.Errorf("message should name the group path, got %q", verr.Message)
if exitErr.Detail.Type != "unknown_subcommand" {
t.Errorf("expected Detail.Type=unknown_subcommand, got %q", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
}
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
// back to pointing at --help (suggestions, when present, are folded into hint).
if !strings.Contains(verr.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", verr.Hint)
// back to pointing at --help; the full machine-readable list lives in
// detail.available below (which also excludes hidden commands).
if !strings.Contains(exitErr.Detail.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("expected Detail.Detail to be map[string]any, got %T", exitErr.Detail.Detail)
}
if detail["unknown"] != "+bogus" {
t.Errorf("detail.unknown should be +bogus, got %v", detail["unknown"])
}
if detail["command_path"] != "lark-cli drive" {
t.Errorf("detail.command_path should be %q, got %v", "lark-cli drive", detail["command_path"])
}
available, ok := detail["available"].([]string)
if !ok {
t.Fatalf("detail.available should be []string, got %T", detail["available"])
}
if len(available) != 3 {
t.Errorf("expected 3 available entries (hidden excluded), got %d: %v", len(available), available)
}
}
@@ -254,12 +144,13 @@ func TestUnknownSubcommandRunE_NestedResourceGroup(t *testing.T) {
installUnknownSubcommandGuard(root)
err := files.RunE(files, []string{"bogus"})
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError on nested group, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError on nested group, got %T", err)
}
if !strings.Contains(verr.Message, "lark-cli drive files") {
t.Errorf("message should reflect the nested resource path, got %q", verr.Message)
if exitErr.Detail.Detail.(map[string]any)["command_path"] != "lark-cli drive files" {
t.Errorf("command_path should reflect the nested resource, got %v",
exitErr.Detail.Detail.(map[string]any)["command_path"])
}
}
@@ -273,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)
@@ -284,56 +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 ranks suggestions across both current and deprecated
// subcommands so a mistyped legacy alias resolves; the closest match is folded
// into the hint.
func TestUnknownSubcommandRunE_SuggestsAcrossDeprecatedBucket(t *testing.T) {
svc := &cobra.Command{Use: "sheets"}
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
svc.AddCommand(
&cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
)
err := unknownSubcommandRunE(svc, []string{"+reat"})
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
// "+reat" is closest to the deprecated +read: the candidate must surface
// both as a machine-readable param suggestion (for agent retry) and in the
// hint, proving ranking spans the deprecated bucket.
if len(verr.Params) != 1 || verr.Params[0].Name != "+reat" {
t.Fatalf("params = %v, want one entry named +reat (the offending subcommand)", verr.Params)
}
foundSuggestion := false
for _, s := range verr.Params[0].Suggestions {
if s == "+read" {
foundSuggestion = true
}
}
if !foundSuggestion {
t.Errorf("Params[0].Suggestions should include +read, got %v", verr.Params[0].Suggestions)
}
if !strings.Contains(verr.Hint, "+read") {
t.Errorf("hint %q should suggest +read (typo target across deprecated bucket)", verr.Hint)
}
}

View File

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

View File

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

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