Compare commits

..

117 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
zgz2048
e57d97f341 docs: optimize base skill references (#1171) 2026-06-02 17:30:10 +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
MaxHuang22
57ba4fae61 feat: unconditionally inject --format flag for all shortcuts (#1156)
* feat: unconditionally inject --format flag for all shortcuts

Removes three HasFormat guards in runner.go so every shortcut
gets --format regardless of the Shortcut.HasFormat field value.
Shortcuts that already define a custom 'format' flag in Flags[]
are skipped to avoid redefinition panics (e.g. mail +triage, +watch).
HasFormat is retained in the struct but marked deprecated.

Change-Id: I5e8fe07e839d5aed4cefaf7d753dabbaee68fb6e

* test: isolate config dir in format-universal test

Change-Id: I3a59942aa8a6753cd949ca42f2a19a72f032ff55

* test: revert unnecessary config-dir isolation (mount-only test)

Change-Id: I0146e5a2f57f5419863bdeeaa1a662fd8f70bddf
2026-06-02 16:55:02 +08:00
YH-1600
925ae5ecd6 docs: add lark drive knowledge organization workflow (#1028)
Change-Id: I2343fcdf26ceefb898cc8d4faeae4b17384cfea8
2026-06-02 16:28:25 +08:00
liangshuo-1
4710a294f5 refactor(transport): own all HTTP transport in internal/transport, fix util layering inversion (#1213)
internal/util imported internal/proxyplugin (SharedTransport, FallbackTransport,
NewHTTPClient, and WarnIfProxied via proxyPluginStatus), so a foundational util
package depended up into a feature package, pulling binding/core/vfs into the
transitive cone of every util importer.

Move internal/proxyplugin -> internal/transport and make it the single owner of
outbound transport: fold the two SharedTransport functions into one Shared()
(proxy-plugin override -> LARK_CLI_NO_PROXY -> http.DefaultTransport), and move
Fallback/NewHTTPClient/WarnIfProxied/DetectProxyEnv/noProxyTransport out of the
now-deleted internal/util/proxy.go into the new package. The proxy-plugin probe
is demoted to a private pluginTransport(); the duplicate redactProxyURL collapses
to one. internal/util keeps no proxy code and is a leaf again.

Re-point all consumers (registry, doctor, config, auth, cmdutil, update) to
internal/transport. Behavior-preserving: package move + symbol rename + dedup.
Two new tests lock the fail-closed contract (plugin overrides NO_PROXY; malformed
config never falls through to direct egress).
2026-06-02 16:10:35 +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
JackZhao10086
bc8e9bd6ef feat: increase agent trace max length to 1024 (#1211) 2026-06-02 11:08:53 +08:00
JackZhao10086
f65712cacf feat: add proxy plugin mode for CLI HTTP transport (#1181)
* feat: add security plugin for proxy

* docs: remove outdated proxyplugin README files

* refactor(proxyplugin): tighten proxy URL validation and add security checks

* refactor(proxyplugin): cache blocked transport and clean up error handling

* fix(proxyplugin): fix CR issues for Security hardening

---------

Co-authored-by: AlbertSun <sunxingjian@bytedance.com>
2026-06-02 10:57:02 +08:00
zhangjun-bytedance
915cc623cc feat(vc): inline transcript from artifacts API and add keywords (#1206) 2026-06-02 10:36:41 +08:00
liangshuo-1
3bfb80951d chore(release): v1.0.45 (#1207) 2026-06-01 22:08:11 +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
hugang-lark
639259fbfd fix: add vc-domain-boundaries and enrich vc +notes (#1172) 2026-06-01 19:03:55 +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
297 changed files with 32956 additions and 17405 deletions

View File

@@ -2,6 +2,22 @@
All notable changes to this project will be documented in this file.
## [v1.0.45] - 2026-06-01
### Features
- **errors**: Add typed envelope contract for auth-domain errors (#1135)
- **platform**: Support multiple policy rules per plugin (#1182)
### Bug Fixes
- **vc**: Add domain boundaries and enrich `+notes` (#1172)
- **whiteboard**: Fix whiteboard skill (#1180)
### Refactor
- **auth**: Update login hint and split-flow docs (#1201)
## [v1.0.44] - 2026-05-29
### Features
@@ -948,6 +964,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[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
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42

View File

@@ -117,6 +117,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
// inherited by every subcommand, turning unknown-flag errors into a
// structured "did you mean" envelope.
rootCmd.SilenceUsage = true
rootCmd.SetFlagErrorFunc(flagDidYouMean)
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {

View File

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

View File

@@ -19,6 +19,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/update"
)
@@ -152,7 +153,9 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
}
}
httpClient := &http.Client{}
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
// the real egress path (and are blocked when proxy plugin fails closed).
httpClient := transport.NewHTTPClient(0)
mcpURL := ep.MCP + "/mcp"
type probeResult struct {

View File

@@ -10,6 +10,7 @@ import (
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/suggest"
)
const maxSuggestions = 3
@@ -28,7 +29,7 @@ func suggestEventKeys(input string) []string {
hits = append(hits, match{def.Key, 0})
continue
}
if d := levenshtein(input, def.Key); d <= threshold {
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
hits = append(hits, match{def.Key, d})
}
}
@@ -69,34 +70,3 @@ func unknownEventKeyErr(key string) error {
"Run 'lark-cli event list' to see available keys.",
)
}
// levenshtein computes classic edit distance (two-row DP).
func levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}

View File

@@ -10,27 +10,6 @@ import (
_ "github.com/larksuite/cli/events"
)
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "", 1},
{"", "abc", 3},
{"kitten", "kitten", 0},
{"kitten", "sitten", 1},
{"kitten", "sitting", 3},
{"飞书", "飞书", 0},
{"飞书", "飞s", 1},
}
for _, tc := range cases {
if got := levenshtein(tc.a, tc.b); got != tc.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestSuggestEventKeys(t *testing.T) {
cases := []struct {
name string

70
cmd/flag_suggest_test.go Normal file
View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"slices"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
func TestUnknownFlagName(t *testing.T) {
cases := []struct {
in string
name string
ok bool
}{
{"unknown flag: --query", "query", true},
{"unknown flag: --with-styles", "with-styles", true},
{"unknown shorthand flag: 'z' in -z", "", false},
{"flag needs an argument: --find", "", false},
{`invalid argument "x" for "--count"`, "", false},
}
for _, c := range cases {
name, ok := unknownFlagName(errors.New(c.in))
if name != c.name || ok != c.ok {
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
}
}
}
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().String("find", "", "")
c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.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 exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Type != "flag_error" {
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
}
}

View File

@@ -24,8 +24,10 @@ import (
"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"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const rootLong = `lark-cli — Lark/Feishu CLI tool.
@@ -308,6 +310,12 @@ func asExitError(err error) *output.ExitError {
func installUnknownSubcommandGuard(cmd *cobra.Command) {
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
cmd.RunE = unknownSubcommandRunE
// Route an unknown subcommand to unknownSubcommandRunE even when flags
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
// consumes no flags itself, so unknown flags belong to the (missing)
// subcommand; whitelisting them here prevents cobra from erroring on the
// flag first and printing usage instead of our structured suggestion.
cmd.FParseErrWhitelist.UnknownFlags = true
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
@@ -331,10 +339,12 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
}
unknown := args[0]
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(available) > 0 {
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
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())
}
return &output.ExitError{
Code: output.ExitValidation,
@@ -345,6 +355,7 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
Detail: map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available,
},
},
@@ -367,6 +378,81 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
return subs
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
// --find, where edit distance alone finds nothing). Other flag errors stay
// structured but generic.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "flag_error",
Message: ferr.Error(),
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
},
}
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if len(suggestions) > 0 {
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
Hint: hint,
Detail: map[string]any{
"unknown": "--" + name,
"command_path": c.CommandPath(),
"suggestions": suggestions,
"valid_flags": valid,
},
},
}
}
// 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.
func unknownFlagName(err error) (string, bool) {
const p = "unknown flag: --"
msg := err.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
}
rest := msg[i+len(p):]
if j := strings.IndexAny(rest, " \t"); j >= 0 {
rest = rest[:j]
}
return rest, true
}
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
// the valid_flags detail).
func visibleFlagNames(c *cobra.Command) []string {
var names []string
c.Flags().VisitAll(func(f *pflag.Flag) {
if !f.Hidden {
names = append(names, f.Name)
}
})
sort.Strings(names)
return names
}
// installTipsHelpFunc wraps the default help function to append a TIPS section
// when a command has tips set via cmdutil.SetTips. It also force-shows global
// flags that are normally hidden in single-app mode (currently --profile)

View File

@@ -113,11 +113,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
}
if strings.Contains(exitErr.Detail.Hint, "+secret") {
t.Error("hidden commands must not appear in the hint")
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
// back to pointing at --help; the full machine-readable list lives in
// detail.available below (which also excludes hidden commands).
if !strings.Contains(exitErr.Detail.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)

View File

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

View File

@@ -5,6 +5,7 @@ package cmdpolicy
import (
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/suggest"
)
// suggestRisk returns the closest valid Risk literal by edit distance
@@ -20,9 +21,9 @@ func suggestRisk(bad string) string {
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
}
best := string(candidates[0])
bestDist := levenshtein(lowered, best)
bestDist := suggest.Levenshtein(lowered, best)
for _, c := range candidates[1:] {
if d := levenshtein(lowered, string(c)); d < bestDist {
if d := suggest.Levenshtein(lowered, string(c)); d < bestDist {
bestDist, best = d, string(c)
}
}
@@ -40,47 +41,3 @@ func toLower(s string) string {
}
return string(b)
}
// levenshtein computes the classic edit distance between two strings.
// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set
// makes raw performance irrelevant — clarity beats trickiness here.
func levenshtein(a, b string) int {
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}
prev := make([]int, len(b)+1)
curr := make([]int, len(b)+1)
for j := 0; j <= len(b); j++ {
prev[j] = j
}
for i := 1; i <= len(a); i++ {
curr[0] = i
for j := 1; j <= len(b); j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
curr[j] = min3(
prev[j]+1, // deletion
curr[j-1]+1, // insertion
prev[j-1]+cost, // substitution
)
}
prev, curr = curr, prev
}
return prev[len(b)]
}
func min3(a, b, c int) int {
m := a
if b < m {
m = b
}
if c < m {
m = c
}
return m
}

View File

@@ -29,23 +29,3 @@ func TestSuggestRisk(t *testing.T) {
}
}
}
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"", "abc", 3},
{"abc", "", 3},
{"abc", "abc", 0},
{"wrtie", "write", 2},
{"kitten", "sitting", 3},
}
for _, c := range cases {
got := levenshtein(c.a, c.b)
if got != c.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}

View File

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

View File

@@ -41,7 +41,7 @@ const (
officialModulePath = "github.com/larksuite/cli"
agentTraceMaxLen = 256
agentTraceMaxLen = 1024
)
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
@@ -75,6 +75,8 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
h.Set("x-tt-env", "ppe_moa_canvas")
h.Set("x-use-ppe", "1")
if v := AgentTraceValue(); v != "" {
h.Set(HeaderAgentTrace, v)
}

View File

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

View File

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

View File

@@ -20,4 +20,8 @@ const (
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS"
CliCAPath = "LARKSUITE_CLI_CA_PATH"
)

View File

@@ -17,6 +17,7 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
@@ -178,7 +179,9 @@ func saveCachedMerged(data []byte, meta CacheMeta) error {
// localVersion is sent as data_version query param for server-side version comparison.
// Returns (data, reg, err). A nil reg means the version is unchanged (not modified).
func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) {
client := &http.Client{Timeout: fetchTimeout}
// Route through the shared proxy-plugin-aware transport so remote API
// definition fetches honor proxy plugin mode instead of bypassing it.
client := transport.NewHTTPClient(fetchTimeout)
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
if err != nil {
return nil, nil, err

104
internal/suggest/suggest.go Normal file
View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package suggest provides the shared "did you mean" primitives: a rune-aware
// Levenshtein edit distance and a prefix-weighted Closest ranker. It is the
// single home for these so cmd, cmd/event, and internal/cmdpolicy stop each
// carrying their own copy.
package suggest
import "sort"
// Levenshtein computes the classic edit distance between two strings. It is
// rune-aware, so it is correct for multi-byte input.
func Levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}
// Closest returns up to maxN of candidates that plausibly match typed, ranked
// by shared-prefix length (desc) then edit distance (asc), keeping only
// reasonably-close ones.
//
// Shared prefix is weighted first on purpose: hallucinated names are often
// semantically close but lexically far (e.g. "+cells-find" vs "+cells-search",
// "--with-styles" vs nothing close), where the common prefix is the strongest
// signal of intent that raw edit distance misses.
func Closest(typed string, candidates []string, maxN int) []string {
type scored struct {
name string
prefix int
dist int
}
limit := editLimit(typed)
ranked := make([]scored, 0, len(candidates))
for _, c := range candidates {
p := sharedPrefixLen(typed, c)
d := Levenshtein(typed, c)
// Keep only plausible matches: a meaningful shared prefix, or an edit
// distance within budget. Drop everything else so the hint stays short.
if p >= 3 || d <= limit {
ranked = append(ranked, scored{name: c, prefix: p, dist: d})
}
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].prefix != ranked[j].prefix {
return ranked[i].prefix > ranked[j].prefix
}
if ranked[i].dist != ranked[j].dist {
return ranked[i].dist < ranked[j].dist
}
return ranked[i].name < ranked[j].name
})
if maxN <= 0 || maxN > len(ranked) {
maxN = len(ranked)
}
out := make([]string, 0, maxN)
for _, s := range ranked[:maxN] {
out = append(out, s.name)
}
return out
}
// editLimit allows roughly one third of the typed length in edits (min 2), so
// short names tolerate a couple of typos and longer ones proportionally more.
func editLimit(s string) int {
if l := len([]rune(s)) / 3; l > 2 {
return l
}
return 2
}
func sharedPrefixLen(a, b string) int {
ra, rb := []rune(a), []rune(b)
n := 0
for n < len(ra) && n < len(rb) && ra[n] == rb[n] {
n++
}
return n
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package suggest
import (
"slices"
"testing"
)
func TestClosest_HallucinatedSharesPrefix(t *testing.T) {
cmds := []string{
"+cells-get", "+cells-set", "+cells-search", "+cells-replace",
"+cells-clear", "+cells-merge", "+csv-get", "+chart-create",
"+pivot-create", "+sheet-info",
}
// "+cells-find" is semantically +cells-search but lexically far; the shared
// "+cells-" prefix should still surface the right family (incl. +cells-search).
got := Closest("+cells-find", cmds, 6)
if len(got) == 0 || len(got) > 6 {
t.Fatalf("expected 1..6 suggestions, got %v", got)
}
if !slices.Contains(got, "+cells-search") {
t.Errorf("expected +cells-search among suggestions, got %v", got)
}
for _, s := range got {
if len(s) < 7 || s[:7] != "+cells-" {
t.Errorf("suggestion %q does not share the +cells- prefix", s)
}
}
}
func TestClosest_TypoRanksExactNeighborFirst(t *testing.T) {
got := Closest("+cell-get", []string{"+cells-get", "+cells-set", "+csv-get", "+sheet-info"}, 3)
if len(got) == 0 || got[0] != "+cells-get" {
t.Errorf("expected +cells-get first for typo +cell-get, got %v", got)
}
}
func TestClosest_NoPlausibleMatch(t *testing.T) {
if got := Closest("+zzzzzz", []string{"+cells-get", "+csv-get"}, 6); len(got) != 0 {
t.Errorf("expected no suggestions for unrelated input, got %v", got)
}
}
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "abc", 3},
{"abc", "", 3},
{"abc", "abc", 0},
{"kitten", "sitting", 3},
{"cell-get", "cells-get", 1},
{"--query", "--find", 5},
{"飞书", "飞书", 0}, // rune-aware: multi-byte equal
{"飞书", "飞s", 1}, // one rune substitution, not byte count
}
for _, c := range cases {
if d := Levenshtein(c.a, c.b); d != c.want {
t.Errorf("Levenshtein(%q,%q) = %d, want %d", c.a, c.b, d, c.want)
}
}
}
func TestSharedPrefixLen(t *testing.T) {
if got := sharedPrefixLen("+cells-find", "+cells-search"); got != 7 {
t.Errorf("sharedPrefixLen = %d, want 7", got)
}
if got := sharedPrefixLen("abc", "xyz"); got != 0 {
t.Errorf("sharedPrefixLen = %d, want 0", got)
}
}

View File

@@ -0,0 +1,243 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package transport owns how the CLI assembles its outbound HTTP transport: the
// shared base RoundTripper (Shared/Fallback/NewHTTPClient), the LARK_CLI_NO_PROXY
// direct-egress clone, and the ~/.lark-cli/proxy_config.json proxy-plugin mode.
//
// Proxy-plugin mode forces all outbound HTTP(S) requests through a fixed loopback
// proxy, optionally trusting an extra root CA PEM bundle for TLS-inspection
// proxies, and fails closed on misconfiguration. Environment variables override
// matching values from proxy_config.json.
package transport
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/larksuite/cli/internal/binding"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs"
)
// ConfigFileName is the fixed config file name under core.GetConfigDir().
const (
ConfigFileName = "proxy_config.json"
)
// Config is the on-disk config format. Keys intentionally mirror env var names.
type Config struct {
// Enable turns on proxy plugin transport handling.
Enable bool `json:"LARKSUITE_CLI_PROXY_ENABLE"`
// Proxy is the fixed HTTP proxy address used for all outbound requests.
Proxy string `json:"LARKSUITE_CLI_PROXY_ADDRESS"`
// CAPath points to an extra PEM bundle trusted for proxy TLS interception.
CAPath string `json:"LARKSUITE_CLI_CA_PATH"`
}
// Path returns the absolute path to the proxy plugin config file.
func Path() string {
return filepath.Join(core.GetConfigDir(), ConfigFileName)
}
// loadOnce guards one-time proxy config loading for process-wide transport reuse.
var loadOnce sync.Once
// loadCfg stores the cached proxy config after the first successful Load call.
var loadCfg *Config
// loadErr stores the cached Load error observed during the first load attempt.
var loadErr error
// Load reads ~/.lark-cli/proxy_config.json once and caches the parsed result.
// Environment variables (CliProxyEnable/CliProxyAddress/CliCAPath) take precedence over config file values.
//
// Returns (nil, nil) only when:
// - the config file does not exist AND
// - none of the proxy-related env vars are present.
func Load() (*Config, error) {
loadOnce.Do(func() {
// Start from env-only config if any proxy env var is present.
cfg, hasEnv, err := loadFromEnv()
if err != nil {
loadErr = err
return
}
p := Path()
if _, err := vfs.Stat(p); err != nil {
if errors.Is(err, os.ErrNotExist) {
// No file: return env-only config (if any), else nil.
if hasEnv {
loadCfg = cfg
} else {
loadCfg = nil
}
loadErr = nil
return
}
loadErr = fmt.Errorf("failed to stat proxy plugin config %q: %w", p, err)
return
}
// Security hardening: this config dictates where ALL outbound CLI traffic
// egresses and which extra CA is trusted, so a file another local user or
// process can tamper with (symlink, foreign owner, group/world-writable)
// could redirect credential traffic. Audit it the same way the CA file is.
safePath, err := binding.AssertSecurePath(binding.AuditParams{
TargetPath: p,
Label: ConfigFileName,
AllowReadableByOthers: true, // config is not a secret; only writability/owner/symlink matter
})
if err != nil {
loadErr = fmt.Errorf("unsafe proxy plugin config %q: %w", p, err)
return
}
b, err := vfs.ReadFile(safePath)
if err != nil {
loadErr = fmt.Errorf("failed to read proxy plugin config %q: %w", p, err)
return
}
var fileCfg Config
if err := json.Unmarshal(b, &fileCfg); err != nil {
loadErr = fmt.Errorf("invalid proxy plugin config %q: %w", p, err)
return
}
// Merge: file base + env overrides.
if cfg == nil {
cfg = &fileCfg
} else {
*cfg = fileCfg
applyEnvOverrides(cfg)
}
loadCfg = cfg
})
return loadCfg, loadErr
}
// Enabled reports whether proxy plugin mode is enabled.
func (c *Config) Enabled() bool { return c != nil && c.Enable }
// loadFromEnv builds a config from proxy-related environment variables only.
// It reports whether any proxy-related environment variable was present.
func loadFromEnv() (*Config, bool, error) {
_, hasEnable := os.LookupEnv(envvars.CliProxyEnable)
_, hasProxy := os.LookupEnv(envvars.CliProxyAddress)
_, hasCA := os.LookupEnv(envvars.CliCAPath)
hasAny := hasEnable || hasProxy || hasCA
if !hasAny {
return nil, false, nil
}
cfg := &Config{}
if err := applyEnvOverrides(cfg); err != nil {
return nil, true, err
}
return cfg, true, nil
}
// applyEnvOverrides copies proxy-related environment variable values into cfg.
func applyEnvOverrides(cfg *Config) error {
if v, ok := os.LookupEnv(envvars.CliProxyEnable); ok {
b, err := parseBoolEnv(envvars.CliProxyEnable, v)
if err != nil {
return err
}
cfg.Enable = b
}
if v, ok := os.LookupEnv(envvars.CliProxyAddress); ok {
cfg.Proxy = v
}
if v, ok := os.LookupEnv(envvars.CliCAPath); ok {
cfg.CAPath = v
}
return nil
}
// parseBoolEnv accepts common boolean spellings used in environment variables.
func parseBoolEnv(name, raw string) (bool, error) {
s := strings.TrimSpace(strings.ToLower(raw))
if s == "" {
// Treat empty as false when explicitly present.
return false, nil
}
switch s {
case "1", "true", "on", "yes", "y":
return true, nil
case "0", "false", "off", "no", "n":
return false, nil
}
if b, err := strconv.ParseBool(s); err == nil {
return b, nil
}
return false, fmt.Errorf("invalid %s %q (want true/false/1/0)", name, raw)
}
// proxyURL validates the fixed configured proxy configuration and returns its URL.
func (c *Config) proxyURL() (*url.URL, error) {
raw := strings.TrimSpace(c.Proxy)
if raw == "" {
return nil, fmt.Errorf("%s is empty", envvars.CliProxyAddress)
}
redacted := redactProxyURL(raw)
u, err := url.Parse(raw)
if err != nil {
// Do not wrap the raw url.Parse error: its string embeds the original
// URL, which can contain userinfo (user:password). Return a redacted,
// generic message instead.
return nil, fmt.Errorf("invalid %s %q: malformed URL", envvars.CliProxyAddress, redacted)
}
if u.Scheme != "http" {
return nil, fmt.Errorf("invalid %s %q: scheme must be http", envvars.CliProxyAddress, redacted)
}
if u.Host == "" {
return nil, fmt.Errorf("invalid %s %q: missing host", envvars.CliProxyAddress, redacted)
}
// Security hardening: only allow a loopback proxy. This prevents accidental
// cross-machine proxying of credentials/traffic.
if u.Hostname() != "127.0.0.1" {
return nil, fmt.Errorf("invalid %s %q: host must be 127.0.0.1", envvars.CliProxyAddress, redacted)
}
if u.Port() == "" {
return nil, fmt.Errorf("invalid %s %q: explicit port is required", envvars.CliProxyAddress, redacted)
}
if u.Path != "" {
return nil, fmt.Errorf("invalid %s %q: path is not allowed", envvars.CliProxyAddress, redacted)
}
if u.RawQuery != "" {
return nil, fmt.Errorf("invalid %s %q: query is not allowed", envvars.CliProxyAddress, redacted)
}
if u.Fragment != "" {
return nil, fmt.Errorf("invalid %s %q: fragment is not allowed", envvars.CliProxyAddress, redacted)
}
return u, nil
}
// ApplyToTransport clones base and applies proxy plugin settings to the clone.
// Caller owns the returned *http.Transport.
func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) {
if base == nil {
base = http.DefaultTransport.(*http.Transport)
}
u, err := c.proxyURL()
if err != nil {
return nil, err
}
t := base.Clone()
t.Proxy = http.ProxyURL(u) // fixed proxy overrides environment proxy vars
if err := applyExtraRootCA(t, c.CAPath); err != nil {
return nil, err
}
return t, nil
}

View File

@@ -0,0 +1,372 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/internal/envvars"
)
// unsetEnv clears key for the duration of the test and restores its original value.
func unsetEnv(t *testing.T, key string) {
t.Helper()
old, had := os.LookupEnv(key)
_ = os.Unsetenv(key)
t.Cleanup(func() {
if had {
_ = os.Setenv(key, old)
} else {
_ = os.Unsetenv(key)
}
})
}
// unsetProxyPluginEnv clears proxy-related environment variables for deterministic tests.
func unsetProxyPluginEnv(t *testing.T) {
t.Helper()
unsetEnv(t, envvars.CliProxyEnable)
unsetEnv(t, envvars.CliProxyAddress)
unsetEnv(t, envvars.CliCAPath)
}
// writeFile creates parent directories and writes test data for fixtures.
func writeFile(t *testing.T, path string, data []byte, perm os.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(path, data, perm); err != nil {
t.Fatalf("WriteFile: %v", err)
}
}
// TestLoad_MissingFileReturnsNil verifies that Load reports no config when no file
// or proxy environment overrides exist.
func TestLoad_MissingFileReturnsNil(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
unsetProxyPluginEnv(t)
// TestLoad_MissingFileReturnsNil must reset loadOnce, loadCfg, and loadErr
// because multiple tests in this package share the package-level Load()
// cache via sync.Once.
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg != nil {
t.Fatalf("Load() = %#v, want nil (missing file)", cfg)
}
}
// TestApplyToTransport_SetsProxy verifies that a valid proxy config installs a fixed proxy.
func TestApplyToTransport_SetsProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
unsetProxyPluginEnv(t)
cfgPath := Path()
writeFile(t, cfgPath, []byte(`{
"LARKSUITE_CLI_PROXY_ENABLE": true,
"LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128",
"LARKSUITE_CLI_CA_PATH": ""
}`), 0600)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg == nil || !cfg.Enabled() {
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
}
base := http.DefaultTransport.(*http.Transport)
tr, err := cfg.ApplyToTransport(base)
if err != nil {
t.Fatalf("ApplyToTransport() error = %v", err)
}
if tr.Proxy == nil {
t.Fatal("Proxy func is nil, want fixed proxy")
}
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err != nil {
t.Fatalf("Proxy() error = %v", err)
}
if u == nil || u.String() != "http://127.0.0.1:3128" {
t.Fatalf("Proxy() = %v, want http://127.0.0.1:3128", u)
}
}
// TestLoad_RejectsNonLoopbackProxy verifies that proxy mode rejects non-loopback proxies.
func TestLoad_RejectsNonLoopbackProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
unsetProxyPluginEnv(t)
cfgPath := Path()
writeFile(t, cfgPath, []byte(`{
"LARKSUITE_CLI_PROXY_ENABLE": true,
"LARKSUITE_CLI_PROXY_ADDRESS": "http://10.0.0.1:3128",
"LARKSUITE_CLI_CA_PATH": ""
}`), 0600)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg == nil || !cfg.Enabled() {
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
}
_, err = cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport))
if err == nil {
t.Fatal("ApplyToTransport() error = nil, want invalid proxy host error")
}
}
// TestConfig_ProxyURLRejectsUnsupportedParts verifies the configured proxy validator
// rejects URLs with missing ports, paths, queries, and fragments.
func TestConfig_ProxyURLRejectsUnsupportedParts(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{
name: "missing explicit port",
raw: "http://127.0.0.1",
want: "explicit port is required",
},
{
name: "trailing slash path",
raw: "http://127.0.0.1:3128/",
want: "path is not allowed",
},
{
name: "query string",
raw: "http://127.0.0.1:3128?foo=bar",
want: "query is not allowed",
},
{
name: "fragment",
raw: "http://127.0.0.1:3128#frag",
want: "fragment is not allowed",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
_, err := (&Config{Proxy: tt.raw}).proxyURL()
if err == nil {
t.Fatalf("proxyURL() error = nil, want substring %q", tt.want)
}
if !strings.Contains(err.Error(), tt.want) {
t.Fatalf("proxyURL() error = %q, want substring %q", err, tt.want)
}
})
}
}
// TestLoad_EnvOnlyConfig verifies that proxy settings can come entirely from environment variables.
func TestLoad_EnvOnlyConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
t.Setenv(envvars.CliProxyEnable, "true")
t.Setenv(envvars.CliProxyAddress, "http://127.0.0.1:7777")
t.Setenv(envvars.CliCAPath, "")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg == nil || !cfg.Enabled() {
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
}
tr, err := cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport))
if err != nil {
t.Fatalf("ApplyToTransport() error = %v", err)
}
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err != nil {
t.Fatalf("Proxy() error = %v", err)
}
if u == nil || u.String() != "http://127.0.0.1:7777" {
t.Fatalf("Proxy() = %v, want http://127.0.0.1:7777", u)
}
}
// TestLoad_EnvOverridesFile verifies that proxy environment variables override file values.
func TestLoad_EnvOverridesFile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
// File enables with one proxy.
cfgPath := Path()
writeFile(t, cfgPath, []byte(`{
"LARKSUITE_CLI_PROXY_ENABLE": true,
"LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128",
"LARKSUITE_CLI_CA_PATH": ""
}`), 0600)
// Env overrides: disable + different proxy (should be irrelevant once disabled).
t.Setenv(envvars.CliProxyEnable, "false")
t.Setenv(envvars.CliProxyAddress, "http://127.0.0.1:9999")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg == nil {
t.Fatalf("Load() = nil, want non-nil (file exists)")
}
if cfg.Enabled() {
t.Fatalf("cfg.Enabled() = true, want false (env override)")
}
}
// TestConfig_ProxyURLMalformedDoesNotLeakUserinfo verifies that a malformed proxy
// URL containing credentials does not leak those credentials in the error string.
// url.Parse error strings embed the original URL, so wrapping them with %w would
// expose user:password.
func TestConfig_ProxyURLMalformedDoesNotLeakUserinfo(t *testing.T) {
// Invalid percent-encoding in host makes url.Parse fail while userinfo is present.
raw := "http://user:s3cret@%zz"
_, err := (&Config{Proxy: raw}).proxyURL()
if err == nil {
t.Fatal("proxyURL() error = nil, want malformed URL error")
}
if strings.Contains(err.Error(), "s3cret") {
t.Fatalf("proxyURL() error leaks password: %q", err)
}
if strings.Contains(err.Error(), "user:") {
t.Fatalf("proxyURL() error leaks username: %q", err)
}
if !strings.Contains(err.Error(), "malformed URL") {
t.Fatalf("proxyURL() error = %q, want it to mention malformed URL", err)
}
// The redacted form should still be present for diagnostics.
if !strings.Contains(err.Error(), "***") {
t.Fatalf("proxyURL() error = %q, want redacted userinfo marker", err)
}
}
// resetLoadState resets the package-level Load() cache for deterministic tests.
func resetLoadState() {
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
}
// TestLoad_RejectsWorldWritableConfig verifies that a world-writable proxy config
// is rejected rather than silently trusted (it could be tampered with by other
// local processes to redirect credential traffic).
func TestLoad_RejectsWorldWritableConfig(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX permission semantics")
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
resetLoadState()
unsetProxyPluginEnv(t)
p := Path()
writeFile(t, p, []byte(`{"LARKSUITE_CLI_PROXY_ENABLE":true,"LARKSUITE_CLI_PROXY_ADDRESS":"http://127.0.0.1:3128"}`), 0600)
// Chmod (not WriteFile perm) so umask cannot strip the world-writable bit.
if err := os.Chmod(p, 0o666); err != nil {
t.Fatalf("Chmod: %v", err)
}
_, err := Load()
if err == nil {
t.Fatal("Load() error = nil, want unsafe-config error for world-writable file")
}
if !strings.Contains(err.Error(), "world-writable") {
t.Fatalf("Load() error = %q, want world-writable rejection", err)
}
}
// TestLoad_RejectsGroupWritableConfig verifies group-writable configs are rejected.
func TestLoad_RejectsGroupWritableConfig(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX permission semantics")
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
resetLoadState()
unsetProxyPluginEnv(t)
p := Path()
writeFile(t, p, []byte(`{"LARKSUITE_CLI_PROXY_ENABLE":true,"LARKSUITE_CLI_PROXY_ADDRESS":"http://127.0.0.1:3128"}`), 0600)
if err := os.Chmod(p, 0o660); err != nil {
t.Fatalf("Chmod: %v", err)
}
_, err := Load()
if err == nil {
t.Fatal("Load() error = nil, want unsafe-config error for group-writable file")
}
if !strings.Contains(err.Error(), "group-writable") {
t.Fatalf("Load() error = %q, want group-writable rejection", err)
}
}
// TestLoad_RejectsSymlinkConfig verifies that a symlinked proxy config is rejected,
// preventing redirection of the trusted config path to an attacker-controlled file.
func TestLoad_RejectsSymlinkConfig(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation is privileged on Windows")
}
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
resetLoadState()
unsetProxyPluginEnv(t)
// Real file lives elsewhere; the config path is a symlink to it.
real := filepath.Join(dir, "real_proxy_config.json")
writeFile(t, real, []byte(`{"LARKSUITE_CLI_PROXY_ENABLE":true,"LARKSUITE_CLI_PROXY_ADDRESS":"http://127.0.0.1:3128"}`), 0600)
if err := os.Symlink(real, Path()); err != nil {
t.Fatalf("Symlink: %v", err)
}
_, err := Load()
if err == nil {
t.Fatal("Load() error = nil, want unsafe-config error for symlinked file")
}
if !strings.Contains(err.Error(), "symlink") {
t.Fatalf("Load() error = %q, want symlink rejection", err)
}
}
// TestLoad_AcceptsSecureConfig verifies the audit does not break the normal case:
// an owner-only 0600 config still loads.
func TestLoad_AcceptsSecureConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
resetLoadState()
unsetProxyPluginEnv(t)
writeFile(t, Path(), []byte(`{"LARKSUITE_CLI_PROXY_ENABLE":true,"LARKSUITE_CLI_PROXY_ADDRESS":"http://127.0.0.1:3128"}`), 0600)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v, want nil for secure 0600 config", err)
}
if cfg == nil || !cfg.Enabled() {
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
}
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"net/http"
"os"
"sync"
"time"
)
// Shared returns the base http.RoundTripper for all CLI HTTP clients.
//
// Precedence (highest first):
// 1. proxy-plugin mode — force traffic through a fixed loopback proxy;
// FAIL-CLOSED when the plugin config exists but is invalid.
// 2. LARK_CLI_NO_PROXY — direct egress, proxy disabled.
// 3. http.DefaultTransport — the stdlib process-wide singleton (honors
// HTTP(S)_PROXY), so every client shares one connection pool / TLS cache.
//
// The returned RoundTripper MUST NOT be mutated. Callers that need a customized
// transport should assert to *http.Transport and Clone() it. A shared base is
// required so persistConn read/write goroutines are reused; cloning per call
// leaks them until IdleConnTimeout (~90s) fires.
func Shared() http.RoundTripper {
// Proxy-plugin mode overrides everything, INCLUDING LARK_CLI_NO_PROXY. When
// the plugin config exists but is invalid, pluginTransport returns a
// fail-closed transport with ok=true and we return it here — we MUST NOT
// fall through to the NO_PROXY / DefaultTransport direct-egress paths below.
if t, ok := pluginTransport(); ok {
return t
}
if os.Getenv(EnvNoProxy) != "" {
return noProxyTransport()
}
return http.DefaultTransport
}
// Fallback returns a shared *http.Transport. It is a thin wrapper over Shared
// retained so modules already on the leak-free singleton path (internal/auth,
// internal/cmdutil transport decorators) do not have to migrate. New code
// should prefer Shared and treat the base as an http.RoundTripper.
//
// Fail-closed invariant: pluginTransport always expresses its blocked transport
// as a concrete *http.Transport (see failClosedTransport), so the assertion
// below preserves the block. The noProxyTransport() fallback is therefore only
// reached when no proxy plugin is configured and some external code replaced
// http.DefaultTransport with a non-*http.Transport — a case with no fail-closed
// intent, where a proxy-disabled transport is acceptable.
func Fallback() *http.Transport {
if t, ok := Shared().(*http.Transport); ok {
return t
}
return noProxyTransport()
}
// NewHTTPClient returns an *http.Client whose Transport is the shared,
// proxy-plugin-aware base (see Shared). Prefer this over a bare &http.Client{}
// for outbound requests: a bare client falls back to http.DefaultTransport and
// therefore silently bypasses proxy plugin mode (fixed proxy + trusted CA, or
// fail-closed), creating an audit blind spot.
//
// A zero timeout means no client-level timeout (callers relying on context
// deadlines pass 0).
func NewHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: Shared(),
Timeout: timeout,
}
}
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport, lazily
// built the first time LARK_CLI_NO_PROXY is observed set.
var noProxyTransport = sync.OnceValue(func() *http.Transport {
def, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return &http.Transport{}
}
t := def.Clone()
t.Proxy = nil
return t
})

View File

@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"net/http"
"net/url"
"testing"
"time"
)
// TestShared_DefaultReturnsStdlibSingleton verifies the default shared transport.
func TestShared_DefaultReturnsStdlibSingleton(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "")
if Shared() != http.DefaultTransport {
t.Error("Shared should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset")
}
}
// TestShared_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport.
func TestShared_NoProxyReturnsClone(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "1")
tr := Shared()
if tr == http.DefaultTransport {
t.Fatal("Shared should return a clone, not DefaultTransport, when LARK_CLI_NO_PROXY is set")
}
ht, ok := tr.(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", tr)
}
if ht.Proxy != nil {
t.Error("no-proxy transport should have Proxy == nil")
}
}
// TestShared_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport.
func TestShared_NoProxyIsCachedSingleton(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "1")
if Shared() != Shared() {
t.Error("repeated Shared calls with LARK_CLI_NO_PROXY set must return the same instance")
}
}
// TestShared_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib
// transport after unsetting LARK_CLI_NO_PROXY.
func TestShared_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
// the no-proxy singleton), then unsets it. Subsequent calls must return
// http.DefaultTransport, NOT the cached no-proxy clone.
t.Setenv(EnvNoProxy, "1")
if Shared() == http.DefaultTransport {
t.Fatal("precondition: first call with env set should not return DefaultTransport")
}
t.Setenv(EnvNoProxy, "")
if after := Shared(); after != http.DefaultTransport {
t.Errorf("after unsetting LARK_CLI_NO_PROXY, Shared must return http.DefaultTransport, got %T", after)
}
}
// TestShared_NoProxyOverridesSystemProxy verifies that LARK_CLI_NO_PROXY disables system proxies.
func TestShared_NoProxyOverridesSystemProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
t.Setenv(EnvNoProxy, "1")
ht, ok := Shared().(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", Shared())
}
if ht.Proxy != nil {
t.Error("LARK_CLI_NO_PROXY should override system proxy settings")
}
}
// TestNewHTTPClient verifies the factory wires the shared proxy-plugin-aware
// transport (instead of a bare client that bypasses proxy plugin mode).
func TestNewHTTPClient(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "")
c := NewHTTPClient(7 * time.Second)
if c.Transport == nil {
t.Fatal("NewHTTPClient transport is nil; want shared transport")
}
if c.Transport != Shared() {
t.Errorf("NewHTTPClient transport = %v, want Shared()", c.Transport)
}
if c.Timeout != 7*time.Second {
t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout)
}
}
// TestShared_PluginOverridesNoProxy locks the contract that proxy-plugin mode wins
// over LARK_CLI_NO_PROXY: even with NO_PROXY set, an enabled plugin forces the proxy.
func TestShared_PluginOverridesNoProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
t.Setenv(EnvNoProxy, "1") // NO_PROXY set, but the plugin must win
resetProxyPluginState()
writeFile(t, Path(), []byte(`{
"LARKSUITE_CLI_PROXY_ENABLE": true,
"LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128"
}`), 0600)
tr, ok := Shared().(*http.Transport)
if !ok {
t.Fatalf("Shared() = %T, want proxy *http.Transport, not the NO_PROXY clone", tr)
}
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err != nil || u == nil || u.String() != "http://127.0.0.1:3128" {
t.Fatalf("Proxy() = %v, %v; plugin must override NO_PROXY with the fixed proxy", u, err)
}
}
// TestShared_MalformedConfigFailsClosedEvenWithNoProxy locks the most dangerous
// invariant of the fold: a malformed proxy_config.json must FAIL CLOSED, never
// fall through to direct egress — not even to the LARK_CLI_NO_PROXY clone.
func TestShared_MalformedConfigFailsClosedEvenWithNoProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
t.Setenv(EnvNoProxy, "1")
resetProxyPluginState()
writeFile(t, Path(), []byte(`{`), 0600) // malformed
rt := Shared()
if rt == http.DefaultTransport {
t.Fatal("malformed config returned http.DefaultTransport — fail OPEN")
}
if rt == noProxyTransport() {
t.Fatal("malformed config fell through to the NO_PROXY direct-egress clone — fail OPEN")
}
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err == nil {
t.Fatalf("RoundTrip() err = nil (resp=%v); malformed config must fail closed", resp)
}
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/binding"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs"
)
// applyExtraRootCA augments t with an additional PEM bundle used for configured proxy
// TLS interception.
func applyExtraRootCA(t *http.Transport, caPath string) error {
caPath = strings.TrimSpace(caPath)
if caPath == "" {
return nil
}
if !filepath.IsAbs(caPath) {
return fmt.Errorf("invalid %s %q: must be an absolute path to a PEM file", envvars.CliCAPath, caPath)
}
safeCAPath, err := binding.AssertSecurePath(binding.AuditParams{
TargetPath: caPath,
Label: envvars.CliCAPath,
AllowReadableByOthers: true,
})
if err != nil {
return fmt.Errorf("unsafe %s %q: %w", envvars.CliCAPath, caPath, err)
}
pemBytes, err := vfs.ReadFile(safeCAPath)
if err != nil {
return fmt.Errorf("failed to read %s %q: %w", envvars.CliCAPath, caPath, err)
}
// Augment the system trust store. Do NOT silently discard a SystemCertPool
// error: falling back to an empty pool would make this transport trust ONLY
// the extra CA (dropping all system roots), which narrows trust unexpectedly
// and could break TLS to legitimate endpoints. Fail closed instead.
pool, err := x509.SystemCertPool()
if err != nil {
return fmt.Errorf("failed to load system cert pool for %s: %w", envvars.CliCAPath, err)
}
if pool == nil {
pool = x509.NewCertPool()
}
if ok := pool.AppendCertsFromPEM(pemBytes); !ok {
return fmt.Errorf("invalid %s %q: no certificates parsed from PEM", envvars.CliCAPath, caPath)
}
if t.TLSClientConfig == nil {
t.TLSClientConfig = &tls.Config{}
} else {
// Clone to avoid mutating shared config from the base transport.
t.TLSClientConfig = t.TLSClientConfig.Clone()
}
if t.TLSClientConfig.MinVersion == 0 || t.TLSClientConfig.MinVersion < tls.VersionTLS12 {
t.TLSClientConfig.MinVersion = tls.VersionTLS12
}
t.TLSClientConfig.RootCAs = pool
return nil
}

View File

@@ -0,0 +1,173 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// mustCreateTestCertPEM generates a short-lived self-signed CA certificate for tests.
func mustCreateTestCertPEM(t *testing.T) []byte {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("GenerateKey() error = %v", err)
}
der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "proxyplugin-test-ca",
},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
IsCA: true,
BasicConstraintsValid: true,
}, &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "proxyplugin-test-ca",
},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
IsCA: true,
BasicConstraintsValid: true,
}, &key.PublicKey, key)
if err != nil {
t.Fatalf("CreateCertificate() error = %v", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}
// TestApplyExtraRootCA_EmptyPathIsNoop verifies that an empty CA path leaves the transport unchanged.
func TestApplyExtraRootCA_EmptyPathIsNoop(t *testing.T) {
tr := &http.Transport{}
if err := applyExtraRootCA(tr, " "); err != nil {
t.Fatalf("applyExtraRootCA() error = %v", err)
}
if tr.TLSClientConfig != nil {
t.Fatalf("TLSClientConfig = %#v, want nil", tr.TLSClientConfig)
}
}
// TestApplyExtraRootCA_RejectsRelativePath verifies that CA paths must be absolute.
func TestApplyExtraRootCA_RejectsRelativePath(t *testing.T) {
tr := &http.Transport{}
err := applyExtraRootCA(tr, "ca.pem")
if err == nil || !strings.Contains(err.Error(), "must be an absolute path") {
t.Fatalf("applyExtraRootCA() error = %v, want absolute-path error", err)
}
}
// TestApplyExtraRootCA_RejectsMissingFile verifies missing PEM bundles fail before file reads.
func TestApplyExtraRootCA_RejectsMissingFile(t *testing.T) {
tr := &http.Transport{}
err := applyExtraRootCA(tr, filepath.Join(t.TempDir(), "missing.pem"))
if err == nil || !strings.Contains(err.Error(), "unsafe") {
t.Fatalf("applyExtraRootCA() error = %v, want unsafe path error", err)
}
}
// TestApplyExtraRootCA_RejectsInvalidPEM verifies validation of malformed PEM bundles.
func TestApplyExtraRootCA_RejectsInvalidPEM(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "invalid.pem")
writeFile(t, caPath, []byte("not a pem"), 0600)
tr := &http.Transport{}
err := applyExtraRootCA(tr, caPath)
if err == nil || !strings.Contains(err.Error(), "no certificates parsed from PEM") {
t.Fatalf("applyExtraRootCA() error = %v, want invalid PEM error", err)
}
}
// TestApplyExtraRootCA_RejectsInsecureCAPath verifies CA paths are safety-checked
// before reading the configured file.
func TestApplyExtraRootCA_RejectsInsecureCAPath(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "ca.pem")
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
if err := os.Chmod(caPath, 0666); err != nil {
t.Fatalf("Chmod() error = %v", err)
}
tr := &http.Transport{}
err := applyExtraRootCA(tr, caPath)
if err == nil || !strings.Contains(err.Error(), "unsafe") {
t.Fatalf("applyExtraRootCA() error = %v, want unsafe path error", err)
}
if tr.TLSClientConfig != nil {
t.Fatalf("TLSClientConfig = %#v, want nil", tr.TLSClientConfig)
}
}
// TestApplyExtraRootCA_SetsTLSConfigWhenMissing verifies initialization of TLSClientConfig when absent.
func TestApplyExtraRootCA_SetsTLSConfigWhenMissing(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "ca.pem")
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
tr := &http.Transport{}
if err := applyExtraRootCA(tr, caPath); err != nil {
t.Fatalf("applyExtraRootCA() error = %v", err)
}
if tr.TLSClientConfig == nil {
t.Fatal("TLSClientConfig = nil, want initialized config")
}
if tr.TLSClientConfig.RootCAs == nil {
t.Fatal("RootCAs = nil, want cert pool")
}
}
// TestApplyExtraRootCA_ClonesExistingTLSConfig verifies cloning when the base transport already has TLS settings.
func TestApplyExtraRootCA_ClonesExistingTLSConfig(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "ca.pem")
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
original := &tls.Config{ServerName: "open.feishu.cn"}
tr := &http.Transport{TLSClientConfig: original}
if err := applyExtraRootCA(tr, caPath); err != nil {
t.Fatalf("applyExtraRootCA() error = %v", err)
}
if tr.TLSClientConfig == original {
t.Fatal("TLSClientConfig pointer reused, want clone")
}
if tr.TLSClientConfig.ServerName != original.ServerName {
t.Fatalf("ServerName = %q, want %q", tr.TLSClientConfig.ServerName, original.ServerName)
}
if tr.TLSClientConfig.RootCAs == nil {
t.Fatal("RootCAs = nil, want cert pool")
}
}
// TestApplyExtraRootCA_PreservesHigherTLSMinVersion verifies that adding a CA
// does not relax an existing stricter TLS version floor.
func TestApplyExtraRootCA_PreservesHigherTLSMinVersion(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "ca.pem")
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
tr := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13}}
if err := applyExtraRootCA(tr, caPath); err != nil {
t.Fatalf("applyExtraRootCA() error = %v", err)
}
if tr.TLSClientConfig.MinVersion != tls.VersionTLS13 {
t.Fatalf("MinVersion = %x, want %x", tr.TLSClientConfig.MinVersion, tls.VersionTLS13)
}
}

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"fmt"
"net/http"
"net/url"
"sync"
)
// proxyPluginTransport is a fixed-proxy clone of http.DefaultTransport (with optional
// custom root CA), lazily built on first use when proxy plugin mode is enabled.
var proxyPluginTransport = sync.OnceValue(buildProxyPluginTransport)
// cachedBlockedTransport is a fail-closed transport cached on first use when
// the proxy plugin config exists but is invalid. This avoids cloning
// http.DefaultTransport on every pluginTransport call.
var cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
func buildBlockedTransport() http.RoundTripper {
return failClosedTransport(fmt.Errorf("proxy plugin config is invalid: %w", loadErr))
}
func buildProxyPluginTransport() http.RoundTripper {
def, ok := http.DefaultTransport.(*http.Transport)
if !ok {
// Cannot clone the stdlib transport. Fail closed with a concrete
// *http.Transport (not a bare RoundTripper) so downcasting callers such
// as Fallback cannot silently degrade this into a
// direct-egress transport.
return failClosedTransport(fmt.Errorf("proxy plugin transport unavailable: http.DefaultTransport is %T, want *http.Transport", http.DefaultTransport))
}
cfg, err := Load()
if err != nil {
// Fail closed: config file exists but is malformed/unreadable — do not
// silently fall back to direct egress.
return blockedTransport(def, fmt.Errorf("proxy plugin config is invalid: %w", err))
}
if cfg == nil || !cfg.Enabled() {
return def
}
t, err := cfg.ApplyToTransport(def)
if err != nil {
// Fail closed: do not silently fall back to direct egress when the
// operator explicitly enabled proxy plugin mode.
return blockedTransport(def, fmt.Errorf("proxy plugin enabled but config is invalid: %w", err))
}
return t
}
// pluginTransport returns the proxy plugin transport when proxy plugin mode is
// configured. The bool return is false when the plugin is not configured or not enabled.
func pluginTransport() (http.RoundTripper, bool) {
cfg, err := Load()
if err != nil {
return cachedBlockedTransport(), true
}
if cfg == nil || !cfg.Enabled() {
return nil, false
}
return proxyPluginTransport(), true
}
// failClosedTransport returns a *http.Transport that always fails RoundTrip with
// err. It clones http.DefaultTransport when possible (preserving dial/timeout
// tuning); otherwise it builds a minimal transport. Returning a concrete
// *http.Transport (rather than a bare RoundTripper) is required so downcasting
// callers such as Fallback cannot silently degrade a fail-closed
// signal into a direct-egress transport.
func failClosedTransport(err error) *http.Transport {
if def, ok := http.DefaultTransport.(*http.Transport); ok {
return blockedTransport(def, err)
}
return &http.Transport{
Proxy: func(*http.Request) (*url.URL, error) {
return nil, err
},
}
}
func blockedTransport(base *http.Transport, err error) *http.Transport {
blocked := base.Clone()
blocked.Proxy = func(*http.Request) (*url.URL, error) {
return nil, err
}
return blocked
}

View File

@@ -0,0 +1,195 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"io"
"net/http"
"net/url"
"strings"
"sync"
"testing"
)
func resetProxyPluginState() {
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
proxyPluginTransport = sync.OnceValue(buildProxyPluginTransport)
cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
}
func TestPluginTransport_NotConfigured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
tr, ok := pluginTransport()
if ok {
t.Fatalf("pluginTransport() ok = true, want false")
}
if tr != nil {
t.Fatalf("pluginTransport() transport = %T, want nil", tr)
}
}
func TestPluginTransport_EnabledReturnsFixedProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
cfgPath := Path()
writeFile(t, cfgPath, []byte(`{
"LARKSUITE_CLI_PROXY_ENABLE": true,
"LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128",
"LARKSUITE_CLI_CA_PATH": ""
}`), 0600)
rt, ok := pluginTransport()
if !ok {
t.Fatal("pluginTransport() ok = false, want true")
}
tr, ok := rt.(*http.Transport)
if !ok {
t.Fatalf("pluginTransport() = %T, want *http.Transport", rt)
}
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err != nil {
t.Fatalf("Proxy() error = %v", err)
}
if u == nil || u.String() != "http://127.0.0.1:3128" {
t.Fatalf("Proxy() = %v, want http://127.0.0.1:3128", u)
}
}
func TestPluginTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
restoreDefaultTransport := replaceDefaultTransport(okRoundTripper{})
defer restoreDefaultTransport()
writeFile(t, Path(), []byte(`{`), 0600)
rt, ok := pluginTransport()
if !ok {
t.Fatal("pluginTransport() ok = false, want true")
}
if rt == http.DefaultTransport {
t.Fatalf("pluginTransport() returned http.DefaultTransport, want fail-closed transport")
}
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err == nil {
t.Fatalf("RoundTrip() error = nil, response = %#v; want fail-closed error", resp)
}
if resp != nil {
t.Fatalf("RoundTrip() response = %#v, want nil", resp)
}
}
func TestPluginTransport_InvalidConfigReturnsCachedInstance(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
writeFile(t, Path(), []byte(`{`), 0600)
a, ok := pluginTransport()
if !ok {
t.Fatal("pluginTransport() ok = false, want true")
}
b, ok := pluginTransport()
if !ok {
t.Fatal("pluginTransport() ok = false, want true")
}
if a != b {
t.Fatalf("pluginTransport() returned different instances on repeated calls; blocked transport must be cached")
}
}
func TestBuildProxyPluginTransport_InvalidConfigFailsClosed(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
writeFile(t, Path(), []byte(`{`), 0600)
rt := buildProxyPluginTransport()
if rt == http.DefaultTransport {
t.Fatalf("buildProxyPluginTransport() returned http.DefaultTransport, want fail-closed transport")
}
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err == nil {
t.Fatalf("RoundTrip() error = nil, response = %#v; want fail-closed error", resp)
}
if resp != nil {
t.Fatalf("RoundTrip() response = %#v, want nil", resp)
}
}
func TestBuildProxyPluginTransport_NonTransportDefaultFailsClosed(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
restoreDefaultTransport := replaceDefaultTransport(okRoundTripper{})
defer restoreDefaultTransport()
rt := buildProxyPluginTransport()
if rt == http.DefaultTransport {
t.Fatalf("buildProxyPluginTransport() returned http.DefaultTransport, want fail-closed transport")
}
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err == nil {
t.Fatalf("RoundTrip() error = nil, response = %#v; want fail-closed error", resp)
}
if resp != nil {
t.Fatalf("RoundTrip() response = %#v, want nil", resp)
}
}
// TestPluginTransport_InvalidConfigBlockerIsConcreteTransport guards the
// fail-closed invariant that Fallback relies on: even when
// http.DefaultTransport is not an *http.Transport, an invalid proxy config must
// produce a blocked transport that is itself a concrete *http.Transport. If it
// were a bare RoundTripper, Fallback would downcast-fail and
// silently degrade it into a direct-egress transport.
func TestPluginTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
restoreDefaultTransport := replaceDefaultTransport(okRoundTripper{})
defer restoreDefaultTransport()
writeFile(t, Path(), []byte(`{`), 0600)
rt, ok := pluginTransport()
if !ok {
t.Fatal("pluginTransport() ok = false, want true")
}
if _, isTransport := rt.(*http.Transport); !isTransport {
t.Fatalf("pluginTransport() blocked transport = %T, want *http.Transport so Fallback cannot degrade it to direct egress", rt)
}
// Must remain fail-closed.
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err == nil {
t.Fatalf("RoundTrip() error = nil, response = %#v; want fail-closed error", resp)
}
if resp != nil {
t.Fatalf("RoundTrip() response = %#v, want nil", resp)
}
}
type okRoundTripper struct{}
func (okRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(""))}, nil
}
func replaceDefaultTransport(rt http.RoundTripper) func() {
original := http.DefaultTransport
http.DefaultTransport = rt
return func() {
http.DefaultTransport = original
}
}

115
internal/transport/warn.go Normal file
View File

@@ -0,0 +1,115 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"fmt"
"io"
"net/url"
"os"
"strings"
"sync"
"github.com/larksuite/cli/internal/envvars"
)
// Proxy environment constants control shared transport proxy behavior.
const (
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
EnvNoProxy = "LARK_CLI_NO_PROXY"
)
// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads.
var proxyEnvKeys = []string{
"HTTPS_PROXY", "https_proxy",
"HTTP_PROXY", "http_proxy",
"ALL_PROXY", "all_proxy",
}
// DetectProxyEnv returns the first proxy-related environment variable that is set,
// or empty strings if none are configured.
func DetectProxyEnv() (key, value string) {
for _, k := range proxyEnvKeys {
if v := os.Getenv(k); v != "" {
return k, v
}
}
return "", ""
}
// proxyWarningOnce ensures proxy environment warnings are emitted at most once.
var proxyWarningOnce sync.Once
// proxyPluginStatus reports the configured proxy plugin address, the extra
// trusted CA path (if any), and whether proxy plugin mode is enabled. It is
// indirected through a package variable so tests can simulate plugin-enabled
// mode without the process-global Load() sync.Once cache.
var proxyPluginStatus = func() (addr, caPath string, enabled bool) {
cfg, err := Load()
if err != nil || !cfg.Enabled() {
return "", "", false
}
return cfg.Proxy, cfg.CAPath, true
}
// redactProxyURL masks userinfo (username:password) in a proxy URL.
// Handles both scheme-prefixed ("http://user:pass@host") and bare ("user:pass@host") formats.
func redactProxyURL(raw string) string {
// Try standard url.Parse first (works when scheme is present)
u, err := url.Parse(raw)
if err == nil && u.User != nil {
return u.Scheme + "://***@" + u.Host + u.RequestURI()
}
// Fallback: handle bare URLs without scheme (e.g. "user:pass@proxy:8080")
if at := strings.LastIndex(raw, "@"); at > 0 {
return "***@" + raw[at+1:]
}
return raw
}
// WarnIfProxied prints a one-time warning to w when a proxy environment variable
// is detected and proxy is not disabled via LARK_CLI_NO_PROXY. Proxy credentials
// are redacted. Safe to call multiple times; only the first call prints.
//
// The warning is suppressed entirely when interactive is false — i.e. stdin is
// not a TTY, which is the case for agent / CI / piped invocations. Those callers
// frequently parse the CLI's stdout as JSON and merge streams with `2>&1`; a
// stray stderr warning then corrupts the parsed payload. Suppressing in the
// non-interactive case keeps machine-consumed output clean, while human
// interactive sessions still get the security notice. Passing interactive=false
// does not consume the once guard, so a later interactive call can still warn.
func WarnIfProxied(w io.Writer, interactive bool) {
if !interactive {
return
}
proxyWarningOnce.Do(func() {
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
// Shared), so its warning and disable instructions take precedence.
// Emitting the env-proxy warning here would be misleading: it tells the
// user to set LARK_CLI_NO_PROXY=1, which does NOT disable the plugin proxy.
if addr, caPath, enabled := proxyPluginStatus(); enabled {
fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin enabled: all requests (including credentials) are forced through %s. To disable, set %s=false or remove %s.\n",
redactProxyURL(addr), envvars.CliProxyEnable, Path())
if strings.TrimSpace(caPath) != "" {
// A custom CA means upstream TLS can be intercepted/inspected by
// the proxy (MITM). Surface it so the operator is aware traffic
// (including Bearer tokens) is decryptable on this host.
fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin trusts a custom CA (%s); TLS to upstreams can be intercepted/inspected by this proxy.\n",
caPath)
}
return
}
if os.Getenv(EnvNoProxy) != "" {
return
}
key, val := DetectProxyEnv()
if key == "" {
return
}
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n",
key, redactProxyURL(val), EnvNoProxy)
})
}

View File

@@ -0,0 +1,275 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"bytes"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/internal/envvars"
)
// TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior.
func TestDetectProxyEnv(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
// Clear all proxy env vars first
for _, k := range proxyEnvKeys {
t.Setenv(k, "")
}
key, val := DetectProxyEnv()
if key != "" || val != "" {
t.Errorf("expected no proxy, got %s=%s", key, val)
}
t.Setenv("HTTPS_PROXY", "http://proxy:8888")
key, val = DetectProxyEnv()
if key != "HTTPS_PROXY" || val != "http://proxy:8888" {
t.Errorf("expected HTTPS_PROXY=http://proxy:8888, got %s=%s", key, val)
}
}
// TestWarnIfProxied_WithProxy verifies that proxy detection emits a warning.
func TestWarnIfProxied_WithProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
var buf bytes.Buffer
WarnIfProxied(&buf, true)
out := buf.String()
if out == "" {
t.Error("expected warning output when proxy is set")
}
if !bytes.Contains([]byte(out), []byte("HTTPS_PROXY")) {
t.Errorf("warning should mention HTTPS_PROXY, got: %s", out)
}
if !bytes.Contains([]byte(out), []byte(EnvNoProxy)) {
t.Errorf("warning should mention %s, got: %s", EnvNoProxy, out)
}
}
// TestWarnIfProxied_WithoutProxy verifies that no warning is emitted without proxy settings.
func TestWarnIfProxied_WithoutProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
proxyWarningOnce = sync.Once{}
for _, k := range proxyEnvKeys {
t.Setenv(k, "")
}
var buf bytes.Buffer
WarnIfProxied(&buf, true)
if buf.Len() != 0 {
t.Errorf("expected no output when no proxy is set, got: %s", buf.String())
}
}
func TestWarnIfProxied_SilentWhenNonInteractive(t *testing.T) {
proxyWarningOnce = sync.Once{}
// Non-interactive (interactive=false) mirrors agent / CI / piped invocations
// where stdin is not a TTY. The proxy warning must be suppressed so callers
// that parse stdout as JSON — often merging streams with `2>&1` — are not
// corrupted by a stray stderr line.
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
var buf bytes.Buffer
WarnIfProxied(&buf, false)
if buf.Len() != 0 {
t.Errorf("expected no warning in non-interactive mode, got: %s", buf.String())
}
}
// TestWarnIfProxied_SilentWhenDisabled verifies that LARK_CLI_NO_PROXY suppresses warnings.
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
t.Setenv(EnvNoProxy, "1")
var buf bytes.Buffer
WarnIfProxied(&buf, true)
if buf.Len() != 0 {
t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String())
}
}
// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once.
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
proxyWarningOnce = sync.Once{}
t.Setenv("HTTP_PROXY", "http://proxy:1234")
var buf bytes.Buffer
WarnIfProxied(&buf, true)
first := buf.String()
WarnIfProxied(&buf, true)
second := buf.String()
if first == "" {
t.Error("expected warning on first call")
}
if second != first {
t.Error("expected no additional output on second call")
}
}
// TestWarnIfProxied_ProxyPluginEnabled verifies that when proxy plugin mode is
// enabled, the warning describes the plugin proxy and the correct disable method
// (LARKSUITE_CLI_PROXY_ENABLE=false) instead of the misleading LARK_CLI_NO_PROXY
// instruction — even when env proxy and LARK_CLI_NO_PROXY are also set.
func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
proxyWarningOnce = sync.Once{}
old := proxyPluginStatus
proxyPluginStatus = func() (string, string, bool) { return "http://127.0.0.1:3128", "", true }
t.Cleanup(func() { proxyPluginStatus = old })
// Plugin mode overrides these; the warning must still be the plugin one.
t.Setenv("HTTPS_PROXY", "http://corp-proxy:8080")
t.Setenv(EnvNoProxy, "1")
var buf bytes.Buffer
WarnIfProxied(&buf, true)
out := buf.String()
if !strings.Contains(out, "127.0.0.1:3128") {
t.Errorf("warning should mention the plugin proxy address, got: %s", out)
}
if !strings.Contains(out, envvars.CliProxyEnable) {
t.Errorf("warning should mention %s as the disable method, got: %s", envvars.CliProxyEnable, out)
}
if strings.Contains(out, "Set "+EnvNoProxy+"=1") {
t.Errorf("warning must NOT give the misleading %s disable instruction when plugin is enabled, got: %s", EnvNoProxy, out)
}
// No custom CA configured -> no interception warning.
if strings.Contains(out, "custom CA") {
t.Errorf("warning should not mention a custom CA when none is configured, got: %s", out)
}
}
// TestWarnIfProxied_ProxyPluginCustomCAWarns verifies that when a custom CA is
// trusted, the warning surfaces the TLS-interception capability.
func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
proxyWarningOnce = sync.Once{}
old := proxyPluginStatus
proxyPluginStatus = func() (string, string, bool) {
return "http://127.0.0.1:3128", "/etc/lark/extra_ca.pem", true
}
t.Cleanup(func() { proxyPluginStatus = old })
var buf bytes.Buffer
WarnIfProxied(&buf, true)
out := buf.String()
if !strings.Contains(out, "custom CA") {
t.Errorf("warning should mention the custom CA, got: %s", out)
}
if !strings.Contains(out, "/etc/lark/extra_ca.pem") {
t.Errorf("warning should include the CA path, got: %s", out)
}
if !strings.Contains(out, "intercept") {
t.Errorf("warning should mention TLS interception, got: %s", out)
}
}
// TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials verifies the plugin
// warning never leaks credentials embedded in the configured proxy address.
func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
proxyWarningOnce = sync.Once{}
old := proxyPluginStatus
proxyPluginStatus = func() (string, string, bool) { return "http://user:s3cret@127.0.0.1:3128", "", true }
t.Cleanup(func() { proxyPluginStatus = old })
var buf bytes.Buffer
WarnIfProxied(&buf, true)
out := buf.String()
if strings.Contains(out, "s3cret") {
t.Errorf("plugin warning leaked password, got: %s", out)
}
if strings.Contains(out, "user:") {
t.Errorf("plugin warning leaked username, got: %s", out)
}
if !strings.Contains(out, "***@127.0.0.1:3128") {
t.Errorf("plugin warning should contain redacted proxy URL, got: %s", out)
}
}
// TestRedactProxyURL verifies redaction of proxy credentials across supported formats.
func TestRedactProxyURL(t *testing.T) {
tests := []struct {
input string
want string
}{
{"http://proxy:8080", "http://proxy:8080"},
{"http://user:pass@proxy:8080", "http://***@proxy:8080/"},
{"http://user:p%40ss@proxy:8080/path", "http://***@proxy:8080/path"},
{"http://user@proxy:8080", "http://***@proxy:8080/"},
{"socks5://admin:secret@10.0.0.1:1080", "socks5://***@10.0.0.1:1080/"},
{"user:pass@proxy:8080", "***@proxy:8080"},
{"admin:s3cret@10.0.0.1:3128", "***@10.0.0.1:3128"},
{"not-a-url", "not-a-url"},
{"", ""},
}
for _, tt := range tests {
got := redactProxyURL(tt.input)
if got != tt.want {
t.Errorf("redactProxyURL(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
// TestWarnIfProxied_RedactsCredentials verifies that warning output never leaks credentials.
func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")
var buf bytes.Buffer
WarnIfProxied(&buf, true)
out := buf.String()
if bytes.Contains([]byte(out), []byte("s3cret")) {
t.Errorf("warning should not contain proxy password, got: %s", out)
}
if bytes.Contains([]byte(out), []byte("admin")) {
t.Errorf("warning should not contain proxy username, got: %s", out)
}
if !bytes.Contains([]byte(out), []byte("***@proxy:8080")) {
t.Errorf("warning should contain redacted proxy URL, got: %s", out)
}
}

View File

@@ -17,7 +17,7 @@ import (
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
@@ -64,7 +64,7 @@ func httpClient() *http.Client {
}
return &http.Client{
Timeout: fetchTimeout,
Transport: util.SharedTransport(),
Transport: transport.Shared(),
}
}

View File

@@ -1,118 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package util
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
)
const (
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
EnvNoProxy = "LARK_CLI_NO_PROXY"
)
// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads.
var proxyEnvKeys = []string{
"HTTPS_PROXY", "https_proxy",
"HTTP_PROXY", "http_proxy",
"ALL_PROXY", "all_proxy",
}
// DetectProxyEnv returns the first proxy-related environment variable that is set,
// or empty strings if none are configured.
func DetectProxyEnv() (key, value string) {
for _, k := range proxyEnvKeys {
if v := os.Getenv(k); v != "" {
return k, v
}
}
return "", ""
}
var proxyWarningOnce sync.Once
// redactProxyURL masks userinfo (username:password) in a proxy URL.
// Handles both scheme-prefixed ("http://user:pass@host") and bare ("user:pass@host") formats.
func redactProxyURL(raw string) string {
// Try standard url.Parse first (works when scheme is present)
u, err := url.Parse(raw)
if err == nil && u.User != nil {
return u.Scheme + "://***@" + u.Host + u.RequestURI()
}
// Fallback: handle bare URLs without scheme (e.g. "user:pass@proxy:8080")
if at := strings.LastIndex(raw, "@"); at > 0 {
return "***@" + raw[at+1:]
}
return raw
}
// WarnIfProxied prints a one-time warning to w when a proxy environment variable
// is detected and proxy is not disabled via LARK_CLI_NO_PROXY. Proxy credentials
// are redacted. Safe to call multiple times; only the first call prints.
func WarnIfProxied(w io.Writer) {
proxyWarningOnce.Do(func() {
if os.Getenv(EnvNoProxy) != "" {
return
}
key, val := DetectProxyEnv()
if key == "" {
return
}
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n",
key, redactProxyURL(val), EnvNoProxy)
})
}
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport,
// lazily built the first time LARK_CLI_NO_PROXY is observed set.
var noProxyTransport = sync.OnceValue(func() *http.Transport {
def, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return &http.Transport{}
}
t := def.Clone()
t.Proxy = nil
return t
})
// SharedTransport returns the base http.RoundTripper for CLI HTTP clients.
//
// By default it returns http.DefaultTransport — the stdlib-provided
// process-wide singleton — so every HTTP client in the process shares one
// TCP connection pool, TLS session cache, and HTTP/2 state. When
// LARK_CLI_NO_PROXY is set it returns a separate proxy-disabled singleton
// clone; LARK_CLI_NO_PROXY is checked on every call, but the clone is built
// at most once.
//
// The returned RoundTripper MUST NOT be mutated. Callers that need a
// customized transport should assert to *http.Transport and Clone() it.
// Using a shared base is required so persistConn readLoop/writeLoop
// goroutines are reused; cloning per call leaks them until IdleConnTimeout
// (~90s) fires.
func SharedTransport() http.RoundTripper {
if os.Getenv(EnvNoProxy) != "" {
return noProxyTransport()
}
return http.DefaultTransport
}
// FallbackTransport returns a shared *http.Transport singleton. It is a
// thin wrapper over SharedTransport retained so modules that were already
// on the leak-free singleton path (internal/auth, internal/cmdutil
// transport decorators) do not have to migrate. New code should prefer
// SharedTransport and treat the base as an http.RoundTripper.
func FallbackTransport() *http.Transport {
if t, ok := SharedTransport().(*http.Transport); ok {
return t
}
return noProxyTransport()
}

View File

@@ -1,204 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package util
import (
"bytes"
"net/http"
"sync"
"testing"
)
func TestDetectProxyEnv(t *testing.T) {
// Clear all proxy env vars first
for _, k := range proxyEnvKeys {
t.Setenv(k, "")
}
key, val := DetectProxyEnv()
if key != "" || val != "" {
t.Errorf("expected no proxy, got %s=%s", key, val)
}
t.Setenv("HTTPS_PROXY", "http://proxy:8888")
key, val = DetectProxyEnv()
if key != "HTTPS_PROXY" || val != "http://proxy:8888" {
t.Errorf("expected HTTPS_PROXY=http://proxy:8888, got %s=%s", key, val)
}
}
func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
t.Setenv(EnvNoProxy, "")
tr := SharedTransport()
if tr != http.DefaultTransport {
t.Error("SharedTransport should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset")
}
}
func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
t.Setenv(EnvNoProxy, "1")
tr := SharedTransport()
if tr == http.DefaultTransport {
t.Fatal("SharedTransport should return a clone, not DefaultTransport, when LARK_CLI_NO_PROXY is set")
}
ht, ok := tr.(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", tr)
}
if ht.Proxy != nil {
t.Error("no-proxy transport should have Proxy == nil")
}
}
func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
t.Setenv(EnvNoProxy, "1")
a := SharedTransport()
b := SharedTransport()
if a != b {
t.Error("repeated SharedTransport calls with LARK_CLI_NO_PROXY set must return the same instance")
}
}
func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
// the no-proxy singleton), then unsets it. Subsequent calls must return
// http.DefaultTransport, NOT the cached no-proxy clone.
t.Setenv(EnvNoProxy, "1")
noProxy := SharedTransport()
if noProxy == http.DefaultTransport {
t.Fatal("precondition: first call with env set should not return DefaultTransport")
}
t.Setenv(EnvNoProxy, "")
after := SharedTransport()
if after != http.DefaultTransport {
t.Errorf("after unsetting LARK_CLI_NO_PROXY, SharedTransport must return http.DefaultTransport, got %T (%p)", after, after)
}
}
func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
t.Setenv(EnvNoProxy, "1")
ht, ok := SharedTransport().(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", SharedTransport())
}
if ht.Proxy != nil {
t.Error("LARK_CLI_NO_PROXY should override system proxy settings")
}
}
func TestWarnIfProxied_WithProxy(t *testing.T) {
// Reset the once guard for this test
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
var buf bytes.Buffer
WarnIfProxied(&buf)
out := buf.String()
if out == "" {
t.Error("expected warning output when proxy is set")
}
if !bytes.Contains([]byte(out), []byte("HTTPS_PROXY")) {
t.Errorf("warning should mention HTTPS_PROXY, got: %s", out)
}
if !bytes.Contains([]byte(out), []byte(EnvNoProxy)) {
t.Errorf("warning should mention %s, got: %s", EnvNoProxy, out)
}
}
func TestWarnIfProxied_WithoutProxy(t *testing.T) {
proxyWarningOnce = sync.Once{}
for _, k := range proxyEnvKeys {
t.Setenv(k, "")
}
var buf bytes.Buffer
WarnIfProxied(&buf)
if buf.Len() != 0 {
t.Errorf("expected no output when no proxy is set, got: %s", buf.String())
}
}
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
t.Setenv(EnvNoProxy, "1")
var buf bytes.Buffer
WarnIfProxied(&buf)
if buf.Len() != 0 {
t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String())
}
}
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
proxyWarningOnce = sync.Once{}
t.Setenv("HTTP_PROXY", "http://proxy:1234")
var buf bytes.Buffer
WarnIfProxied(&buf)
first := buf.String()
WarnIfProxied(&buf)
second := buf.String()
if first == "" {
t.Error("expected warning on first call")
}
if second != first {
t.Error("expected no additional output on second call")
}
}
func TestRedactProxyURL(t *testing.T) {
tests := []struct {
input string
want string
}{
{"http://proxy:8080", "http://proxy:8080"},
{"http://user:pass@proxy:8080", "http://***@proxy:8080/"},
{"http://user:p%40ss@proxy:8080/path", "http://***@proxy:8080/path"},
{"http://user@proxy:8080", "http://***@proxy:8080/"},
{"socks5://admin:secret@10.0.0.1:1080", "socks5://***@10.0.0.1:1080/"},
{"user:pass@proxy:8080", "***@proxy:8080"},
{"admin:s3cret@10.0.0.1:3128", "***@10.0.0.1:3128"},
{"not-a-url", "not-a-url"},
{"", ""},
}
for _, tt := range tests {
got := redactProxyURL(tt.input)
if got != tt.want {
t.Errorf("redactProxyURL(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")
var buf bytes.Buffer
WarnIfProxied(&buf)
out := buf.String()
if bytes.Contains([]byte(out), []byte("s3cret")) {
t.Errorf("warning should not contain proxy password, got: %s", out)
}
if bytes.Contains([]byte(out), []byte("admin")) {
t.Errorf("warning should not contain proxy username, got: %s", out)
}
if !bytes.Contains([]byte(out), []byte("***@proxy:8080")) {
t.Errorf("warning should contain redacted proxy URL, got: %s", out)
}
}

View File

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

View File

@@ -25,6 +25,10 @@ var BaseAdvpermDisable = common.Shortcut{
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
},
Tips: []string{
baseHighRiskYesTip,
"Disabling advanced permissions invalidates existing custom roles; confirm the target Base before passing --yes.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")

View File

@@ -25,6 +25,9 @@ var BaseAdvpermEnable = common.Shortcut{
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
},
Tips: []string{
"Caller must be a Base admin; enable advanced permissions before creating or updating roles.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")

View File

@@ -24,6 +24,11 @@ var BaseBaseCopy = common.Shortcut{
{Name: "without-content", Type: "bool", Desc: "copy structure only"},
{Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"},
},
Tips: []string{
`Example: lark-cli base +base-copy --base-token <base_token> --name "Copy of Project Tracker"`,
"Use --without-content when the user wants only structure.",
"If copied as bot, output may include permission_grant; report it so the user knows whether they can open the new Base.",
},
DryRun: dryRunBaseCopy,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseCopy(runtime)

View File

@@ -22,6 +22,10 @@ var BaseBaseCreate = common.Shortcut{
{Name: "folder-token", Desc: "folder token for destination"},
{Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"},
},
Tips: []string{
`Example: lark-cli base +base-create --name "Project Tracker" --time-zone Asia/Shanghai`,
"If created as bot, output may include permission_grant; report it so the user knows whether they can open the new Base.",
},
DryRun: dryRunBaseCreate,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseCreate(runtime)

View File

@@ -20,7 +20,12 @@ var BaseDataQuery = common.Shortcut{
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "dsl", Desc: "query JSON DSL (LiteQuery Protocol)", Required: true},
{Name: "dsl", Desc: "query JSON DSL; read lark-base-data-query-guide.md first, then lark-base-data-query.md for the full DSL SSOT", Required: true},
},
Tips: []string{
"Use +data-query for server-side aggregation, grouping, filtering, sorting, and Top N queries.",
"Read lark-base-data-query-guide.md for common fewshots; use lark-base-data-query.md only when the full DSL reference is needed.",
"`dimensions` and `measures` cannot both be empty.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
var dsl map[string]interface{}

View File

@@ -515,7 +515,7 @@ func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
if !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
if !strings.Contains(err.Error(), "lark-base skill") {
if !strings.Contains(err.Error(), "match the documented shape") {
t.Fatalf("err=%v", err)
}
if strings.Contains(err.Error(), "array") {

View File

@@ -25,6 +25,9 @@ var BaseFormCreate = common.Shortcut{
{Name: "name", Desc: "form name", Required: true},
{Name: "description", Desc: `form description (plain text or markdown link like [text](https://example.com))`},
},
Tips: []string{
"Record the returned form_id; form question create/list/update/delete commands need it.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms").

View File

@@ -22,6 +22,10 @@ var BaseFormDelete = common.Shortcut{
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},
Tips: []string{
"Use +form-list or +form-get first when the form target is ambiguous.",
baseHighRiskYesTip,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id").

View File

@@ -23,7 +23,7 @@ var BaseFormsList = common.Shortcut{
Flags: []common.Flag{
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "page-size", Type: "int", Default: "100", Desc: "page size per request (max 100)"},
{Name: "page-size", Type: "int", Default: "100", Desc: "page size per request, max 100"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().

View File

@@ -25,6 +25,9 @@ var BaseFormQuestionsDelete = common.Shortcut{
{Name: "form-id", Desc: "form ID", Required: true},
{Name: "question-ids", Desc: `JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`, Required: true},
},
Tips: []string{
baseHighRiskYesTip,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").

View File

@@ -25,6 +25,9 @@ var BaseFormQuestionsList = common.Shortcut{
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},
Tips: []string{
"Use returned question id values for +form-questions-update and +form-questions-delete.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").

View File

@@ -17,7 +17,10 @@ var BaseBaseGet = common.Shortcut{
Scopes: []string{"base:app:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true)},
DryRun: dryRunBaseGet,
Tips: []string{
"Use a real Base token; workspace tokens and wiki tokens are not accepted by this command.",
},
DryRun: dryRunBaseGet,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseGet(runtime)
},

View File

@@ -25,7 +25,12 @@ var BaseRoleCreate = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "json", Desc: `body JSON (AdvPermBaseRoleConfig), e.g. {"role_name":"Reviewer","role_type":"custom_role","table_rule_map":{...}}`, Required: true},
{Name: "json", Desc: "role config JSON; read lark-base-role-guide.md and role-config.md before constructing permissions", Required: true},
},
Tips: []string{
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
"Use lark-base-role-guide.md as the entry guide and role-config.md as the role permission JSON SSOT.",
"Create supports custom_role only.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {

View File

@@ -26,6 +26,12 @@ var BaseRoleDelete = common.Shortcut{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
},
Tips: []string{
baseHighRiskYesTip,
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
"Only custom roles can be deleted; system roles cannot be deleted.",
"Use +role-get first if the role target is ambiguous, then pass --yes to confirm deletion.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")

View File

@@ -27,6 +27,10 @@ var BaseRoleGet = common.Shortcut{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
},
Tips: []string{
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
"Use before +role-update to inspect the current full permission config.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")

View File

@@ -26,6 +26,10 @@ var BaseRoleList = common.Shortcut{
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
},
Tips: []string{
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
"Returns role summaries; use +role-get for the full permission config.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")

View File

@@ -26,7 +26,13 @@ var BaseRoleUpdate = common.Shortcut{
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
{Name: "json", Desc: `body JSON (delta AdvPermBaseRoleConfig), e.g. {"role_name":"New Name","role_type":"custom_role","table_rule_map":{...}}`, Required: true},
{Name: "json", Desc: "delta role config JSON; read lark-base-role-guide.md and role-config.md before changing permissions", Required: true},
},
Tips: []string{
baseHighRiskYesTip,
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
"Update is a delta merge: only changed fields are updated, others remain unchanged.",
"Use lark-base-role-guide.md as the entry guide and role-config.md as the role permission JSON SSOT.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {

View File

@@ -63,7 +63,7 @@ func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
}
func jsonInputTip(flagName string) string {
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; use the lark-base skill or this command's reference to find the expected body", flagName)
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; for complex JSON/DSL, read the lark-base reference and match the documented shape", flagName)
}
func formatJSONError(flagName string, target string, err error) error {

View File

@@ -198,6 +198,25 @@ func TestBaseDeleteShortcutsRisk(t *testing.T) {
}
}
func TestBaseHighRiskShortcutsTipsGuideAgents(t *testing.T) {
for _, shortcut := range Shortcuts() {
if shortcut.Risk != "high-risk-write" {
continue
}
parent := &cobra.Command{Use: "base"}
shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
flag := cmd.Flags().Lookup("yes")
if flag == nil {
t.Fatalf("%s missing --yes flag", shortcut.Command)
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
if !strings.Contains(tips, "pass --yes without asking again") {
t.Fatalf("%s tips missing agent guidance:\n%s", shortcut.Command, tips)
}
}
}
func TestBaseFieldCreateHelpHidesReadGuideFlag(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldCreate.Mount(parent, &cmdutil.Factory{})
@@ -251,20 +270,19 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
name: "record search",
shortcut: BaseRecordSearch,
wantHelp: []string{
"requires keyword/search_fields",
"optional select_fields/view_id/offset/limit",
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`,
"for keyword search only",
"output format: markdown (default) | json",
},
wantTips: []string{
`lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json`,
`"select_fields":["Name","Status"]`,
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"]`,
"Happy path fields: keyword (string), search_fields",
"search_fields length 1-20",
"limit range 1-200 defaults to 10",
"view_id scopes search to records in that view",
"Default output is markdown",
"only for keyword search",
"lark-base record read SOP",
"inventing search JSON",
},
},
{
@@ -311,6 +329,401 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
}
}
func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantTips []string
}{
{
name: "dashboard list",
shortcut: BaseDashboardList,
wantTips: []string{
"Use returned dashboard_id values",
},
},
{
name: "dashboard get",
shortcut: BaseDashboardGet,
wantTips: []string{
"block-level details",
},
},
{
name: "dashboard create",
shortcut: BaseDashboardCreate,
wantTips: []string{
"Record the returned dashboard_id",
},
},
{
name: "dashboard update",
shortcut: BaseDashboardUpdate,
wantTips: []string{},
},
{
name: "dashboard delete",
shortcut: BaseDashboardDelete,
wantTips: []string{
"lark-cli base +dashboard-delete --base-token <base_token> --dashboard-id <dashboard_id> --yes",
"also deletes its blocks",
"pass --yes",
},
},
{
name: "dashboard arrange",
shortcut: BaseDashboardArrange,
wantTips: []string{
"not deterministic or position-specific",
},
},
{
name: "dashboard block list",
shortcut: BaseDashboardBlockList,
wantTips: []string{
"lark-cli base +dashboard-block-list --base-token <base_token> --dashboard-id <dashboard_id>",
"Use returned block_id and type values",
},
},
{
name: "dashboard block get",
shortcut: BaseDashboardBlockGet,
wantTips: []string{
"lark-cli base +dashboard-block-get --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id>",
"metadata such as name, type, layout, and data_config",
"computed chart result",
},
},
{
name: "dashboard block get data",
shortcut: BaseDashboardBlockGetData,
wantTips: []string{
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",
"does not need --dashboard-id",
"computed chart protocol JSON",
},
},
{
name: "dashboard block create",
shortcut: BaseDashboardBlockCreate,
wantTips: []string{
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
`--type text --data-config '{"text":"# Sales Dashboard"}'`,
"+table-list and +field-list",
"not table_id or field_id",
"dashboard-block-data-config.md as the SSOT",
"do not invent data_config from natural language",
"sequentially",
},
},
{
name: "dashboard block update",
shortcut: BaseDashboardBlockUpdate,
wantTips: []string{
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --name "Total Sales"`,
`--data-config '{"series":[{"field_name":"Amount","rollup":"SUM"}]}'`,
"dashboard-block-data-config.md as the SSOT",
"do not invent data_config from natural language",
"Block type cannot be changed",
"top-level keys",
},
},
{
name: "dashboard block delete",
shortcut: BaseDashboardBlockDelete,
wantTips: []string{
"lark-cli base +dashboard-block-delete --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --yes",
"pass --yes",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseWorkflowHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantTips []string
}{
{
name: "workflow list",
shortcut: BaseWorkflowList,
wantTips: []string{
"workflow_id values with wkf prefix",
"auto-paginates",
},
},
{
name: "workflow get",
shortcut: BaseWorkflowGet,
wantTips: []string{
"workflow-id must start with wkf",
"steps may be an empty array",
"Use +workflow-get before +workflow-update",
"lark-base-workflow-schema.md",
},
},
{
name: "workflow create",
shortcut: BaseWorkflowCreate,
wantTips: []string{
"lark-cli base +workflow-create --base-token <base_token> --json @workflow.json",
"client_token is required",
"New workflows are created disabled",
"+table-list and +field-list",
"Step ids must be unique",
"lark-base-workflow-guide.md as the entry guide",
"lark-base-workflow-schema.md as the steps JSON SSOT",
"do not invent steps[].type/data/next/children from natural language",
},
},
{
name: "workflow update",
shortcut: BaseWorkflowUpdate,
wantTips: []string{
"lark-cli base +workflow-update --base-token <base_token> --workflow-id <workflow_id> --json @workflow.json",
"PUT uses full replacement semantics",
"Use +workflow-get first",
"keep title/status/steps fields",
"workflow-id must start with wkf",
"Updating does not enable or disable",
"do not invent steps[].type/data/next/children from natural language",
},
},
{
name: "workflow enable",
shortcut: BaseWorkflowEnable,
wantTips: []string{
"workflow-id must start with wkf",
"does not modify steps",
"New workflows are created disabled",
},
},
{
name: "workflow disable",
shortcut: BaseWorkflowDisable,
wantTips: []string{
"workflow-id must start with wkf",
"does not delete the workflow or its steps",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantHelp []string
}{
{
name: "table create fields",
shortcut: BaseTableCreate,
wantHelp: []string{
`field JSON array for create, e.g. [{"name":"Title","type":"text"}`,
},
},
{
name: "view set filter",
shortcut: BaseViewSetFilter,
wantHelp: []string{
`filter JSON object, e.g. {"logic":"and","conditions":[["Status","==","Todo"]]}`,
},
},
{
name: "view set sort",
shortcut: BaseViewSetSort,
wantHelp: []string{
`sort_config JSON object, e.g. {"sort_config":[{"field":"Priority","desc":true}]}`,
`use {"sort_config":[]} to clear`,
},
},
{
name: "view set group",
shortcut: BaseViewSetGroup,
wantHelp: []string{
`group JSON object with group_config array, e.g. {"group_config":[{"field":"Status","desc":false}]}`,
},
},
{
name: "view set card",
shortcut: BaseViewSetCard,
wantHelp: []string{
`card JSON object, e.g. {"cover_field":"Cover"} or {"cover_field":null} to clear`,
},
},
{
name: "view set timebar",
shortcut: BaseViewSetTimebar,
wantHelp: []string{
`timebar JSON object with start_time, end_time, title, e.g. {"start_time":"Start Date","end_time":"End Date","title":"Name"}`,
},
},
{
name: "view set visible fields",
shortcut: BaseViewSetVisibleFields,
wantHelp: []string{
`visible fields JSON object, e.g. {"visible_fields":["Name","Status"]}`,
},
},
{
name: "form question delete",
shortcut: BaseFormQuestionsDelete,
wantHelp: []string{
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
},
},
{
name: "record search json",
shortcut: BaseRecordSearch,
wantHelp: []string{
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`,
},
},
{
name: "record upsert json",
shortcut: BaseRecordUpsert,
wantHelp: []string{
`record field map JSON object, e.g. {"Name":"Alice","Status":"Todo"}; do not wrap in fields`,
},
},
{
name: "record batch create json",
shortcut: BaseRecordBatchCreate,
wantHelp: []string{
`batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`,
},
},
{
name: "record batch update json",
shortcut: BaseRecordBatchUpdate,
wantHelp: []string{
`batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
for _, want := range tt.wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
})
}
}
func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantTips []string
}{
{
name: "record upsert",
shortcut: BaseRecordUpsert,
wantTips: []string{
"Happy path JSON is a top-level field map",
"Without --record-id this creates a record",
"does not auto-upsert by business key",
"use +field-list to confirm real writable fields",
"do not write system fields, formula, lookup, or attachment fields",
"CellValue happy path: text/phone/url",
"select -> \"Todo\"",
"multi-select -> [\"Tag A\",\"Tag B\"]",
"datetime -> \"2026-03-24 10:00:00\"",
"checkbox -> true/false",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
`location uses {"lng":116.397428,"lat":39.90923}`,
"Do not guess user/chat/linked-record IDs or location coordinates",
"lark-base-cell-value.md",
"do not invent values for fields not covered by the happy path",
},
},
{
name: "record batch create",
shortcut: BaseRecordBatchCreate,
wantTips: []string{
"Happy path fields: fields is the column order",
"rows is an array of row arrays",
"may use null for empty cells",
"use +field-list to confirm real writable fields",
"Batch create supports max 200 rows per call",
"CellValue happy path: text/phone/url",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
"lark-base-cell-value.md",
"do not invent values for fields not covered by the happy path",
},
},
{
name: "record batch update",
shortcut: BaseRecordBatchUpdate,
wantTips: []string{
"Happy path fields: record_id_list is the target record IDs",
"patch is a field map applied unchanged to every target record",
"Do not use +record-batch-update for per-row different values",
"use +field-list to confirm real writable fields",
"Batch update supports max 200 records per call",
"CellValue happy path: text/phone/url",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
"lark-base-cell-value.md",
"do not invent values for fields not covered by the happy path",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
@@ -328,7 +741,7 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
wantTips := []string{
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
"full field-definition PUT semantics",
"Read the current field first with +field-get",

View File

@@ -22,6 +22,9 @@ var BaseDashboardArrange = common.Shortcut{
dashboardIDFlag(true),
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
},
Tips: []string{
"Server-side smart layout is not deterministic or position-specific; use only when the user asks to arrange or beautify a dashboard.",
},
DryRun: dryRunDashboardArrange,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeDashboardArrange(runtime)

View File

@@ -25,10 +25,19 @@ var BaseDashboardBlockCreate = common.Shortcut{
dashboardIDFlag(true),
{Name: "name", Desc: "block name", Required: true},
{Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read dashboard-block-data-config.md before creating.", Required: true},
{Name: "data-config", Desc: "data config JSON object (table_name, series, count_all, group_by, filter, etc.)"},
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
},
Tips: []string{
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Dashboard Note" --type text --data-config '{"text":"# Sales Dashboard"}'`,
"Before creating data-backed blocks, use +table-list and +field-list to confirm real table and field names.",
"data_config uses table and field names, not table_id or field_id.",
"Read dashboard-block-data-config.md as the SSOT for chart templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.",
"Record the returned block_id; block update/delete/get-data commands need it.",
"Create dashboard blocks sequentially; do not parallelize multiple block creates for the same dashboard.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
if runtime.Bool("no-validate") {

View File

@@ -22,6 +22,10 @@ var BaseDashboardBlockDelete = common.Shortcut{
dashboardIDFlag(true),
blockIDFlag(true),
},
Tips: []string{
"lark-cli base +dashboard-block-delete --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --yes",
baseHighRiskYesTip,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id").

View File

@@ -24,6 +24,11 @@ var BaseDashboardBlockGet = common.Shortcut{
blockIDFlag(true),
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
},
Tips: []string{
"lark-cli base +dashboard-block-get --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id>",
"Use this command for block metadata such as name, type, layout, and data_config.",
"Use +dashboard-block-get-data when you need the computed chart result instead of metadata.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{}
if uid := strings.TrimSpace(runtime.Str("user-id-type")); uid != "" {

View File

@@ -23,6 +23,7 @@ var BaseDashboardBlockGetData = common.Shortcut{
},
Tips: []string{
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",
"This command does not need --dashboard-id.",
"Use +dashboard-block-get first when you need block metadata like name, type, or data_config.",
"This command returns computed chart protocol JSON directly, not wrapped block metadata.",
"Text blocks do not have computed chart data; this shortcut is for chart/statistics blocks.",

View File

@@ -21,9 +21,13 @@ var BaseDashboardBlockList = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
dashboardIDFlag(true),
{Name: "page-size", Desc: "page size (max 100)"},
{Name: "page-size", Desc: "page size, default 20, max 100"},
{Name: "page-token", Desc: "pagination token"},
},
Tips: []string{
"lark-cli base +dashboard-block-list --base-token <base_token> --dashboard-id <dashboard_id>",
"Use returned block_id and type values for +dashboard-block-get/update/delete/get-data.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{}
if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" {

View File

@@ -24,10 +24,18 @@ var BaseDashboardBlockUpdate = common.Shortcut{
dashboardIDFlag(true),
blockIDFlag(true),
{Name: "name", Desc: "new block name"},
{Name: "data-config", Desc: "data config JSON. For chart types: table_name, series|count_all, group_by, filter. For text type: text (markdown supported). See dashboard-block-data-config.md for details."},
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
},
Tips: []string{
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --name "Total Sales"`,
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --data-config '{"series":[{"field_name":"Amount","rollup":"SUM"}]}'`,
"Read dashboard-block-data-config.md as the SSOT for data_config templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.",
"Use +dashboard-block-get first to inspect the current data_config before replacing nested values.",
"Block type cannot be changed; delete and recreate the block to change chart type.",
"data_config update merges top-level keys, but each provided key is replaced as a whole.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
if runtime.Bool("no-validate") {

View File

@@ -20,7 +20,10 @@ var BaseDashboardCreate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "name", Desc: "dashboard name", Required: true},
{Name: "theme-style", Desc: "theme style"},
{Name: "theme-style", Desc: "theme style, defaults to platform default when omitted"},
},
Tips: []string{
"Record the returned dashboard_id; dashboard block create/get/update/delete/arrange commands need it.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := map[string]interface{}{}

View File

@@ -21,6 +21,11 @@ var BaseDashboardDelete = common.Shortcut{
baseTokenFlag(true),
dashboardIDFlag(true),
},
Tips: []string{
"lark-cli base +dashboard-delete --base-token <base_token> --dashboard-id <dashboard_id> --yes",
"Deleting a dashboard also deletes its blocks and cannot be recovered.",
baseHighRiskYesTip,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id").

View File

@@ -21,6 +21,9 @@ var BaseDashboardGet = common.Shortcut{
baseTokenFlag(true),
dashboardIDFlag(true),
},
Tips: []string{
"Use +dashboard-block-list or +dashboard-block-get when you need block-level details.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id").

View File

@@ -20,9 +20,12 @@ var BaseDashboardList = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "page-size", Desc: "page size (max 100)"},
{Name: "page-size", Desc: "page size, max 100"},
{Name: "page-token", Desc: "pagination token"},
},
Tips: []string{
"Use returned dashboard_id values for +dashboard-get, +dashboard-block-list, and +dashboard-block-create.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{}
if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" {

View File

@@ -21,7 +21,7 @@ var BaseDashboardUpdate = common.Shortcut{
baseTokenFlag(true),
dashboardIDFlag(true),
{Name: "name", Desc: "new dashboard name"},
{Name: "theme-style", Desc: "theme style"},
{Name: "theme-style", Desc: "theme style, leave empty to keep current theme"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := map[string]interface{}{}

View File

@@ -23,7 +23,8 @@ var BaseFieldCreate = common.Shortcut{
{Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true},
},
Tips: []string{
`Example: --json '{"name":"Status","type":"text"}'`,
`Example text: lark-cli base +field-create --base-token <base_token> --table-id <table_id> --json '{"name":"Status","type":"text"}'`,
`Example select: lark-cli base +field-create --base-token <base_token> --table-id <table_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
"Agent hint: use the lark-base skill's field-create guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -17,7 +17,11 @@ var BaseFieldDelete = common.Shortcut{
Scopes: []string{"base:field:delete"},
AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)},
DryRun: dryRunFieldDelete,
Tips: []string{
baseHighRiskYesTip,
`Example: lark-cli base +field-delete --base-token <base_token> --table-id <table_id> --field-id "Status" --yes`,
},
DryRun: dryRunFieldDelete,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeFieldDelete(runtime)
},

View File

@@ -17,7 +17,12 @@ var BaseFieldGet = common.Shortcut{
Scopes: []string{"base:field:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)},
DryRun: dryRunFieldGet,
Tips: []string{
`Example: lark-cli base +field-get --base-token <base_token> --table-id <table_id> --field-id "Status"`,
"field-id accepts a field ID (fld...) or the field name from the current table.",
"Returns full field configuration; use it as the baseline before +field-update.",
},
DryRun: dryRunFieldGet,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeFieldGet(runtime)
},

View File

@@ -20,7 +20,7 @@ var BaseFieldList = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
},
DryRun: dryRunFieldList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -22,7 +22,11 @@ var BaseFieldSearchOptions = common.Shortcut{
fieldRefFlag(true),
{Name: "keyword", Desc: "keyword for option query"},
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "30", Desc: "pagination size"},
{Name: "limit", Type: "int", Default: "30", Desc: "pagination size, default 30"},
},
Tips: []string{
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <table_id> --field-id "Status" --keyword "Do"`,
"Use only for fields with options, such as select or multi-select fields.",
},
DryRun: dryRunFieldSearchOptions,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -24,8 +24,9 @@ var BaseFieldUpdate = common.Shortcut{
{Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true},
},
Tips: []string{
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
baseHighRiskYesTip,
`Example text: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
`Example select: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`,
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
"Formula and lookup updates require reading the corresponding guide first.",

View File

@@ -38,7 +38,7 @@ func TestParseHelpers(t *testing.T) {
if err != nil || obj["name"] != "demo" {
t.Fatalf("obj=%v err=%v", obj, err)
}
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "lark-base skill") || strings.Contains(err.Error(), "array") {
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "match the documented shape") || strings.Contains(err.Error(), "array") {
t.Fatalf("err=%v", err)
}
if _, err := parseJSONObject(testPC, `null`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
@@ -66,7 +66,7 @@ func TestParseHelpers(t *testing.T) {
if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
t.Fatalf("err=%v", err)
}
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "lark-base skill") {
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "complex JSON/DSL") {
t.Fatalf("err=%v", err)
}
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
@@ -334,11 +334,11 @@ func TestJSONInputHelpers(t *testing.T) {
t.Fatalf("err=%v", err)
}
syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7})
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "lark-base skill") {
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "complex JSON/DSL") {
t.Fatalf("syntaxErr=%v", syntaxErr)
}
typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"})
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "lark-base skill") {
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "complex JSON/DSL") {
t.Fatalf("typeErr=%v", typeErr)
}
}

View File

@@ -0,0 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
const baseHighRiskYesTip = "This is a high-risk write command. If the user explicitly requested it and the target is unambiguous, pass --yes without asking again."

View File

@@ -19,13 +19,14 @@ var BaseRecordBatchCreate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: "batch create JSON object", Required: true},
},
Tips: []string{
`Example: --json '{"fields":["Title","Status"],"rows":[["Task A","Open"],["Task B","Done"]]}'`,
"Agent hint: use the lark-base skill's record-batch-create guide for usage and limits.",
"Agent hint: use lark-base-cell-value.md as the source of truth for each CellValue.",
{Name: "json", Desc: `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`, Required: true},
},
Tips: append([]string{
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch create supports max 200 rows per call.",
"Use the record-batch-create guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},

View File

@@ -19,13 +19,14 @@ var BaseRecordBatchUpdate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: "batch update JSON object", Required: true},
},
Tips: []string{
`Example: --json '{"record_id_list":["recXXX"],"patch":{"Status":"Done"}}'`,
"Agent hint: use the lark-base skill's record-batch-update guide for usage and limits.",
"Agent hint: use lark-base-cell-value.md as the source of truth for each patch CellValue.",
{Name: "json", Desc: `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`, Required: true},
},
Tips: append([]string{
"Happy path fields: record_id_list is the target record IDs; patch is a field map applied unchanged to every target record.",
"Do not use +record-batch-update for per-row different values; call +record-upsert per record or use another supported flow.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch update supports max 200 records per call; use the record-batch-update guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},

View File

@@ -22,6 +22,10 @@ var BaseRecordDelete = common.Shortcut{
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
},
Tips: []string{
baseHighRiskYesTip,
`Example: lark-cli base +record-delete --base-token <base_token> --table-id <table_id> --record-id <record_id_1> --record-id <record_id_2> --yes`,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordSelection(runtime)
},

View File

@@ -21,7 +21,11 @@ var BaseRecordHistoryList = common.Shortcut{
tableRefFlag(true),
recordRefFlag(true),
{Name: "max-version", Type: "int", Desc: "max version for next page"},
{Name: "page-size", Type: "int", Default: "30", Desc: "pagination size"},
{Name: "page-size", Type: "int", Default: "30", Desc: "pagination size, max 50"},
},
Tips: []string{
`Example: lark-cli base +record-history-list --base-token <base_token> --table-id <table_id> --record-id <record_id>`,
"This reads one record's history only; it is not a table-wide audit scan.",
},
DryRun: dryRunRecordHistoryList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -15,6 +15,13 @@ import (
const maxRecordSelectionCount = 200
const maxBatchGetSelectFieldCount = 100
var recordCellValueHappyPathTips = []string{
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}], [{"id":"oc_xxx"}], [{"id":"rec_xxx"}]; location uses {"lng":116.397428,"lat":39.90923}; null clears a cell when allowed.`,
"Do not guess user/chat/linked-record IDs or location coordinates; resolve them first with the relevant contact/im/record lookup flow.",
"Use lark-base-cell-value.md for complex CellValue shapes and special field types; do not invent values for fields not covered by the happy path.",
}
type recordSelection struct {
recordIDs []string
selectFields []string

View File

@@ -20,17 +20,15 @@ var BaseRecordSearch = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: `record search JSON object; requires keyword/search_fields, optional select_fields/view_id/offset/limit`, Required: true},
{Name: "json", Desc: `record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}; for keyword search only`, Required: true},
recordReadFormatFlag(),
},
Tips: []string{
`Example: lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json '{"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}'`,
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"],"select_fields":["<field_id_or_name>"],"view_id":"<view_id_or_name>","offset":0,"limit":10}.`,
`Happy path fields: keyword (string), search_fields (1-20 field names/ids), select_fields (optional projection, <=50), view_id (optional), offset (default 0), limit (default 10, range 1-200).`,
"JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.",
"view_id scopes search to records in that view; when select_fields is omitted, returned fields follow that view's visible fields.",
"Default output is markdown; pass --format json to get the raw JSON envelope.",
"Use +record-search only for keyword search; use a filtered view plus +record-list for structured conditions.",
"Agent hint: follow the lark-base record read SOP for record read routing and limits.",
"Use +record-search only for keyword search; for structured conditions, sorting, Top/Bottom N, or global conclusions, follow the lark-base record read SOP instead of inventing search JSON.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil {

View File

@@ -22,8 +22,9 @@ var BaseRecordShareLinkCreate = common.Shortcut{
{Name: "record-ids", Type: "string_slice", Desc: "record IDs to generate share links for (comma-separated or repeatable, max 100)", Required: true},
},
Tips: []string{
`Single record: --base-token xxx --table-id tblxxx --record-ids recxxx`,
`Multiple records: --base-token xxx --table-id tblxxx --record-ids rec001,rec002,rec003`,
`Example: lark-cli base +record-share-link-create --base-token <base_token> --table-id <table_id> --record-ids <record_id>`,
"Max 100 record IDs per call; duplicate IDs are ignored.",
"Output record_share_links maps record_id to URL; records without permission or missing records may be absent.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordShareBatch(runtime)

View File

@@ -117,6 +117,7 @@ var BaseRecordRemoveAttachment = common.Shortcut{
{Name: "file-token", Type: "string_array", Desc: "attachment file_token to remove from the target cell; repeat to remove multiple attachments; max 50 tokens", Required: true},
},
Tips: []string{
baseHighRiskYesTip,
`Example: lark-cli base +record-remove-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --field-id <attachment_field_id> --file-token <file_token> --yes`,
`Repeat --file-token to remove multiple attachments from the same cell in one call.`,
`This is a high-risk write command and requires --yes.`,

View File

@@ -20,12 +20,14 @@ var BaseRecordUpsert = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
recordRefFlag(false),
{Name: "json", Desc: "record JSON object: Map<FieldNameOrID, CellValue>", Required: true},
},
Tips: []string{
`Example: --json '{"Name":"Alice"}'`,
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
{Name: "json", Desc: `record field map JSON object, e.g. {"Name":"Alice","Status":"Todo"}; do not wrap in fields`, Required: true},
},
Tips: append([]string{
"Happy path JSON is a top-level field map: each key is a real field name or field ID, each value is that field's CellValue.",
"Without --record-id this creates a record; with --record-id this updates that record. It does not auto-upsert by business key.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Use the record-upsert guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},

View File

@@ -20,7 +20,11 @@ var BaseTableCreate = common.Shortcut{
baseTokenFlag(true),
{Name: "name", Desc: "table name", Required: true},
{Name: "view", Desc: "view JSON object/array for create"},
{Name: "fields", Desc: "field JSON array for create"},
{Name: "fields", Desc: `field JSON array for create, e.g. [{"name":"Title","type":"text"},{"name":"Status","type":"select","options":[{"name":"Todo"},{"name":"Done"}]}]`},
},
Tips: []string{
"Before using --fields, read lark-base-field-json.md or rely on the same field JSON shape used by +field-create; do not invent field properties.",
"The first --fields item replaces the default field.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateTableCreate(runtime)

View File

@@ -17,7 +17,12 @@ var BaseTableDelete = common.Shortcut{
Scopes: []string{"base:table:delete"},
AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)},
DryRun: dryRunTableDelete,
Tips: []string{
`Example: lark-cli base +table-delete --base-token <base_token> --table-id "Old Tasks" --yes`,
"table-id accepts a table ID (tbl...) or the table name in the current Base.",
baseHighRiskYesTip,
},
DryRun: dryRunTableDelete,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeTableDelete(runtime)
},

View File

@@ -17,7 +17,11 @@ var BaseTableGet = common.Shortcut{
Scopes: []string{"base:table:read", "base:field:read", "base:view:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)},
DryRun: dryRunTableGet,
Tips: []string{
`Example: lark-cli base +table-get --base-token <base_token> --table-id "Tasks"`,
"table-id accepts a table ID (tbl...) or the table name in the current Base.",
},
DryRun: dryRunTableGet,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeTableGet(runtime)
},

View File

@@ -19,7 +19,7 @@ var BaseTableList = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "50", Desc: "pagination limit"},
{Name: "limit", Type: "int", Default: "50", Desc: "pagination size, range 1-100"},
},
DryRun: dryRunTableList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -19,11 +19,13 @@ var BaseViewCreate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: "view JSON object/array", Required: true},
{Name: "json", Desc: "view JSON object/array; type defaults to grid; type range: grid, kanban, gallery, calendar, gantt", Required: true},
},
Tips: []string{
`Example: --json '{"name":"Main","type":"grid"}'`,
"Agent hint: use the lark-base skill's view-create guide for usage and limits.",
`Example: lark-cli base +view-create --base-token <base_token> --table-id <table_id> --json '{"name":"Main","type":"grid"}'`,
`Minimal: --json '{"name":"Main"}' creates a grid view.`,
"Do not pass form as a view type; form views are managed through form commands.",
`Use +view-set-visible-fields after creation when the user needs a specific field order or visibility.`,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewCreate(runtime)

View File

@@ -17,7 +17,11 @@ var BaseViewDelete = common.Shortcut{
Scopes: []string{"base:view:write_only"},
AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)},
DryRun: dryRunViewDelete,
Tips: []string{
baseHighRiskYesTip,
`Example: lark-cli base +view-delete --base-token <base_token> --table-id <table_id> --view-id "Old View" --yes`,
},
DryRun: dryRunViewDelete,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeViewDelete(runtime)
},

View File

@@ -20,7 +20,7 @@ var BaseViewList = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
},
DryRun: dryRunViewList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -20,11 +20,12 @@ var BaseViewSetCard = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
viewRefFlag(true),
{Name: "json", Desc: "card JSON object", Required: true},
{Name: "json", Desc: `card JSON object, e.g. {"cover_field":"Cover"} or {"cover_field":null} to clear`, Required: true},
},
Tips: []string{
`Example: --json '{"cover_field":"fldCover"}'`,
"Agent hint: use the lark-base skill's view-set-card guide for usage and limits.",
"Supported view types: gallery, kanban.",
"cover_field should be an attachment field id/name, or null to clear.",
"Use +view-get-card first when updating an existing card view configuration.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)

View File

@@ -20,10 +20,9 @@ var BaseViewSetFilter = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
viewRefFlag(true),
{Name: "json", Desc: "filter JSON object", Required: true},
{Name: "json", Desc: `filter JSON object, e.g. {"logic":"and","conditions":[["Status","==","Todo"]]}`, Required: true},
},
Tips: []string{
`Example: --json '{"logic":"and","conditions":[["fldStatus","==","Todo"]]}'`,
"Agent hint: use the lark-base skill's view-set-filter guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -20,11 +20,13 @@ var BaseViewSetGroup = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
viewRefFlag(true),
{Name: "json", Desc: "group JSON object", Required: true},
{Name: "json", Desc: `group JSON object with group_config array, e.g. {"group_config":[{"field":"Status","desc":false}]}; use {"group_config":[]} to clear`, Required: true},
},
Tips: []string{
`Example: --json '{"group_config":[{"field":"fldStatus","desc":false}]}'`,
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
"Supported view types: grid, kanban, gantt.",
"Use a JSON object, not a bare array; grouping fields must be supported by the current view.",
"group_config supports max 3 group items.",
"Use +view-get-group first when modifying an existing grouping configuration.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)

View File

@@ -20,11 +20,13 @@ var BaseViewSetSort = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
viewRefFlag(true),
{Name: "json", Desc: "sort_config JSON object", Required: true},
{Name: "json", Desc: `sort_config JSON object, e.g. {"sort_config":[{"field":"Priority","desc":true}]}; use {"sort_config":[]} to clear; max 10 items`, Required: true},
},
Tips: []string{
`Example: --json '{"sort_config":[{"field":"fldPriority","desc":true}]}'`,
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
"Supported view types: grid, kanban, gallery, gantt.",
"Use a JSON object, not a bare array; sorting fields must be supported by the current view.",
"sort_config supports max 10 sort items.",
"Use +view-get-sort first when modifying an existing sort configuration.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)

View File

@@ -20,11 +20,12 @@ var BaseViewSetTimebar = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
viewRefFlag(true),
{Name: "json", Desc: "timebar JSON object", Required: true},
{Name: "json", Desc: `timebar JSON object with start_time, end_time, title, e.g. {"start_time":"Start Date","end_time":"End Date","title":"Name"}`, Required: true},
},
Tips: []string{
`Example: --json '{"start_time":"fldStart","end_time":"fldEnd","title":"fldTitle"}'`,
"Agent hint: use the lark-base skill's view-set-timebar guide for usage and limits.",
"Supported view types: calendar, gantt.",
"start_time, end_time, and title are required; use date/time fields for start_time and end_time.",
"Use +view-get-timebar first when modifying an existing timebar configuration.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)

View File

@@ -20,11 +20,12 @@ var BaseViewSetVisibleFields = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
viewRefFlag(true),
{Name: "json", Desc: `visible fields JSON object with "visible_fields"`, Required: true},
{Name: "json", Desc: `visible fields JSON object, e.g. {"visible_fields":["Name","Status"]}`, Required: true},
},
Tips: []string{
`Example: --json '{"visible_fields":["fldXXX"]}'`,
"Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.",
"Supported view types: grid, kanban, gallery, calendar, gantt.",
"Use a JSON object, not a bare array; primary field may be forced to the first position by the API.",
"visible_fields controls both visibility and order; include every field that should remain visible.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)

View File

@@ -19,7 +19,15 @@ var BaseWorkflowCreate = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"My Workflow","steps":[...]}`, Required: true},
{Name: "json", Desc: "workflow body JSON; read lark-base-workflow-guide.md and lark-base-workflow-schema.md before constructing steps", Required: true},
},
Tips: []string{
"lark-cli base +workflow-create --base-token <base_token> --json @workflow.json",
"client_token is required and should be unique per create request.",
"New workflows are created disabled; call +workflow-enable after creation when the user wants it active.",
"Before constructing steps, use +table-list and +field-list to confirm real table and field names.",
"Step ids must be unique, and every next/children link must reference an existing step id.",
"Use lark-base-workflow-guide.md as the entry guide and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {

View File

@@ -21,6 +21,10 @@ var BaseWorkflowDisable = common.Shortcut{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
},
Tips: []string{
"workflow-id must start with wkf; do not pass a tbl table ID from the same URL.",
"Disable only changes workflow state; it does not delete the workflow or its steps.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")

View File

@@ -21,6 +21,11 @@ var BaseWorkflowEnable = common.Shortcut{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
},
Tips: []string{
"workflow-id must start with wkf; do not pass a tbl table ID from the same URL.",
"Enable only changes workflow state; it does not modify steps.",
"New workflows are created disabled; enable after creation only when the user wants it active.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")

View File

@@ -20,7 +20,13 @@ var BaseWorkflowGet = common.Shortcut{
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
{Name: "user-id-type", Desc: "user ID type for creator/updater fields", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "user-id-type", Desc: "user ID type for creator/updater fields, default open_id", Enum: []string{"open_id", "union_id", "user_id"}},
},
Tips: []string{
"workflow-id must start with wkf; use +workflow-list if the ID is unknown.",
"steps may be an empty array; that is valid for an unconfigured workflow.",
"Use +workflow-get before +workflow-update, then edit the returned definition and keep fields you do not intend to change.",
"Read lark-base-workflow-schema.md when interpreting or reusing returned steps.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {

View File

@@ -20,7 +20,11 @@ var BaseWorkflowList = common.Shortcut{
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "status", Desc: "filter by status", Enum: []string{"enabled", "disabled"}},
{Name: "page-size", Type: "int", Default: "100", Desc: "page size per request (max 100)"},
{Name: "page-size", Type: "int", Default: "100", Desc: "page size per request, max 100"},
},
Tips: []string{
"Returns workflow_id values with wkf prefix; pass those IDs to +workflow-get/enable/disable/update.",
"This shortcut auto-paginates and returns all matched workflows.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {

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